mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -113,6 +113,12 @@ class TestDocStorage implements DocStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TimestampBlindDocStorage extends IndexedDBDocStorage {
|
||||||
|
override async getDocTimestamps(): Promise<DocClocks> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PermissionDeniedRemoteDocStorage implements DocStorage {
|
class PermissionDeniedRemoteDocStorage implements DocStorage {
|
||||||
readonly storageType = 'doc' as const;
|
readonly storageType = 'doc' as const;
|
||||||
readonly connection = new DummyConnection();
|
readonly connection = new DummyConnection();
|
||||||
@@ -306,7 +312,7 @@ test('doc', async () => {
|
|||||||
type: 'workspace',
|
type: 'workspace',
|
||||||
});
|
});
|
||||||
|
|
||||||
const peerBDoc = new IndexedDBDocStorage({
|
const peerBDoc = new TimestampBlindDocStorage({
|
||||||
id: 'ws1',
|
id: 'ws1',
|
||||||
flavour: 'b',
|
flavour: 'b',
|
||||||
type: 'workspace',
|
type: 'workspace',
|
||||||
@@ -340,6 +346,26 @@ test('doc', async () => {
|
|||||||
docId: 'doc1',
|
docId: 'doc1',
|
||||||
bin: update,
|
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({
|
const sync = new Sync({
|
||||||
local: peerA,
|
local: peerA,
|
||||||
@@ -348,6 +374,7 @@ test('doc', async () => {
|
|||||||
c: peerC,
|
c: peerC,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const removeRootPriority = sync.doc.addPriority('ws1', 100);
|
||||||
sync.start();
|
sync.start();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
@@ -366,8 +393,33 @@ test('doc', async () => {
|
|||||||
hello: 'world',
|
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');
|
doc.getMap('test').set('foo', 'bar');
|
||||||
const update2 = encodeStateAsUpdate(doc);
|
const update2 = encodeStateAsUpdate(doc);
|
||||||
await peerC.get('doc').pushDocUpdate({
|
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 () => {
|
test('blob', async () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { createNode } from './node-builder';
|
import { createNode } from './node-builder';
|
||||||
|
|
||||||
const SQLITE_INDEXER_VERSION_OFFSET = 1;
|
const SQLITE_INDEXER_VERSION_OFFSET = 1;
|
||||||
|
const NATIVE_INDEXER_MAX_LIMIT = 0xffffffff;
|
||||||
|
|
||||||
export class SqliteIndexerStorage extends IndexerStorageBase {
|
export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||||
static readonly identifier = 'SqliteIndexerStorage';
|
static readonly identifier = 'SqliteIndexerStorage';
|
||||||
@@ -83,7 +84,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
|
|||||||
String(table),
|
String(table),
|
||||||
toNativeQuery(query),
|
toNativeQuery(query),
|
||||||
String(field),
|
String(field),
|
||||||
limit,
|
toNativeLimit(limit),
|
||||||
skip,
|
skip,
|
||||||
options?.hits
|
options?.hits
|
||||||
? toNativeOptions(options.hits, hitLimit, hitSkip)
|
? toNativeOptions(options.hits, hitLimit, hitSkip)
|
||||||
@@ -240,7 +241,7 @@ function toNativeOptions(
|
|||||||
): NativeIndexSearchOptions {
|
): NativeIndexSearchOptions {
|
||||||
const highlights = options?.highlights?.map(item => String(item.field)) ?? [];
|
const highlights = options?.highlights?.map(item => String(item.field)) ?? [];
|
||||||
return {
|
return {
|
||||||
limit,
|
limit: toNativeLimit(limit),
|
||||||
offset,
|
offset,
|
||||||
fields: [
|
fields: [
|
||||||
...new Set([...(options?.fields?.map(String) ?? []), ...highlights]),
|
...new Set([...(options?.fields?.map(String) ?? []), ...highlights]),
|
||||||
@@ -248,3 +249,7 @@ function toNativeOptions(
|
|||||||
highlights,
|
highlights,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNativeLimit(limit: number) {
|
||||||
|
return limit === Infinity ? NATIVE_INDEXER_MAX_LIMIT : limit;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface DocSyncDocState {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESET_SYNC_CONNECT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export interface DocSync {
|
export interface DocSync {
|
||||||
readonly state$: Observable<DocSyncState>;
|
readonly state$: Observable<DocSyncState>;
|
||||||
docState$(docId: string): Observable<DocSyncDocState>;
|
docState$(docId: string): Observable<DocSyncDocState>;
|
||||||
@@ -50,6 +52,8 @@ export class DocSyncImpl implements DocSync {
|
|||||||
new DocSyncPeer(peerId, this.storages.local, this.sync, remote)
|
new DocSyncPeer(peerId, this.storages.local, this.sync, remote)
|
||||||
);
|
);
|
||||||
private abort: AbortController | null = null;
|
private abort: AbortController | null = null;
|
||||||
|
private running: Promise<void> = Promise.resolve();
|
||||||
|
private resetting: Promise<void> | null = null;
|
||||||
|
|
||||||
private readonly _state$ = combineLatest(
|
private readonly _state$ = combineLatest(
|
||||||
this.peers.map(peer => peer.peerState$)
|
this.peers.map(peer => peer.peerState$)
|
||||||
@@ -155,12 +159,16 @@ export class DocSyncImpl implements DocSync {
|
|||||||
if (this.abort) {
|
if (this.abort) {
|
||||||
this.abort.abort(MANUALLY_STOP);
|
this.abort.abort(MANUALLY_STOP);
|
||||||
}
|
}
|
||||||
|
const previous = this.running;
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
this.abort = abort;
|
this.abort = abort;
|
||||||
Promise.allSettled(
|
this.running = previous.then(async () => {
|
||||||
this.peers.map(peer => peer.mainLoop(abort.signal))
|
if (abort.signal.aborted) {
|
||||||
).catch(error => {
|
return;
|
||||||
console.error(error);
|
}
|
||||||
|
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());
|
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 running = this.abort !== null;
|
||||||
|
const activeRun = this.running;
|
||||||
|
const shouldConnectSyncStorage =
|
||||||
|
this.sync.connection.status === 'idle' ||
|
||||||
|
this.sync.connection.status === 'closed';
|
||||||
this.stop();
|
this.stop();
|
||||||
await this.sync.clearClocks();
|
await activeRun;
|
||||||
if (running) {
|
if (shouldConnectSyncStorage) {
|
||||||
this.start();
|
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<typeof setTimeout> & {
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,9 +280,14 @@ export class DocSyncPeer {
|
|||||||
(await this.syncMetadata.getPeerPulledRemoteClock(this.peerId, docId))
|
(await this.syncMetadata.getPeerPulledRemoteClock(this.peerId, docId))
|
||||||
?.timestamp ?? null;
|
?.timestamp ?? null;
|
||||||
const remoteClock = this.status.remoteClocks.get(docId);
|
const remoteClock = this.status.remoteClocks.get(docId);
|
||||||
|
const hasRemoteClock = remoteClock.getTime() > 0;
|
||||||
|
const hasPulled = pulled !== null && pulled.getTime() > 0;
|
||||||
if (
|
if (
|
||||||
remoteClock &&
|
hasRemoteClock
|
||||||
(pulled === null || pulled.getTime() < remoteClock.getTime())
|
? !hasPulled ||
|
||||||
|
(pulled !== null && pulled.getTime() < remoteClock.getTime())
|
||||||
|
: (this.prioritySettings.get(docId) ?? 0) > 0 &&
|
||||||
|
(!clock || !hasPulled)
|
||||||
) {
|
) {
|
||||||
await this.jobs.pull(docId, signal);
|
await this.jobs.pull(docId, signal);
|
||||||
}
|
}
|
||||||
@@ -503,10 +508,7 @@ export class DocSyncPeer {
|
|||||||
if (!this.status.docs.has(docId)) {
|
if (!this.status.docs.has(docId)) {
|
||||||
this.status.docs.add(docId);
|
this.status.docs.add(docId);
|
||||||
this.statusUpdatedSubject$.next(docId);
|
this.statusUpdatedSubject$.next(docId);
|
||||||
this.schedule({
|
this.schedule({ type: 'connect', docId });
|
||||||
type: 'connect',
|
|
||||||
docId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -756,6 +758,11 @@ export class DocSyncPeer {
|
|||||||
for (const docId of this.status.remoteClocks.keys()) {
|
for (const docId of this.status.remoteClocks.keys()) {
|
||||||
this.actions.addDoc(docId);
|
this.actions.addDoc(docId);
|
||||||
}
|
}
|
||||||
|
for (const [docId, priority] of this.prioritySettings) {
|
||||||
|
if (priority > 0) {
|
||||||
|
this.actions.addDoc(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// begin to process jobs
|
// begin to process jobs
|
||||||
|
|
||||||
@@ -890,13 +897,22 @@ export class DocSyncPeer {
|
|||||||
|
|
||||||
addPriority(id: string, priority: number) {
|
addPriority(id: string, priority: number) {
|
||||||
const oldPriority = this.prioritySettings.get(id) ?? 0;
|
const oldPriority = this.prioritySettings.get(id) ?? 0;
|
||||||
this.prioritySettings.set(id, priority);
|
const newPriority = oldPriority + priority;
|
||||||
this.status.jobDocQueue.setPriority(id, 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 () => {
|
return () => {
|
||||||
const currentPriority = this.prioritySettings.get(id) ?? 0;
|
const currentPriority = this.prioritySettings.get(id) ?? 0;
|
||||||
this.prioritySettings.set(id, currentPriority - priority);
|
const restoredPriority = currentPriority - priority;
|
||||||
this.status.jobDocQueue.setPriority(id, currentPriority - priority);
|
this.prioritySettings.set(id, restoredPriority);
|
||||||
|
this.status.jobDocQueue.setPriority(id, restoredPriority);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>AFFiNE requires access to the camera to capture images and insert them into your documents</string>
|
<string>AFFiNE requires access to the camera to capture images and insert them into your documents</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>AFFiNE needs local network access to connect to your self-hosted workspace on this device's Wi-Fi network.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
||||||
<key>NSUserTrackingUsageDescription</key>
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
|||||||
@@ -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" : {
|
"NSPhotoLibraryUsageDescription" : {
|
||||||
"comment" : "Privacy - Photo Library Usage Description",
|
"comment" : "Privacy - Photo Library Usage Description",
|
||||||
"extractionState" : "extracted_with_value",
|
"extractionState" : "extracted_with_value",
|
||||||
|
|||||||
@@ -335,15 +335,31 @@ registerNativeImageFilesPicker(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ------ some apis for native ------
|
// ------ some apis for native ------
|
||||||
(window as any).getCurrentServerBaseUrl = () => {
|
const getCurrentServerForNative = () => {
|
||||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
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 serversService = frameworkProvider.get(ServersService);
|
||||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
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) ??
|
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||||
defaultServerService.server;
|
defaultServerService.server
|
||||||
return currentServer.baseUrl;
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).getCurrentServerBaseUrl = () => {
|
||||||
|
return getCurrentServerForNative().baseUrl;
|
||||||
};
|
};
|
||||||
(window as any).getCurrentI18nLocale = () => {
|
(window as any).getCurrentI18nLocale = () => {
|
||||||
return I18n.language;
|
return I18n.language;
|
||||||
@@ -365,11 +381,15 @@ registerNativeImageFilesPicker(async () => {
|
|||||||
};
|
};
|
||||||
(window as any).waitForSelectedSources = async (documentIds: string[]) => {
|
(window as any).waitForSelectedSources = async (documentIds: string[]) => {
|
||||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||||
const currentWorkspaceId =
|
const globalContext = globalContextService.globalContext;
|
||||||
globalContextService.globalContext.workspaceId.get();
|
const currentWorkspaceId = globalContext.workspaceId.get();
|
||||||
|
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
|
||||||
const workspacesService = frameworkProvider.get(WorkspacesService);
|
const workspacesService = frameworkProvider.get(WorkspacesService);
|
||||||
const workspaceRef = currentWorkspaceId
|
const workspaceRef = currentWorkspaceId
|
||||||
? workspacesService.openByWorkspaceId(currentWorkspaceId)
|
? workspacesService.openByWorkspaceId(
|
||||||
|
currentWorkspaceId,
|
||||||
|
currentWorkspaceFlavour
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
if (!workspaceRef) {
|
if (!workspaceRef) {
|
||||||
throw new Error('Current workspace is unavailable');
|
throw new Error('Current workspace is unavailable');
|
||||||
@@ -408,13 +428,7 @@ registerNativeImageFilesPicker(async () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const getCurrentNativeSignInContext = () => {
|
const getCurrentNativeSignInContext = () => {
|
||||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
const currentServer = getCurrentServerForNative();
|
||||||
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 authService = currentServer.scope.get(AuthService);
|
const authService = currentServer.scope.get(AuthService);
|
||||||
return { authService, currentServer };
|
return { authService, currentServer };
|
||||||
};
|
};
|
||||||
@@ -531,12 +545,16 @@ const showNativeSignIn = async () => {
|
|||||||
};
|
};
|
||||||
(window as any).getCurrentDocContentInMarkdown = async () => {
|
(window as any).getCurrentDocContentInMarkdown = async () => {
|
||||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||||
const currentWorkspaceId =
|
const globalContext = globalContextService.globalContext;
|
||||||
globalContextService.globalContext.workspaceId.get();
|
const currentWorkspaceId = globalContext.workspaceId.get();
|
||||||
const currentDocId = globalContextService.globalContext.docId.get();
|
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
|
||||||
|
const currentDocId = globalContext.docId.get();
|
||||||
const workspacesService = frameworkProvider.get(WorkspacesService);
|
const workspacesService = frameworkProvider.get(WorkspacesService);
|
||||||
const workspaceRef = currentWorkspaceId
|
const workspaceRef = currentWorkspaceId
|
||||||
? workspacesService.openByWorkspaceId(currentWorkspaceId)
|
? workspacesService.openByWorkspaceId(
|
||||||
|
currentWorkspaceId,
|
||||||
|
currentWorkspaceFlavour
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
if (!workspaceRef) {
|
if (!workspaceRef) {
|
||||||
return;
|
return;
|
||||||
@@ -589,11 +607,15 @@ const showNativeSignIn = async () => {
|
|||||||
title: string
|
title: string
|
||||||
) => {
|
) => {
|
||||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||||
const currentWorkspaceId =
|
const globalContext = globalContextService.globalContext;
|
||||||
globalContextService.globalContext.workspaceId.get();
|
const currentWorkspaceId = globalContext.workspaceId.get();
|
||||||
|
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
|
||||||
const workspacesService = frameworkProvider.get(WorkspacesService);
|
const workspacesService = frameworkProvider.get(WorkspacesService);
|
||||||
const workspaceRef = currentWorkspaceId
|
const workspaceRef = currentWorkspaceId
|
||||||
? workspacesService.openByWorkspaceId(currentWorkspaceId)
|
? workspacesService.openByWorkspaceId(
|
||||||
|
currentWorkspaceId,
|
||||||
|
currentWorkspaceFlavour
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ describe('shouldRefreshAccessToken', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('auth request fetch', () => {
|
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<typeof fetch>(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 () => {
|
test('injects the endpoint token', async () => {
|
||||||
const provider = {
|
const provider = {
|
||||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||||
@@ -164,6 +188,30 @@ describe('auth request fetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('auth request XMLHttpRequest', () => {
|
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 () => {
|
test('does not send after abort while waiting for a token', async () => {
|
||||||
let resolveToken: (token: string | null) => void = () => {};
|
let resolveToken: (token: string | null) => void = () => {};
|
||||||
const token = new Promise<string | null>(resolve => {
|
const token = new Promise<string | null>(resolve => {
|
||||||
|
|||||||
@@ -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(
|
export function createAuthFetch(
|
||||||
provider: AuthRequestProvider,
|
provider: AuthRequestProvider,
|
||||||
rawFetch: typeof globalThis.fetch
|
rawFetch: typeof globalThis.fetch
|
||||||
@@ -26,7 +50,8 @@ export function createAuthFetch(
|
|||||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init);
|
const request = new Request(input, init);
|
||||||
const retry = request.clone();
|
const retry = request.clone();
|
||||||
const endpoint = authEndpointForUrl(request.url);
|
const skipStoredAuth = shouldSkipStoredAuthToken(request.url);
|
||||||
|
const endpoint = skipStoredAuth ? null : authEndpointForUrl(request.url);
|
||||||
const token = endpoint
|
const token = endpoint
|
||||||
? await provider.getValidAccessToken(endpoint)
|
? await provider.getValidAccessToken(endpoint)
|
||||||
: null;
|
: null;
|
||||||
@@ -70,6 +95,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
|||||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
||||||
private replaying = false;
|
private replaying = false;
|
||||||
private hasReplayed = false;
|
private hasReplayed = false;
|
||||||
|
private skipStoredAuth = false;
|
||||||
private sendVersion = 0;
|
private sendVersion = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -87,6 +113,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
|||||||
this.status !== 401 ||
|
this.status !== 401 ||
|
||||||
this.replaying ||
|
this.replaying ||
|
||||||
this.hasReplayed ||
|
this.hasReplayed ||
|
||||||
|
this.skipStoredAuth ||
|
||||||
!this.request?.async
|
!this.request?.async
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +150,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
|||||||
this.requestBody = undefined;
|
this.requestBody = undefined;
|
||||||
this.replaying = false;
|
this.replaying = false;
|
||||||
this.hasReplayed = false;
|
this.hasReplayed = false;
|
||||||
|
this.skipStoredAuth = shouldSkipStoredAuthToken(url.toString());
|
||||||
xhrRequestUrls.set(this, url.toString());
|
xhrRequestUrls.set(this, url.toString());
|
||||||
return super.open(
|
return super.open(
|
||||||
method,
|
method,
|
||||||
@@ -141,9 +169,11 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
|||||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||||
this.requestBody = body;
|
this.requestBody = body;
|
||||||
const requestUrl = xhrRequestUrls.get(this);
|
const requestUrl = xhrRequestUrls.get(this);
|
||||||
const endpoint = authEndpointForUrl(
|
const targetUrl = requestUrl ?? globalThis.location.href;
|
||||||
requestUrl ?? globalThis.location.href
|
this.skipStoredAuth = shouldSkipStoredAuthToken(targetUrl);
|
||||||
);
|
const endpoint = this.skipStoredAuth
|
||||||
|
? null
|
||||||
|
: authEndpointForUrl(targetUrl);
|
||||||
const sendVersion = this.sendVersion;
|
const sendVersion = this.sendVersion;
|
||||||
|
|
||||||
const sendWithToken = (token: string | null) => {
|
const sendWithToken = (token: string | null) => {
|
||||||
@@ -168,7 +198,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
|||||||
|
|
||||||
private async replayWithFreshToken() {
|
private async replayWithFreshToken() {
|
||||||
const request = this.request;
|
const request = this.request;
|
||||||
if (!request) return this.failReplay();
|
if (!request || this.skipStoredAuth) return this.failReplay();
|
||||||
const endpoint = authEndpointForUrl(request.url);
|
const endpoint = authEndpointForUrl(request.url);
|
||||||
if (!endpoint) return this.failReplay();
|
if (!endpoint) return this.failReplay();
|
||||||
const sendVersion = this.sendVersion;
|
const sendVersion = this.sendVersion;
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export type WorkspaceSettingsRouteOptions = {
|
|||||||
scrollAnchor?: string;
|
scrollAnchor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NavigateToPageOptions = Omit<NavigateOptions, 'replace'> & {
|
||||||
|
search?: string | URLSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSearch = (search?: string | URLSearchParams) => {
|
||||||
|
const value = search?.toString();
|
||||||
|
if (!value) return '';
|
||||||
|
return value.startsWith('?') ? value : `?${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildWorkspaceSettingsPath(
|
export function buildWorkspaceSettingsPath(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
options?: WorkspaceSettingsRouteOptions
|
options?: WorkspaceSettingsRouteOptions
|
||||||
@@ -84,11 +94,17 @@ export function useNavigateHelper() {
|
|||||||
(
|
(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
logic: RouteLogic = RouteLogic.PUSH
|
logic: RouteLogic = RouteLogic.PUSH,
|
||||||
|
options?: NavigateToPageOptions
|
||||||
) => {
|
) => {
|
||||||
return navigate(`/workspace/${workspaceId}/${pageId}`, {
|
const { search, ...navigateOptions } = options ?? {};
|
||||||
replace: logic === RouteLogic.REPLACE,
|
return navigate(
|
||||||
});
|
`/workspace/${workspaceId}/${pageId}${normalizeSearch(search)}`,
|
||||||
|
{
|
||||||
|
...navigateOptions,
|
||||||
|
replace: logic === RouteLogic.REPLACE,
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
@@ -176,8 +192,13 @@ export function useNavigateHelper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openPage = useCallback(
|
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]
|
[jumpToPage]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
|
import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
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 { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||||
import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar';
|
import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +16,6 @@ import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
|||||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||||
import {
|
import {
|
||||||
type WorkspaceMetadata,
|
type WorkspaceMetadata,
|
||||||
WorkspaceService,
|
|
||||||
WorkspacesService,
|
WorkspacesService,
|
||||||
} from '@affine/core/modules/workspace';
|
} from '@affine/core/modules/workspace';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -45,7 +47,7 @@ const WorkspaceItem = ({
|
|||||||
<li className={styles.wsItem}>
|
<li className={styles.wsItem}>
|
||||||
<button className={clsx(styles.wsCard, className)} {...attrs}>
|
<button className={clsx(styles.wsCard, className)} {...attrs}>
|
||||||
<WorkspaceAvatar
|
<WorkspaceAvatar
|
||||||
key={workspace.id}
|
key={`${workspace.flavour}:${workspace.id}`}
|
||||||
meta={workspace}
|
meta={workspace}
|
||||||
rounded={6}
|
rounded={6}
|
||||||
data-testid="workspace-avatar"
|
data-testid="workspace-avatar"
|
||||||
@@ -71,7 +73,7 @@ export const WorkspaceList = (props: WorkspaceListProps) => {
|
|||||||
|
|
||||||
return workspaceList.map(item => (
|
return workspaceList.map(item => (
|
||||||
<WorkspaceItem
|
<WorkspaceItem
|
||||||
key={item.id}
|
key={`${item.flavour}:${item.id}`}
|
||||||
workspace={item}
|
workspace={item}
|
||||||
onClick={() => props.onClick(item)}
|
onClick={() => props.onClick(item)}
|
||||||
/>
|
/>
|
||||||
@@ -276,12 +278,18 @@ const AddServer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
|
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
|
||||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
|
||||||
const workspacesService = useService(WorkspacesService);
|
const workspacesService = useService(WorkspacesService);
|
||||||
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
||||||
const serversService = useService(ServersService);
|
const serversService = useService(ServersService);
|
||||||
|
const globalContextService = useService(GlobalContextService);
|
||||||
const { jumpToPage } = useNavigateHelper();
|
const { jumpToPage } = useNavigateHelper();
|
||||||
|
|
||||||
|
const currentWorkspaceId = useLiveData(
|
||||||
|
globalContextService.globalContext.workspaceId.$
|
||||||
|
);
|
||||||
|
const currentWorkspaceFlavour = useLiveData(
|
||||||
|
globalContextService.globalContext.workspaceFlavour.$
|
||||||
|
);
|
||||||
const servers = useLiveData(serversService.servers$);
|
const servers = useLiveData(serversService.servers$);
|
||||||
const affineCloudServer = useMemo(
|
const affineCloudServer = useMemo(
|
||||||
() => servers.find(s => s.id === 'affine-cloud') as Server,
|
() => servers.find(s => s.id === 'affine-cloud') as Server,
|
||||||
@@ -311,12 +319,29 @@ export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
const handleClickWorkspace = useCallback(
|
const handleClickWorkspace = useCallback(
|
||||||
(workspaceMetadata: WorkspaceMetadata) => {
|
(workspaceMetadata: WorkspaceMetadata) => {
|
||||||
const id = workspaceMetadata.id;
|
const id = workspaceMetadata.id;
|
||||||
if (id !== currentWorkspace?.id) {
|
const isCurrentWorkspace =
|
||||||
jumpToPage(id, 'home');
|
id === currentWorkspaceId &&
|
||||||
|
workspaceMetadata.flavour === currentWorkspaceFlavour;
|
||||||
|
if (!isCurrentWorkspace) {
|
||||||
|
const server = servers.find(
|
||||||
|
server => server.id === workspaceMetadata.flavour
|
||||||
|
);
|
||||||
|
if (workspaceMetadata.flavour !== 'local' && !server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
flavour: workspaceMetadata.flavour,
|
||||||
|
});
|
||||||
|
if (workspaceMetadata.flavour !== 'local' && server) {
|
||||||
|
searchParams.set('server', server.baseUrl);
|
||||||
|
}
|
||||||
|
jumpToPage(id, 'home', RouteLogic.PUSH, {
|
||||||
|
search: searchParams,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
onClose?.();
|
onClose?.();
|
||||||
},
|
},
|
||||||
[currentWorkspace.id, jumpToPage, onClose]
|
[currentWorkspaceFlavour, currentWorkspaceId, jumpToPage, onClose, servers]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -102,9 +102,54 @@ export const Component = () => {
|
|||||||
const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
|
const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
|
||||||
const listLoading = useLiveData(workspacesService.list.isRevalidating$);
|
const listLoading = useLiveData(workspacesService.list.isRevalidating$);
|
||||||
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
||||||
|
|
||||||
|
const serverSearchParam = searchParams.get('server');
|
||||||
|
const flavourSearchParam = searchParams.get('flavour');
|
||||||
|
const serverFromSearchParams = useLiveData(
|
||||||
|
serverSearchParam
|
||||||
|
? serversService.serverByBaseUrl$(serverSearchParam)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
const meta = useMemo(() => {
|
const meta = useMemo(() => {
|
||||||
return workspaces.find(({ id }) => id === params.workspaceId);
|
const workspaceId = params.workspaceId;
|
||||||
}, [workspaces, params.workspaceId]);
|
if (!workspaceId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const findByFlavour = (flavour: string) =>
|
||||||
|
workspaces.find(
|
||||||
|
workspace =>
|
||||||
|
workspace.id === workspaceId && workspace.flavour === flavour
|
||||||
|
);
|
||||||
|
|
||||||
|
if (flavourSearchParam) {
|
||||||
|
return findByFlavour(flavourSearchParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverSearchParam) {
|
||||||
|
if (!serverFromSearchParams) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return findByFlavour(serverFromSearchParams.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastWorkspaceFlavour = localStorage.getItem('last_workspace_flavour');
|
||||||
|
if (lastWorkspaceFlavour) {
|
||||||
|
const lastWorkspace = findByFlavour(lastWorkspaceFlavour);
|
||||||
|
if (lastWorkspace) {
|
||||||
|
return lastWorkspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = workspaces.filter(({ id }) => id === workspaceId);
|
||||||
|
return matches.length === 1 ? matches[0] : undefined;
|
||||||
|
}, [
|
||||||
|
flavourSearchParam,
|
||||||
|
params.workspaceId,
|
||||||
|
serverSearchParam,
|
||||||
|
serverFromSearchParams,
|
||||||
|
workspaces,
|
||||||
|
]);
|
||||||
|
|
||||||
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,19 +180,16 @@ export const Component = () => {
|
|||||||
return;
|
return;
|
||||||
}, [listLoading, meta, workspaceNotFound, workspacesService]);
|
}, [listLoading, meta, workspaceNotFound, workspacesService]);
|
||||||
|
|
||||||
// server search params
|
|
||||||
const serverFromSearchParams = useLiveData(
|
|
||||||
searchParams.has('server')
|
|
||||||
? serversService.serverByBaseUrl$(searchParams.get('server') as string)
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
// server from workspace
|
// server from workspace
|
||||||
const serverFromWorkspace = useLiveData(
|
const serverFromWorkspace = useLiveData(
|
||||||
meta?.flavour && meta.flavour !== 'local'
|
meta?.flavour && meta.flavour !== 'local'
|
||||||
? serversService.server$(meta?.flavour)
|
? serversService.server$(meta?.flavour)
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
const server = serverFromWorkspace ?? serverFromSearchParams;
|
const server =
|
||||||
|
meta?.flavour === 'local'
|
||||||
|
? undefined
|
||||||
|
: (serverFromWorkspace ?? serverFromSearchParams);
|
||||||
|
|
||||||
if (workspaceNotFound) {
|
if (workspaceNotFound) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const WorkspaceLayout = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
localStorage.setItem('last_workspace_id', workspace.id);
|
localStorage.setItem('last_workspace_id', workspace.id);
|
||||||
|
localStorage.setItem('last_workspace_flavour', workspace.flavour);
|
||||||
globalContextService.globalContext.workspaceId.set(workspace.id);
|
globalContextService.globalContext.workspaceId.set(workspace.id);
|
||||||
if (workspaceServer) {
|
if (workspaceServer) {
|
||||||
globalContextService.globalContext.serverId.set(workspaceServer.id);
|
globalContextService.globalContext.serverId.set(workspaceServer.id);
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function useBindWorkbenchToBrowserRouter(
|
|||||||
|
|
||||||
const newBrowserLocation = viewLocationToBrowserLocation(
|
const newBrowserLocation = viewLocationToBrowserLocation(
|
||||||
update.location,
|
update.location,
|
||||||
basename
|
basename,
|
||||||
|
browserLocation.search
|
||||||
);
|
);
|
||||||
|
|
||||||
navigate(newBrowserLocation, {
|
navigate(newBrowserLocation, {
|
||||||
@@ -97,12 +98,44 @@ function browserLocationToViewLocation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveWorkspaceContextSearch(
|
||||||
|
nextSearch: string,
|
||||||
|
currentSearch: string
|
||||||
|
) {
|
||||||
|
const nextParams = new URLSearchParams(nextSearch);
|
||||||
|
const currentParams = new URLSearchParams(currentSearch);
|
||||||
|
const currentFlavour = currentParams.get('flavour');
|
||||||
|
const nextFlavour = nextParams.get('flavour');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!nextParams.has('flavour') &&
|
||||||
|
!nextParams.has('server') &&
|
||||||
|
currentFlavour
|
||||||
|
) {
|
||||||
|
nextParams.set('flavour', currentFlavour);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedNextFlavour = nextParams.get('flavour');
|
||||||
|
const shouldPreserveServer =
|
||||||
|
resolvedNextFlavour !== 'local' &&
|
||||||
|
(!nextFlavour || !currentFlavour || nextFlavour === currentFlavour);
|
||||||
|
const currentServer = currentParams.get('server');
|
||||||
|
if (!nextParams.has('server') && currentServer && shouldPreserveServer) {
|
||||||
|
nextParams.set('server', currentServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = nextParams.toString();
|
||||||
|
return search ? `?${search}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
function viewLocationToBrowserLocation(
|
function viewLocationToBrowserLocation(
|
||||||
location: Location,
|
location: Location,
|
||||||
basename: string
|
basename: string,
|
||||||
|
currentSearch: string
|
||||||
): Location {
|
): Location {
|
||||||
return {
|
return {
|
||||||
...location,
|
...location,
|
||||||
pathname: `${basename}${location.pathname}`,
|
pathname: `${basename}${location.pathname}`,
|
||||||
|
search: preserveWorkspaceContextSearch(location.search, currentSearch),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ObjectPool, Service } from '@toeverything/infra';
|
|||||||
|
|
||||||
import type { Workspace } from '../entities/workspace';
|
import type { Workspace } from '../entities/workspace';
|
||||||
import { WorkspaceInitialized } from '../events';
|
import { WorkspaceInitialized } from '../events';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
import type { WorkspaceOpenOptions } from '../open-options';
|
import type { WorkspaceOpenOptions } from '../open-options';
|
||||||
import { WorkspaceScope } from '../scopes/workspace';
|
import { WorkspaceScope } from '../scopes/workspace';
|
||||||
import type { WorkspaceFlavoursService } from './flavours';
|
import type { WorkspaceFlavoursService } from './flavours';
|
||||||
@@ -13,6 +14,9 @@ import { WorkspaceService } from './workspace';
|
|||||||
|
|
||||||
const logger = new DebugLogger('affine:workspace-repository');
|
const logger = new DebugLogger('affine:workspace-repository');
|
||||||
|
|
||||||
|
const getWorkspacePoolKey = (metadata: WorkspaceMetadata) =>
|
||||||
|
`${metadata.flavour}:${metadata.id}`;
|
||||||
|
|
||||||
export class WorkspaceRepositoryService extends Service {
|
export class WorkspaceRepositoryService extends Service {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly flavoursService: WorkspaceFlavoursService,
|
private readonly flavoursService: WorkspaceFlavoursService,
|
||||||
@@ -58,7 +62,7 @@ export class WorkspaceRepositoryService extends Service {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const exist = this.pool.get(options.metadata.id);
|
const exist = this.pool.get(getWorkspacePoolKey(options.metadata));
|
||||||
if (exist) {
|
if (exist) {
|
||||||
return {
|
return {
|
||||||
workspace: exist.obj,
|
workspace: exist.obj,
|
||||||
@@ -68,7 +72,7 @@ export class WorkspaceRepositoryService extends Service {
|
|||||||
|
|
||||||
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
|
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
|
||||||
|
|
||||||
const ref = this.pool.put(workspace.meta.id, workspace);
|
const ref = this.pool.put(getWorkspacePoolKey(workspace.meta), workspace);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspace: ref.obj,
|
workspace: ref.obj,
|
||||||
@@ -76,9 +80,13 @@ export class WorkspaceRepositoryService extends Service {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
openByWorkspaceId = (workspaceId: string) => {
|
openByWorkspaceId = (workspaceId: string, flavour?: string | null) => {
|
||||||
const workspaceMetadata =
|
const workspaceMetadata = flavour
|
||||||
this.workspacesListService.list.workspace$(workspaceId).value;
|
? this.workspacesListService.list.workspaces$.value.find(
|
||||||
|
workspace =>
|
||||||
|
workspace.id === workspaceId && workspace.flavour === flavour
|
||||||
|
)
|
||||||
|
: this.workspacesListService.list.workspace$(workspaceId).value;
|
||||||
return workspaceMetadata && this.open({ metadata: workspaceMetadata });
|
return workspaceMetadata && this.open({ metadata: workspaceMetadata });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user