From cd6593c6592cd90685c3ae9e6d24ff6c70a40ead Mon Sep 17 00:00:00 2001 From: keepClamDown <59951516+keepClamDown@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:33:58 +0800 Subject: [PATCH] fix(ios): ios local access limit for self-hosted workspace (#15338) ## Summary - Route iOS nbstore worker token reads through the main-thread MessagePort and skip Capacitor Auth on `/socket.io` polling so self-host WebSocket/XHR sync no longer hangs on Connect timeout. - Harden workspace `flavour:id` routing, DocSyncPeer abort/status handling, and `resetSync` so local selfhost edits push and Mac browsers can receive them. - Soften root-doc readiness waits and session-exchange throttling to keep mobile selfhost sign-in/sync stable under retries. ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Improved workspace switching across local and remote environments, preserving workspace type and server context. * Added support for page navigation with query parameters. * Added iOS local-network permission messaging for self-hosted workspaces. * **Bug Fixes** * Improved document synchronization, reset handling, prioritized document refreshes, and retry behavior. * Prevented authentication headers and refresh attempts for socket connection requests. * Improved workspace reopening and routing when multiple workspace types share an ID. * Fixed handling of unlimited data query limits. --------- Co-authored-by: DarkSky --- .../common/nbstore/src/__tests__/sync.spec.ts | 61 ++++++++++++++++- .../nbstore/src/impls/sqlite/indexer/index.ts | 9 ++- packages/common/nbstore/src/sync/doc/index.ts | 62 ++++++++++++++--- packages/common/nbstore/src/sync/doc/peer.ts | 36 +++++++--- packages/frontend/apps/ios/App/App/Info.plist | 2 + .../apps/ios/App/App/InfoPlist.xcstrings | 12 ++++ packages/frontend/apps/ios/src/app.tsx | 66 ++++++++++++------- .../apps/mobile-shared/src/auth/auth.spec.ts | 48 ++++++++++++++ .../apps/mobile-shared/src/auth/request.ts | 40 +++++++++-- .../components/hooks/use-navigate-helper.ts | 33 ++++++++-- .../components/workspace-selector/menu.tsx | 41 +++++++++--- .../core/src/mobile/pages/workspace/index.tsx | 60 ++++++++++++++--- .../src/mobile/pages/workspace/layout.tsx | 1 + .../modules/workbench/view/browser-adapter.ts | 37 ++++++++++- .../src/modules/workspace/services/repo.ts | 18 +++-- 15 files changed, 448 insertions(+), 78 deletions(-) diff --git a/packages/common/nbstore/src/__tests__/sync.spec.ts b/packages/common/nbstore/src/__tests__/sync.spec.ts index 12b7c9d45f..af981b2309 100644 --- a/packages/common/nbstore/src/__tests__/sync.spec.ts +++ b/packages/common/nbstore/src/__tests__/sync.spec.ts @@ -113,6 +113,12 @@ class TestDocStorage implements DocStorage { } } +class TimestampBlindDocStorage extends IndexedDBDocStorage { + override async getDocTimestamps(): Promise { + return {}; + } +} + class PermissionDeniedRemoteDocStorage implements DocStorage { readonly storageType = 'doc' as const; readonly connection = new DummyConnection(); @@ -306,7 +312,7 @@ test('doc', async () => { type: 'workspace', }); - const peerBDoc = new IndexedDBDocStorage({ + const peerBDoc = new TimestampBlindDocStorage({ id: 'ws1', flavour: 'b', type: 'workspace', @@ -340,6 +346,26 @@ test('doc', async () => { docId: 'doc1', bin: update, }); + const prioritizedDocId = 'prioritized-doc'; + const localPrioritizedDoc = new YDoc(); + localPrioritizedDoc.getMap('test').set('local', true); + const localPrioritizedClock = await peerA.get('doc').pushDocUpdate({ + docId: prioritizedDocId, + bin: encodeStateAsUpdate(localPrioritizedDoc), + }); + await peerASync.setPeerPushedClock('b', localPrioritizedClock); + const remotePrioritizedDoc = new YDoc(); + remotePrioritizedDoc.getMap('test').set('remote', true); + await peerB.get('doc').pushDocUpdate({ + docId: prioritizedDocId, + bin: encodeStateAsUpdate(remotePrioritizedDoc), + }); + const rootDoc = new YDoc(); + rootDoc.getMap('meta').set('name', 'Self-host workspace'); + await peerB.get('doc').pushDocUpdate({ + docId: 'ws1', + bin: encodeStateAsUpdate(rootDoc), + }); const sync = new Sync({ local: peerA, @@ -348,6 +374,7 @@ test('doc', async () => { c: peerC, }, }); + const removeRootPriority = sync.doc.addPriority('ws1', 100); sync.start(); await new Promise(resolve => setTimeout(resolve, 1000)); @@ -366,8 +393,33 @@ test('doc', async () => { hello: 'world', }, }); + + const root = await peerA.get('doc').getDoc('ws1'); + expectYjsEqual(root!.bin, { + meta: { + name: 'Self-host workspace', + }, + }); + + const prioritized = await peerA.get('doc').getDoc(prioritizedDocId); + expectYjsEqual(prioritized!.bin, { + test: { + local: true, + }, + }); } + const removeDocPriority = sync.doc.addPriority(prioritizedDocId, 100); + await vi.waitFor(async () => { + const prioritized = await peerA.get('doc').getDoc(prioritizedDocId); + expectYjsEqual(prioritized!.bin, { + test: { + local: true, + remote: true, + }, + }); + }); + doc.getMap('test').set('foo', 'bar'); const update2 = encodeStateAsUpdate(doc); await peerC.get('doc').pushDocUpdate({ @@ -394,6 +446,13 @@ test('doc', async () => { }, }); } + + removeDocPriority(); + removeRootPriority(); + sync.stop(); + peerA.disconnect(); + peerB.disconnect(); + peerC.disconnect(); }); test('blob', async () => { diff --git a/packages/common/nbstore/src/impls/sqlite/indexer/index.ts b/packages/common/nbstore/src/impls/sqlite/indexer/index.ts index 4c6af67132..659906d9e4 100644 --- a/packages/common/nbstore/src/impls/sqlite/indexer/index.ts +++ b/packages/common/nbstore/src/impls/sqlite/indexer/index.ts @@ -24,6 +24,7 @@ import { import { createNode } from './node-builder'; const SQLITE_INDEXER_VERSION_OFFSET = 1; +const NATIVE_INDEXER_MAX_LIMIT = 0xffffffff; export class SqliteIndexerStorage extends IndexerStorageBase { static readonly identifier = 'SqliteIndexerStorage'; @@ -83,7 +84,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase { String(table), toNativeQuery(query), String(field), - limit, + toNativeLimit(limit), skip, options?.hits ? toNativeOptions(options.hits, hitLimit, hitSkip) @@ -240,7 +241,7 @@ function toNativeOptions( ): NativeIndexSearchOptions { const highlights = options?.highlights?.map(item => String(item.field)) ?? []; return { - limit, + limit: toNativeLimit(limit), offset, fields: [ ...new Set([...(options?.fields?.map(String) ?? []), ...highlights]), @@ -248,3 +249,7 @@ function toNativeOptions( highlights, }; } + +function toNativeLimit(limit: number) { + return limit === Infinity ? NATIVE_INDEXER_MAX_LIMIT : limit; +} diff --git a/packages/common/nbstore/src/sync/doc/index.ts b/packages/common/nbstore/src/sync/doc/index.ts index 40fae0f82b..d4412e3f96 100644 --- a/packages/common/nbstore/src/sync/doc/index.ts +++ b/packages/common/nbstore/src/sync/doc/index.ts @@ -34,6 +34,8 @@ export interface DocSyncDocState { errorMessage: string | null; } +const RESET_SYNC_CONNECT_TIMEOUT_MS = 30_000; + export interface DocSync { readonly state$: Observable; docState$(docId: string): Observable; @@ -50,6 +52,8 @@ export class DocSyncImpl implements DocSync { new DocSyncPeer(peerId, this.storages.local, this.sync, remote) ); private abort: AbortController | null = null; + private running: Promise = Promise.resolve(); + private resetting: Promise | null = null; private readonly _state$ = combineLatest( this.peers.map(peer => peer.peerState$) @@ -155,12 +159,16 @@ export class DocSyncImpl implements DocSync { if (this.abort) { this.abort.abort(MANUALLY_STOP); } + const previous = this.running; const abort = new AbortController(); this.abort = abort; - Promise.allSettled( - this.peers.map(peer => peer.mainLoop(abort.signal)) - ).catch(error => { - console.error(error); + this.running = previous.then(async () => { + if (abort.signal.aborted) { + return; + } + await Promise.allSettled( + this.peers.map(peer => peer.mainLoop(abort.signal)) + ); }); } @@ -174,12 +182,50 @@ export class DocSyncImpl implements DocSync { return () => undo.forEach(fn => fn()); } - async resetSync() { + resetSync() { + if (this.resetting) { + return this.resetting; + } + const resetting = this.performReset().finally(() => { + if (this.resetting === resetting) { + this.resetting = null; + } + }); + this.resetting = resetting; + return resetting; + } + + private async performReset() { const running = this.abort !== null; + const activeRun = this.running; + const shouldConnectSyncStorage = + this.sync.connection.status === 'idle' || + this.sync.connection.status === 'closed'; this.stop(); - await this.sync.clearClocks(); - if (running) { - this.start(); + await activeRun; + if (shouldConnectSyncStorage) { + this.sync.connection.connect(); + } + const abort = new AbortController(); + const timeoutId = setTimeout(() => { + abort.abort(new Error('Connect to remote timeout')); + }, RESET_SYNC_CONNECT_TIMEOUT_MS) as ReturnType & { + unref?: () => void; + }; + timeoutId.unref?.(); + try { + await this.sync.connection.waitForConnected(abort.signal); + await this.sync.clearClocks(); + } catch (error) { + console.error('Failed to reset sync', error); + throw error; + } finally { + clearTimeout(timeoutId); + if (running) { + this.start(); + } else if (shouldConnectSyncStorage) { + this.sync.connection.disconnect(); + } } } } diff --git a/packages/common/nbstore/src/sync/doc/peer.ts b/packages/common/nbstore/src/sync/doc/peer.ts index 687f8777c8..e5e3bb3ca6 100644 --- a/packages/common/nbstore/src/sync/doc/peer.ts +++ b/packages/common/nbstore/src/sync/doc/peer.ts @@ -280,9 +280,14 @@ export class DocSyncPeer { (await this.syncMetadata.getPeerPulledRemoteClock(this.peerId, docId)) ?.timestamp ?? null; const remoteClock = this.status.remoteClocks.get(docId); + const hasRemoteClock = remoteClock.getTime() > 0; + const hasPulled = pulled !== null && pulled.getTime() > 0; if ( - remoteClock && - (pulled === null || pulled.getTime() < remoteClock.getTime()) + hasRemoteClock + ? !hasPulled || + (pulled !== null && pulled.getTime() < remoteClock.getTime()) + : (this.prioritySettings.get(docId) ?? 0) > 0 && + (!clock || !hasPulled) ) { await this.jobs.pull(docId, signal); } @@ -503,10 +508,7 @@ export class DocSyncPeer { if (!this.status.docs.has(docId)) { this.status.docs.add(docId); this.statusUpdatedSubject$.next(docId); - this.schedule({ - type: 'connect', - docId, - }); + this.schedule({ type: 'connect', docId }); } }, }; @@ -756,6 +758,11 @@ export class DocSyncPeer { for (const docId of this.status.remoteClocks.keys()) { this.actions.addDoc(docId); } + for (const [docId, priority] of this.prioritySettings) { + if (priority > 0) { + this.actions.addDoc(docId); + } + } // begin to process jobs @@ -890,13 +897,22 @@ export class DocSyncPeer { addPriority(id: string, priority: number) { const oldPriority = this.prioritySettings.get(id) ?? 0; - this.prioritySettings.set(id, priority); - this.status.jobDocQueue.setPriority(id, oldPriority + priority); + const newPriority = oldPriority + priority; + this.prioritySettings.set(id, newPriority); + this.status.jobDocQueue.setPriority(id, newPriority); + if (oldPriority <= 0 && newPriority > 0 && this.status.syncing) { + if (!this.status.docs.has(id)) { + this.actions.addDoc(id); + } else { + this.schedule({ type: 'connect', docId: id }); + } + } return () => { const currentPriority = this.prioritySettings.get(id) ?? 0; - this.prioritySettings.set(id, currentPriority - priority); - this.status.jobDocQueue.setPriority(id, currentPriority - priority); + const restoredPriority = currentPriority - priority; + this.prioritySettings.set(id, restoredPriority); + this.status.jobDocQueue.setPriority(id, restoredPriority); }; } diff --git a/packages/frontend/apps/ios/App/App/Info.plist b/packages/frontend/apps/ios/App/App/Info.plist index 4f7c2c1952..97b36d3bce 100644 --- a/packages/frontend/apps/ios/App/App/Info.plist +++ b/packages/frontend/apps/ios/App/App/Info.plist @@ -39,6 +39,8 @@ NSCameraUsageDescription AFFiNE requires access to the camera to capture images and insert them into your documents + NSLocalNetworkUsageDescription + AFFiNE needs local network access to connect to your self-hosted workspace on this device's Wi-Fi network. NSPhotoLibraryUsageDescription AFFiNE requires access to select photos from your photo library and insert them into your documents NSUserTrackingUsageDescription diff --git a/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings b/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings index 51ac029a0f..000a0fee9b 100644 --- a/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings +++ b/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings @@ -37,6 +37,18 @@ } } }, + "NSLocalNetworkUsageDescription" : { + "comment" : "Privacy - Local Network Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "AFFiNE needs local network access to connect to your self-hosted workspace on this device's Wi-Fi network." + } + } + } + }, "NSPhotoLibraryUsageDescription" : { "comment" : "Privacy - Photo Library Usage Description", "extractionState" : "extracted_with_value", diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index dcab19ea8a..9e161641fc 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -335,15 +335,31 @@ registerNativeImageFilesPicker(async () => { }); // ------ some apis for native ------ -(window as any).getCurrentServerBaseUrl = () => { +const getCurrentServerForNative = () => { const globalContextService = frameworkProvider.get(GlobalContextService); - const currentServerId = globalContextService.globalContext.serverId.get(); + const globalContext = globalContextService.globalContext; + const currentServerId = globalContext.serverId.get(); + const currentWorkspaceFlavour = globalContext.workspaceFlavour.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); - const currentServer = + + if (currentWorkspaceFlavour && currentWorkspaceFlavour !== 'local') { + const workspaceServer = serversService.server$( + currentWorkspaceFlavour + ).value; + if (workspaceServer) { + return workspaceServer; + } + } + + return ( (currentServerId ? serversService.server$(currentServerId).value : null) ?? - defaultServerService.server; - return currentServer.baseUrl; + defaultServerService.server + ); +}; + +(window as any).getCurrentServerBaseUrl = () => { + return getCurrentServerForNative().baseUrl; }; (window as any).getCurrentI18nLocale = () => { return I18n.language; @@ -365,11 +381,15 @@ registerNativeImageFilesPicker(async () => { }; (window as any).waitForSelectedSources = async (documentIds: string[]) => { const globalContextService = frameworkProvider.get(GlobalContextService); - const currentWorkspaceId = - globalContextService.globalContext.workspaceId.get(); + const globalContext = globalContextService.globalContext; + const currentWorkspaceId = globalContext.workspaceId.get(); + const currentWorkspaceFlavour = globalContext.workspaceFlavour.get(); const workspacesService = frameworkProvider.get(WorkspacesService); const workspaceRef = currentWorkspaceId - ? workspacesService.openByWorkspaceId(currentWorkspaceId) + ? workspacesService.openByWorkspaceId( + currentWorkspaceId, + currentWorkspaceFlavour + ) : null; if (!workspaceRef) { throw new Error('Current workspace is unavailable'); @@ -408,13 +428,7 @@ registerNativeImageFilesPicker(async () => { return true; }; const getCurrentNativeSignInContext = () => { - const globalContextService = frameworkProvider.get(GlobalContextService); - const currentServerId = globalContextService.globalContext.serverId.get(); - const serversService = frameworkProvider.get(ServersService); - const defaultServerService = frameworkProvider.get(DefaultServerService); - const currentServer = - (currentServerId ? serversService.server$(currentServerId).value : null) ?? - defaultServerService.server; + const currentServer = getCurrentServerForNative(); const authService = currentServer.scope.get(AuthService); return { authService, currentServer }; }; @@ -531,12 +545,16 @@ const showNativeSignIn = async () => { }; (window as any).getCurrentDocContentInMarkdown = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); - const currentWorkspaceId = - globalContextService.globalContext.workspaceId.get(); - const currentDocId = globalContextService.globalContext.docId.get(); + const globalContext = globalContextService.globalContext; + const currentWorkspaceId = globalContext.workspaceId.get(); + const currentWorkspaceFlavour = globalContext.workspaceFlavour.get(); + const currentDocId = globalContext.docId.get(); const workspacesService = frameworkProvider.get(WorkspacesService); const workspaceRef = currentWorkspaceId - ? workspacesService.openByWorkspaceId(currentWorkspaceId) + ? workspacesService.openByWorkspaceId( + currentWorkspaceId, + currentWorkspaceFlavour + ) : null; if (!workspaceRef) { return; @@ -589,11 +607,15 @@ const showNativeSignIn = async () => { title: string ) => { const globalContextService = frameworkProvider.get(GlobalContextService); - const currentWorkspaceId = - globalContextService.globalContext.workspaceId.get(); + const globalContext = globalContextService.globalContext; + const currentWorkspaceId = globalContext.workspaceId.get(); + const currentWorkspaceFlavour = globalContext.workspaceFlavour.get(); const workspacesService = frameworkProvider.get(WorkspacesService); const workspaceRef = currentWorkspaceId - ? workspacesService.openByWorkspaceId(currentWorkspaceId) + ? workspacesService.openByWorkspaceId( + currentWorkspaceId, + currentWorkspaceFlavour + ) : null; try { diff --git a/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts b/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts index 7456bca365..68f87a7975 100644 --- a/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts +++ b/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts @@ -96,6 +96,30 @@ describe('shouldRefreshAccessToken', () => { }); describe('auth request fetch', () => { + test.each([ + ['/socket.io', true], + ['/socket.io/', true], + ['/socket.io/?EIO=4&transport=polling', true], + ['/socket.ioevil', false], + ])('handles the socket auth boundary for %s', async (path, skipped) => { + const provider = { + getValidAccessToken: vi.fn(async () => 'access-token'), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + const rawFetch = vi.fn(async request => { + expect((request as Request).headers.has('Authorization')).toBe(!skipped); + return new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + }); + + await createAuthFetch(provider, rawFetch)(`https://example.com${path}`); + + expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1); + expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1); + }); + test('injects the endpoint token', async () => { const provider = { getValidAccessToken: vi.fn(async () => 'access-token'), @@ -164,6 +188,30 @@ describe('auth request fetch', () => { }); describe('auth request XMLHttpRequest', () => { + test.each([ + ['/socket.io', true], + ['/socket.io/', true], + ['/socket.io/?EIO=4&transport=polling', true], + ['/socket.ioevil', false], + ])('handles the socket auth boundary for %s', async (path, skipped) => { + const xhrCalls = stubXMLHttpRequest(); + const provider = { + getValidAccessToken: vi.fn(async () => 'access-token'), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + installAuthRequestProxy(provider); + const xhr = new XMLHttpRequest(); + + xhr.open('GET', `https://example.com${path}`); + xhr.send(); + await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce()); + xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' })); + await Promise.resolve(); + + expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1); + expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1); + }); + test('does not send after abort while waiting for a token', async () => { let resolveToken: (token: string | null) => void = () => {}; const token = new Promise(resolve => { diff --git a/packages/frontend/apps/mobile-shared/src/auth/request.ts b/packages/frontend/apps/mobile-shared/src/auth/request.ts index f54ead9387..d20a04a590 100644 --- a/packages/frontend/apps/mobile-shared/src/auth/request.ts +++ b/packages/frontend/apps/mobile-shared/src/auth/request.ts @@ -19,6 +19,30 @@ function authEndpointForUrl(url: string | URL) { } } +function shouldSkipStoredAuthToken(url: string | URL) { + try { + const { pathname } = new URL( + url, + globalThis.location?.origin ?? 'http://localhost' + ); + if (pathname === '/socket.io' || pathname.startsWith('/socket.io/')) { + return true; + } + return [ + '/api/auth/captcha', + '/api/auth/magic-link', + '/api/auth/open-app/sign-in', + '/api/auth/preflight', + '/api/auth/session/exchange', + '/api/auth/sign-in', + '/api/oauth/callback', + '/api/oauth/preflight', + ].includes(pathname); + } catch { + return false; + } +} + export function createAuthFetch( provider: AuthRequestProvider, rawFetch: typeof globalThis.fetch @@ -26,7 +50,8 @@ export function createAuthFetch( return async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init); const retry = request.clone(); - const endpoint = authEndpointForUrl(request.url); + const skipStoredAuth = shouldSkipStoredAuthToken(request.url); + const endpoint = skipStoredAuth ? null : authEndpointForUrl(request.url); const token = endpoint ? await provider.getValidAccessToken(endpoint) : null; @@ -70,6 +95,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) { private requestBody?: Document | XMLHttpRequestBodyInit | null; private replaying = false; private hasReplayed = false; + private skipStoredAuth = false; private sendVersion = 0; constructor() { @@ -87,6 +113,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) { this.status !== 401 || this.replaying || this.hasReplayed || + this.skipStoredAuth || !this.request?.async ) { return; @@ -123,6 +150,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) { this.requestBody = undefined; this.replaying = false; this.hasReplayed = false; + this.skipStoredAuth = shouldSkipStoredAuthToken(url.toString()); xhrRequestUrls.set(this, url.toString()); return super.open( method, @@ -141,9 +169,11 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) { override send(body?: Document | XMLHttpRequestBodyInit | null): void { this.requestBody = body; const requestUrl = xhrRequestUrls.get(this); - const endpoint = authEndpointForUrl( - requestUrl ?? globalThis.location.href - ); + const targetUrl = requestUrl ?? globalThis.location.href; + this.skipStoredAuth = shouldSkipStoredAuthToken(targetUrl); + const endpoint = this.skipStoredAuth + ? null + : authEndpointForUrl(targetUrl); const sendVersion = this.sendVersion; const sendWithToken = (token: string | null) => { @@ -168,7 +198,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) { private async replayWithFreshToken() { const request = this.request; - if (!request) return this.failReplay(); + if (!request || this.skipStoredAuth) return this.failReplay(); const endpoint = authEndpointForUrl(request.url); if (!endpoint) return this.failReplay(); const sendVersion = this.sendVersion; diff --git a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts index 4e42084e27..3aabbc3b66 100644 --- a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts @@ -22,6 +22,16 @@ export type WorkspaceSettingsRouteOptions = { scrollAnchor?: string; }; +export type NavigateToPageOptions = Omit & { + search?: string | URLSearchParams; +}; + +const normalizeSearch = (search?: string | URLSearchParams) => { + const value = search?.toString(); + if (!value) return ''; + return value.startsWith('?') ? value : `?${value}`; +}; + export function buildWorkspaceSettingsPath( workspaceId: string, options?: WorkspaceSettingsRouteOptions @@ -84,11 +94,17 @@ export function useNavigateHelper() { ( workspaceId: string, pageId: string, - logic: RouteLogic = RouteLogic.PUSH + logic: RouteLogic = RouteLogic.PUSH, + options?: NavigateToPageOptions ) => { - return navigate(`/workspace/${workspaceId}/${pageId}`, { - replace: logic === RouteLogic.REPLACE, - }); + const { search, ...navigateOptions } = options ?? {}; + return navigate( + `/workspace/${workspaceId}/${pageId}${normalizeSearch(search)}`, + { + ...navigateOptions, + replace: logic === RouteLogic.REPLACE, + } + ); }, [navigate] ); @@ -176,8 +192,13 @@ export function useNavigateHelper() { ); const openPage = useCallback( - (workspaceId: string, pageId: string, logic?: RouteLogic) => { - return jumpToPage(workspaceId, pageId, logic); + ( + workspaceId: string, + pageId: string, + logic?: RouteLogic, + options?: NavigateToPageOptions + ) => { + return jumpToPage(workspaceId, pageId, logic, options); }, [jumpToPage] ); diff --git a/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx b/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx index b00a66c17f..c0fc437511 100644 --- a/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx +++ b/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx @@ -1,6 +1,9 @@ import { Divider, IconButton, Menu, MenuItem } from '@affine/component'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; +import { + RouteLogic, + useNavigateHelper, +} from '@affine/core/components/hooks/use-navigate-helper'; import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info'; import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar'; import { @@ -13,7 +16,6 @@ import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { type WorkspaceMetadata, - WorkspaceService, WorkspacesService, } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; @@ -45,7 +47,7 @@ const WorkspaceItem = ({