mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 13:17:10 +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 {
|
||||
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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DocSyncDocState {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const RESET_SYNC_CONNECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface DocSync {
|
||||
readonly state$: Observable<DocSyncState>;
|
||||
docState$(docId: string): Observable<DocSyncDocState>;
|
||||
@@ -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<void> = Promise.resolve();
|
||||
private resetting: Promise<void> | 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<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))
|
||||
?.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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user