Merge branch 'feat/desktop-offline' into feat/do-browser-signin

This commit is contained in:
Linus Rath
2026-11-04 03:11:34 +01:00
12 changed files with 478 additions and 29 deletions
+23
View File
@@ -67,3 +67,26 @@ jobs:
- name: Lint
run: npm run lint
# The build the release tarball and a manual install use, with its guard at
# the end. Docker and the Lite images build another way (webpack, export), so
# nothing else would notice this one breaking before a release does.
standalone-build:
name: Standalone build and guard
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
@@ -0,0 +1,72 @@
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { RichTextEditor } from '../rich-text-editor';
import { serializeEditorContent } from '../quoted-html';
// A reply's quoted inline images load while the caret already waits in the
// body, and swapping them in re-sets the editor's content. That threw the caret
// onto the quote as a node, where the first key typed replaced the whole quote
// and the reply went out without the original or its images, or below the quote.
const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const IMAGE = 'data:image/png;base64,iVBORw0KGgo=';
const body = (src: string, below: string) => `<p></p><p data-reply-quote-start="true">On Tuesday, C wrote:</p><div data-quoted-html=""><p>Original text</p><img src="${src}" alt="logo" data-cid="logo@x"></div>${below}`;
async function openReply(below: string) {
let editor: Editor | null = null;
const element = (content: string) => (
<RichTextEditor content={content} onChange={() => {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />
);
const view = render(element(body(PLACEHOLDER, below)));
await waitFor(() => expect(editor).not.toBeNull());
const ed = editor as unknown as Editor;
// Where focus('start') puts it; focus itself waits for a frame.
act(() => { ed.commands.setTextSelection(1); });
return { editor: ed, setContent: (content: string) => view.rerender(element(content)) };
}
const type = (editor: Editor, text: string) => act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); });
describe.each([
['with a line below the quote', '<p></p>'],
['with the quote at the very end', ''],
])('typing in a reply once its inline images have loaded, %s', (_name, below) => {
it('goes above the quote and keeps it and its image', async () => {
const { editor, setContent } = await openReply(below);
setContent(body(IMAGE, below));
type(editor, 'My answer');
const sent = serializeEditorContent(editor);
expect(editor.state.doc.child(0).textContent).toBe('My answer');
expect(sent).toContain('Original text');
expect(sent).toContain(IMAGE);
});
it('carries on where the user was already typing', async () => {
const { editor, setContent } = await openReply(below);
type(editor, 'Hel');
setContent(body(IMAGE, `${below}`).replace('<p></p>', '<p>Hel</p>'));
type(editor, 'lo');
expect(editor.state.doc.child(0).textContent).toBe('Hello');
expect(serializeEditorContent(editor)).toContain('Original text');
});
});
describe('a body replaced by a shorter one', () => {
it('leaves the caret inside it', async () => {
const { editor, setContent } = await openReply('<p>A long line below the quote</p>');
act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); });
setContent('<p>Short</p>');
type(editor, '!');
expect(editor.state.doc.textContent).toContain('Short');
expect(editor.state.doc.textContent).toContain('!');
});
});
+16
View File
@@ -23,6 +23,22 @@ const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
// Atom blocks typing must never replace just because they were left selected.
const ATOM_BLOCKS = new Set(["quotedHtml", "signatureBlock"]);
/**
* Replacing the editor's content throws the caret to where the old selection
* maps to: the quote as a node, where the next key typed replaces the whole
* quote, or the line below it. There is no focus event for the guard below to
* catch: a reply's inline images finish loading while the caret already waits
* in the body. It goes back to where it was, or to the start if that is gone.
*/
export function restoreCaretAfterContentSync(editor: Editor, caret: number): void {
const { doc } = editor.state;
const near = Selection.near(doc.resolve(Math.min(caret, doc.content.size)));
const target = near instanceof TextSelection ? near : Selection.atStart(doc);
if (target instanceof TextSelection && !target.eq(editor.state.selection)) {
editor.view.dispatch(editor.state.tr.setSelection(target));
}
}
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
+3 -1
View File
@@ -24,7 +24,7 @@ import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { ReplyQuoteCollapse } from "@/components/email/reply-quote-collapse";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { QuotedHtml, restoreCaretAfterContentSync, serializeEditorContent } from "@/components/email/quoted-html";
import { keepLoadedParagraphSpacing, paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
@@ -614,7 +614,9 @@ export function RichTextEditor({
if (!editor) return;
const loaded = keepLoadedParagraphSpacing(content);
if (loaded !== serializeEditorContent(editor)) {
const caret = editor.state.selection.from;
editor.commands.setContent(loaded, { emitUpdate: false });
restoreCaretAfterContentSync(editor, caret);
}
}, [content, editor]);
@@ -2,6 +2,7 @@ import { render, renderHook, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useWopiStatus } from '@/hooks/use-wopi-status';
import { WopiEditor } from '@/components/files/wopi-editor';
import { useAuthStore } from '@/stores/auth-store';
// Behind a reverse proxy at a sub-path (e.g. /webmail) a bare
// fetch('/api/...') reaches the mail server instead of the webmail and comes
@@ -26,9 +27,9 @@ afterEach(() => {
describe('WOPI requests under a sub-path mount', () => {
it('probes the editor status under the mount prefix', async () => {
useAuthStore.setState({ isAuthenticated: true, client: {} as never });
renderHook(() => useWopiStatus(true));
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/status');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/webmail/api/wopi/status', undefined));
});
it('launches the editor under the mount prefix', async () => {
+2
View File
@@ -3214,6 +3214,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
// The login holding the message, whose blob this attachment is.
accountId: email.sourceClientAccountId ?? viewingAccountId ?? undefined,
},
});
setComposerMode('forward');
@@ -0,0 +1,137 @@
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
/**
* A reply, forward or forward-as-attachment from a Pro message view names the
* login holding the message. The composer defaults its From to that account and
* copies the original's parts from it; without the login it takes the message
* for the active account's, so the reply goes out from the wrong account, or
* fails on a blobId that account doesn't have (#1511).
*/
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: Record<string, unknown>) => {
viewerProps.current = props;
return null;
},
}));
vi.mock('@/components/error', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
EmailViewerErrorFallback: () => null,
}));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
vi.mock('@/hooks/use-pro-multi-account-identities', async (importOriginal) => ({
...await importOriginal<typeof import('@/hooks/use-pro-multi-account-identities')>(),
useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }),
}));
const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() }));
function selectorStore<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return hook;
}
const auth = vi.hoisted(() => ({
state: { client: null as unknown, activeAccountId: 'login-a', getClientForAccount: () => undefined },
}));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
const MESSAGE = vi.hoisted(() => ({
id: 'eaaaaab',
blobId: 'blob-of-message',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ email: 'sender@example.com', name: 'Sender' }],
to: [{ email: 'b@example.org' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
bodyValues: { '1': { value: '<img src="cid:logo">' } },
textBody: [],
htmlBody: [{ partId: '1', type: 'text/html' }],
attachments: [{ blobId: 'blob-of-logo', name: 'logo.png', type: 'image/png', size: 10, cid: 'logo', disposition: 'inline' }],
}));
vi.mock('@/stores/email-store', () => ({
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client, clientAccountId: auth.state.activeAccountId }),
useEmailStore: selectorStore({
fetchEmailContent: async () => MESSAGE,
deleteEmail: async () => {},
markAsRead: async () => {},
toggleStar: async () => {},
moveToMailbox: async () => {},
setEmailKeywordsLocal: () => {},
mailboxes: [],
emails: [],
selectedEmail: null,
}),
}));
vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({ emailDownloadTemplate: '' }) }));
vi.mock('@/stores/pro-tab-store', () => ({
useProTabStore: selectorStore({ openComposeTab, closeTab: () => {}, updateTabTitle: () => {} }),
}));
import { ProEmailView } from '../pro-email-tab-body';
const ACTIONS = ['onReply', 'onReplyAll', 'onForward', 'onForwardAsAttachment'] as const;
async function open(props: { clientAccountId?: string; accountId?: string; override?: boolean }) {
const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'b@example.org', getAccountId: () => 'jmap-b' };
const { override = true, ...rest } = props;
if (!override) auth.state.client = client;
const view = render(<ProEmailView emailId="eaaaaab" client={override ? client as never : undefined} {...rest} onClose={vi.fn()} />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
return { ...view, client };
}
function composeFrom(action: typeof ACTIONS[number]) {
openComposeTab.mockClear();
(viewerProps.current![action] as () => void)();
expect(openComposeTab).toHaveBeenCalledTimes(1);
return openComposeTab.mock.calls[0][0].replyTo as Record<string, unknown>;
}
describe('compose actions of a Pro message view', () => {
beforeEach(() => {
viewerProps.current = null;
openComposeTab.mockClear();
auth.state.activeAccountId = 'login-a';
auth.state.client = null;
});
it.each(ACTIONS)('%s of another login names that login', async (action) => {
await open({ clientAccountId: 'login-b' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of a shared folder of another login names the login, not the owner', async (action) => {
await open({ clientAccountId: 'login-b', accountId: 'jmap-team' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of the active login names the active login', async (action) => {
await open({ override: false });
expect(composeFrom(action).accountId).toBe('login-a');
});
it('keeps naming the login the message was read in after the active account changes', async () => {
// A message of b isn't foreign while b is active, so it carries no stamp.
auth.state.activeAccountId = 'login-b';
const { rerender, client } = await open({ clientAccountId: 'login-b' });
auth.state.activeAccountId = 'login-a';
rerender(<ProEmailView emailId="eaaaaab" client={client as never} clientAccountId="login-b" onClose={vi.fn()} />);
expect(composeFrom('onReply').accountId).toBe('login-b');
});
});
+16 -5
View File
@@ -111,7 +111,17 @@ export function stampProSource(email: Email, source: EmailBlobContext, loginId:
return { ...email, sourceClientAccountId: login, sourceAccountId: account };
}
function buildReplyContext(email: Email): ProReplyContext {
/**
* The login holding the message, for ProReplyContext.accountId: the composer
* defaults its From to that account and copies the original's parts from it
* (#1104, #1511). email.accountId can't say: it is for display, and a message
* read through client.getEmail() has none.
*/
function replyLogin(email: Email, source: EmailWriteSource): string | undefined {
return email.sourceClientAccountId ?? source.clientAccountId;
}
function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyContext {
return {
from: email.from,
replyToAddresses: email.replyTo,
@@ -122,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext {
subject: email.subject,
...getQuoteBodies(email),
receivedAt: email.receivedAt,
accountId: email.accountId,
accountId: replyLogin(email, source),
attachments: email.attachments,
messageId: email.messageId,
inReplyTo: email.inReplyTo,
@@ -285,7 +295,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'reply',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
initialDraftText: draftText,
@@ -305,7 +315,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'replyAll',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
@@ -316,7 +326,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'forward',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
@@ -355,6 +365,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
accountId: replyLogin(email, writeSource),
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,
+140
View File
@@ -0,0 +1,140 @@
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// /api/wopi/status needs the sign-in context cookie. The mail and files views
// can mount before it exists, so a 401 there says nothing about the editor and
// must not leave office editing off until the next page load (#1518).
const ENABLED = { enabled: true, editExtensions: ['docx', 'odt'], viewExtensions: ['pdf'], newExtensions: ['docx'] };
const answer = (status: number, body: unknown = {}) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
let fetchMock: Mock<(url: string, init?: RequestInit) => Promise<unknown>>;
let useWopiStatus: typeof import('@/hooks/use-wopi-status').useWopiStatus;
let useAuthStore: typeof import('@/stores/auth-store').useAuthStore;
const signIn = (client: object) => useAuthStore.setState({ isAuthenticated: true, client: client as never });
beforeEach(async () => {
fetchMock = vi.fn();
// Only the status route counts: the auth store asks for other things on import.
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => (
url.includes('/api/wopi/status') ? fetchMock(url, init) : Promise.resolve(answer(404))
));
// The hook shares its answer through module state, like a page load does.
vi.resetModules();
({ useWopiStatus } = await import('@/hooks/use-wopi-status'));
({ useAuthStore } = await import('@/stores/auth-store'));
useAuthStore.setState({ isAuthenticated: false, client: null });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('useWopiStatus', () => {
it('asks nothing before sign-in, and asks once sign-in is done', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
const { result } = renderHook(() => useWopiStatus(true));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
signIn({});
await waitFor(() => expect(result.current?.enabled).toBe(true));
expect(result.current?.editExtensions).toContain('docx');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('asks nothing while the persisted flag says signed in but no client is connected yet', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
useAuthStore.setState({ isAuthenticated: true, client: null });
renderHook(() => useWopiStatus(true));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
});
it('turns editing on when a 401 is followed by the real client taking over', async () => {
// A local copy is on screen before the restore writes the context cookie.
fetchMock.mockResolvedValueOnce(answer(401)).mockResolvedValue(answer(200, ENABLED));
signIn({ kind: 'local copy' });
const { result } = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current?.enabled).toBe(false);
signIn({ kind: 'real' });
await waitFor(() => expect(result.current?.enabled).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not share a 401 still in flight with the client that took over', async () => {
let finishFirst: (value: unknown) => void = () => {};
fetchMock
.mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; }))
.mockResolvedValue(answer(200, ENABLED));
signIn({ kind: 'local copy' });
const { result } = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
signIn({ kind: 'real' });
await waitFor(() => expect(result.current?.enabled).toBe(true));
finishFirst(answer(401));
// The late refusal does not take the answer back, for this view or the next.
await new Promise((resolve) => setTimeout(resolve));
expect(result.current?.enabled).toBe(true);
const next = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(next.result.current?.enabled).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it.each([401, 500, 502])('asks again on the next mount after a %i', async (status) => {
fetchMock.mockResolvedValueOnce(answer(status)).mockResolvedValue(answer(200, ENABLED));
signIn({});
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current).not.toBeNull());
expect(first.result.current?.enabled).toBe(false);
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
});
it('keeps a real answer for the page, across views and account switches', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
signIn({ account: 'a' });
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current?.enabled).toBe(true));
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
signIn({ account: 'b' });
await waitFor(() => expect(useAuthStore.getState().client).toEqual({ account: 'b' }));
await new Promise((resolve) => setTimeout(resolve));
expect(first.result.current?.enabled).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps a real "no editor" answer too, so mail views do not keep asking', async () => {
fetchMock.mockResolvedValue(answer(200, { enabled: false }));
signIn({});
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current?.enabled).toBe(false));
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(false));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('asks nothing while the caller has it switched off', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
signIn({});
const { result } = renderHook(() => useWopiStatus(false));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current).toBeNull();
});
});
+39 -18
View File
@@ -3,11 +3,12 @@
import { useEffect, useState } from "react";
import { IS_LITE } from "@/lib/lite";
import { apiFetch } from "@/lib/browser-navigation";
import { useAuthStore } from "@/stores/auth-store";
/**
* Whether a WOPI document editor is configured for this deployment and which
* file extensions it handles (#425). Fetched once per page load and shared
* across consumers via a module-level promise.
* file extensions it handles (#425). The answer is the same for every account,
* so the first real one is kept for the page load and shared across consumers.
*/
export interface WopiStatus {
enabled: boolean;
@@ -19,41 +20,61 @@ export interface WopiStatus {
const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtensions: [], newExtensions: [] };
let statusPromise: Promise<WopiStatus> | null = null;
let known: WopiStatus | null = null;
let pending: Promise<WopiStatus> | null = null;
let pendingClient: unknown = null;
function fetchWopiStatus(): Promise<WopiStatus> {
function fetchWopiStatus(client: unknown): Promise<WopiStatus> {
// The WOPI bridge is a server feature; the static build never has an editor.
if (IS_LITE) return Promise.resolve(DISABLED);
if (!statusPromise) {
statusPromise = apiFetch("/api/wopi/status")
.then((res) => (res.ok ? res.json() : DISABLED))
.then((data) => ({
if (known) return Promise.resolve(known);
// A request sent for an earlier client may be refused for lack of the sign-in
// context this one brought, so only the same client shares it.
if (pending && pendingClient === client) return pending;
const request: Promise<WopiStatus> = apiFetch("/api/wopi/status")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
known = {
enabled: !!data?.enabled,
editExtensions: Array.isArray(data?.editExtensions) ? data.editExtensions : [],
viewExtensions: Array.isArray(data?.viewExtensions) ? data.viewExtensions : [],
newExtensions: Array.isArray(data?.newExtensions) ? data.newExtensions : [],
}))
.catch(() => {
statusPromise = null; // allow a retry on the next mount
return DISABLED;
});
}
return statusPromise;
};
return known;
})
// A refusal or an outage says nothing about the editor (#1518): show no
// editor for now and ask again on the next mount or client.
.catch(() => DISABLED)
.finally(() => {
if (pending === request) pending = null;
});
pending = request;
pendingClient = client;
return request;
}
export function useWopiStatus(enabled: boolean): WopiStatus | null {
const [status, setStatus] = useState<WopiStatus | null>(null);
// The route needs the sign-in context cookie, which is written before the
// store holds a connected client. `isAuthenticated` alone comes back from
// storage on load, and a local copy's client gives way to the real one once
// the restore wrote the cookie, so this follows the client itself (#1518).
const client = useAuthStore((state) => (state.isAuthenticated ? state.client : null));
useEffect(() => {
if (!enabled) return;
if (!enabled || !client) return;
let cancelled = false;
fetchWopiStatus().then((s) => {
fetchWopiStatus(client).then((s) => {
if (!cancelled) setStatus(s);
});
return () => {
cancelled = true;
};
}, [enabled]);
}, [enabled, client]);
return status;
}
+23 -1
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
const ROOT = path.resolve(__dirname, '../../..');
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
@@ -112,6 +112,28 @@ describe('the flag is fixed at build time', () => {
}
});
/** Compiles the trust check alone, kept as an export the way Turbopack keeps it. */
async function trustCheckOfBuild(flag: string): Promise<string> {
const result = await build({
entryPoints: [path.join(ROOT, 'lib/desktop/trusted-servers.ts')],
bundle: true,
write: false,
minify: true,
format: 'esm',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
});
return result.outputFiles[0].text;
}
it('a server build holds no trust path, even when a bundler keeps the unused export', async () => {
expect(await trustCheckOfBuild('')).not.toContain(DESKTOP_TRUST_MARKER);
});
it('a desktop build still holds it', async () => {
expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER);
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
+4 -2
View File
@@ -7,8 +7,9 @@
* or the environment can write it, so script in a page can't aim the login
* routes at the LAN.
*
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
* whole thing away. scripts/standalone-guard.mjs checks that it did.
* Callers check the flag too, but Turbopack keeps an imported export after the
* call is gone. So the check also starts here, spelled out: every bundler then
* drops the body from a server build. scripts/standalone-guard.mjs checks it did.
*/
type TrustedOrigins = ReadonlySet<string>;
@@ -25,6 +26,7 @@ function originOf(url: string): string | null {
}
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false;
if (!url) return false;
const origin = originOf(url);
if (!origin) return false;