From 65b1993b301a0bc21f0e310ffbf64804c95d4a97 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:57:31 +0100 Subject: [PATCH] feat: run files sync in the desktop app's main window for the signed-in accounts --- app/(main)/[locale]/layout.tsx | 2 + components/layout/window-controls-host.tsx | 6 +- components/providers/files-sync-host.tsx | 92 ++++++ lib/files-sync/__tests__/host.test.ts | 225 ++++++++++++++ lib/files-sync/controls.ts | 28 ++ lib/files-sync/host.ts | 329 +++++++++++++++++++++ lib/files-sync/lifecycle.ts | 72 +++++ 7 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 components/providers/files-sync-host.tsx create mode 100644 lib/files-sync/__tests__/host.test.ts create mode 100644 lib/files-sync/controls.ts create mode 100644 lib/files-sync/host.ts create mode 100644 lib/files-sync/lifecycle.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index d5823c0c4..02718207f 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -34,6 +34,7 @@ import { NotificationActionsHost } from "@/components/providers/notification-act import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; import { FileDropHost } from "@/components/file-drop/file-drop-host"; import { EditedFilesHost } from "@/components/desktop/edited-files-host"; +import { FilesSyncHost } from "@/components/providers/files-sync-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -97,6 +98,7 @@ export default async function LocaleLayout({ {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/components/layout/window-controls-host.tsx b/components/layout/window-controls-host.tsx index 1681da6f0..a80fbd422 100644 --- a/components/layout/window-controls-host.tsx +++ b/components/layout/window-controls-host.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay"; import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in"; +import { claimFilesControls } from "@/lib/files-sync/controls"; /** * The window's controls over the app, with no title bar row: see @@ -13,7 +14,10 @@ import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in"; export function WindowControlsHost() { // In the first render of every page, before any frame exists: a frame can reach the // bridge, and whoever comes after this gets nothing. - if (typeof window !== "undefined") claimBrowserSignIn(); + if (typeof window !== "undefined") { + claimBrowserSignIn(); + claimFilesControls(); + } const [area, setArea] = useState(null); useEffect(() => watchTitlebarArea((next) => { diff --git a/components/providers/files-sync-host.tsx b/components/providers/files-sync-host.tsx new file mode 100644 index 000000000..0f941679b --- /dev/null +++ b/components/providers/files-sync-host.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { getDesktopPolicy, onDesktopPolicyChange } from "@/lib/desktop/policy"; +import { canDownload } from "@/lib/device-policy"; +import { claimFilesControls } from "@/lib/files-sync/controls"; +import { FilesSyncHost as Host, type HostAccount } from "@/lib/files-sync/host"; +import { registerFilesSyncLifecycle, setRunningFilesSyncHost } from "@/lib/files-sync/lifecycle"; +import { scopeDecided } from "@/lib/offline/offline-mode"; +import { subscribePushStateChanges } from "@/lib/push-events"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; + +registerFilesSyncLifecycle(); + +/** `FilesFolderSyncEnabled` forced off. Main removes the folders itself; this keeps the engine off them meanwhile. */ +function folderSyncForcedOff(): boolean { + const entry = getDesktopPolicy().FilesFolderSyncEnabled; + return entry.locked && entry.value === false; +} + +/** A definite no from the organisation. An unknown policy isn't one. */ +function downloadsForbidden(account: string): boolean { + return scopeDecided(account) && !canDownload(account); +} + +/** + * Files on this computer, in the desktop app's main window (it's under MainWindowOnly): starts + * the sync engine for the signed-in accounts and keeps it fed with push, the network and the + * policies. Renders nothing. In a browser, or a shell without the files bridge, it does nothing. + */ +export function FilesSyncHost() { + const hostRef = useRef(null); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const revision = useAuthStore((s) => s.connectedAccountsRevision); + const client = useAuthStore((s) => s.client); + + useEffect(() => { + const bridge = getDesktopBridge(); + const controls = bridge?.files ? claimFilesControls() : null; + if (!bridge || !controls) return; + const store = useFilesSyncStore.getState(); + const host = new Host({ + controls, + platform: bridge.platform, + publish: (patch) => useFilesSyncStore.getState().publish(patch), + setController: (controller) => useFilesSyncStore.getState().setController(controller), + folderSyncForcedOff, + downloadsForbidden, + }); + hostRef.current = host; + setRunningFilesSyncHost(host); + host.start(); + const stopPush = subscribePushStateChanges((change, changedClient) => { + for (const [account, entry] of useAuthStore.getState().getAllConnectedClients()) { + if (entry !== changedClient) continue; + if (Object.values(change.changed).some((types) => !!types && "FileNode" in types)) host.remoteChanged(account); + } + }); + const stopPolicy = onDesktopPolicyChange(() => host.policyChanged()); + const online = () => host.online(); + window.addEventListener("online", online); + return () => { + stopPush(); + stopPolicy(); + window.removeEventListener("online", online); + setRunningFilesSyncHost(null); + host.stop(); + hostRef.current = null; + store.reset(); + }; + }, []); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const clients = isAuthenticated ? useAuthStore.getState().getAllConnectedClients() : new Map(); + const list: HostAccount[] = []; + for (const [id, entry] of clients) { + const account = accounts.find((a) => a.id === id); + if (account && !account.isConnected) continue; + list.push({ id, label: account?.email || account?.username || account?.label || id, client: entry }); + } + host.setAccounts(list, isAuthenticated ? activeAccountId : null); + }, [accounts, activeAccountId, isAuthenticated, revision, client]); + + return null; +} diff --git a/lib/files-sync/__tests__/host.test.ts b/lib/files-sync/__tests__/host.test.ts new file mode 100644 index 000000000..d37b77657 --- /dev/null +++ b/lib/files-sync/__tests__/host.test.ts @@ -0,0 +1,225 @@ +/** + * The host of Files on this computer: claiming the bridge once, the accounts it runs for, what + * it publishes, the controller, and what sign-out, a wipe and a refused login do. + */ +import { render } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FilesControls } from '@/lib/desktop/types'; +import { WindowControlsHost } from '@/components/layout/window-controls-host'; +import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import type { FilesSyncController, FilesSyncSnapshot } from '../types'; +import { claimFilesControls, resetFilesControlsForTests } from '../controls'; +import { FilesSyncHost, type HostAccount } from '../host'; +import { registerFilesSyncLifecycle, resetFilesSyncLifecycleForTests, setRunningFilesSyncHost } from '../lifecycle'; +import { FakeDisk } from './helpers/fake-disk'; +import { FakeServer } from './helpers/fake-server'; + +const FAST = { localDelayMs: 0, remoteDelayMs: 0, pollMs: 1e9, rescanMs: 1e9, retryMs: [1e9], soonMs: 5, fetchWaitMs: 1000 }; + +function fakeBridge(controls: FilesControls) { + const claim = vi.fn<() => FilesControls | null>(); + let taken = false; + claim.mockImplementation(() => { + if (taken) return null; + taken = true; + return controls; + }); + return { version: 1, platform: 'win32' as const, files: { claim } }; +} + +describe('claiming the bridge', () => { + afterEach(() => { + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetFilesControlsForTests(); + }); + + it('takes the controls in the first render, once, so a frame coming later gets nothing', () => { + const controls = new FakeDisk(); + const bridge = fakeBridge(controls); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge; + const { rerender } = render(createElement(WindowControlsHost)); + rerender(createElement(WindowControlsHost)); + expect(bridge.files.claim).toHaveBeenCalledTimes(1); + expect(claimFilesControls()).toBe(controls); + // A same-origin frame reaching the bridge through parent. + expect(bridge.files.claim()).toBeNull(); + }); + + it('takes nothing in a browser', () => { + render(createElement(WindowControlsHost)); + expect(claimFilesControls()).toBeNull(); + }); +}); + +interface Rig { + host: FilesSyncHost; + disk: FakeDisk; + server: FakeServer; + snapshot: () => Partial; + controller: () => FilesSyncController | null; + folderOff: { value: boolean }; + account: HostAccount; +} + +function rig(options: { drive?: boolean; wanted?: string[] } = {}): Rig { + const disk = new FakeDisk({ caseInsensitive: true, drive: options.drive }); + disk.driveWanted = options.wanted ?? []; + const server = new FakeServer(); + let published: Partial = {}; + let controller: FilesSyncController | null = null; + const folderOff = { value: false }; + const host = new FilesSyncHost({ + controls: disk, + platform: 'win32', + publish: (patch) => { + published = { ...published, ...patch }; + }, + setController: (next) => { + controller = next; + }, + folderSyncForcedOff: () => folderOff.value, + downloadsForbidden: () => false, + remoteFor: () => server.remote(), + readUrl: disk.readUrl, + timing: FAST, + }); + const account: HostAccount = { id: 'a1', label: 'a@example.org', client: { getSessionSnapshot: () => server.session, getAuthHeader: () => 'Basic x' } }; + return { host, disk, server, snapshot: () => published, controller: () => controller, folderOff, account }; +} + +async function settle(host: FilesSyncHost): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); + await host.engine.settled(); + await new Promise((resolve) => setTimeout(resolve, 60)); +} + +describe('the host', () => { + it('runs the roots of signed-in accounts, publishes the current one, and registers the controller', async () => { + const r = rig(); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.addRoot({ id: 'r2', account: 'other', remoteId: null }); + r.disk.write('r1', 'a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + expect(r.server.listing()).toEqual(['a.txt']); + expect(r.snapshot()).toMatchObject({ available: true, account: 'a1', folderSyncForcedOff: false }); + expect(r.snapshot().roots?.map((root) => root.id)).toEqual(['r1']); + expect(r.snapshot().status?.r1).toMatchObject({ state: 'idle' }); + expect(r.snapshot().activity?.[0]).toMatchObject({ kind: 'uploaded', path: 'a.txt' }); + expect(Object.values(r.snapshot().items ?? {})).toEqual(['synced']); + expect(r.controller()).not.toBeNull(); + r.host.stop(); + expect(r.controller()).toBeNull(); + }); + + it('keeps folders out of sync while the policy forbids them', async () => { + const r = rig(); + r.folderOff.value = true; + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.write('r1', 'a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + expect(r.server.listing()).toEqual([]); + expect(r.snapshot().status?.r1).toMatchObject({ state: 'blocked', errorKey: 'errors.policy' }); + expect(r.snapshot().folderSyncForcedOff).toBe(true); + r.host.stop(); + }); + + it('turns the drive back on at sign-in without a click, and off at sign-out keeping the wish', async () => { + const r = rig({ drive: true, wanted: ['a1'] }); + r.server.file('a.txt', null, 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + await settle(r.host); + expect(r.disk.driveCalls[0]).toEqual({ call: 'enable', args: ['a1', 'a@example.org'] }); + expect(r.disk.listing('drive-a1')).toEqual(['a.txt']); + expect(r.disk.states.get('drive-a1')).toContain('a.txt'); + await r.host.signedOut('a1'); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'disable', args: ['a1', 'signed-out'] }); + expect(r.disk.driveWanted).toEqual(['a1']); + // The record went before the folder did: the next sign-in starts from an empty one. + expect(r.disk.states.get('drive-a1')).not.toContain('a.txt'); + r.host.stop(); + }); + + it('removes the drive and forgets the folders of a wiped account, and keeps their files', async () => { + const r = rig({ drive: true, wanted: ['a1', 'a2'] }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.addRoot({ id: 'r2', account: 'a2', remoteId: null }); + r.disk.write('r1', 'mine.txt', 'mine'); + r.host.start(); + await r.host.removeAccountData('a1', 'wipe'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'wipe'] }); + expect(r.disk.driveCalls.some((call) => call.args[0] === 'a2')).toBe(false); + expect(r.disk.calls).toEqual(['removeRoot r1']); + expect(r.disk.read('r1', 'mine.txt')).toBe('mine'); + r.host.stop(); + }); + + it('answers the controller: shared folders refused, the drive switch, show in folder', async () => { + const r = rig({ drive: true }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.write('r1', 'Docs/a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + const controller = r.controller()!; + expect(await controller.addFolder('shared1:n5', 'Team')).toEqual({ ok: false, error: 'unsupported' }); + expect(r.disk.calls).toEqual([]); + const id = r.server.find('Docs/a.txt')!.id; + expect(controller.coverage(id)).toEqual({ rootId: 'r1', kind: 'folder', path: 'Docs/a.txt' }); + expect(await controller.showInFolder(id)).toBe(true); + expect(r.disk.shown).toEqual([{ rootId: 'r1', path: 'Docs/a.txt' }]); + expect(await controller.showInFolder('unknown')).toBe(false); + expect((await controller.setDriveEnabled(true)).ok).toBe(true); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'enable', args: ['a1', 'a@example.org'] }); + expect(await controller.setDriveEnabled(false)).toEqual({ ok: true, value: null }); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'disable', args: ['a1', 'user'] }); + r.host.stop(); + }); +}); + +describe('sign-out, wipe and refused logins', () => { + let r: Rig; + beforeEach(async () => { + resetFilesSyncLifecycleForTests(); + registerFilesSyncLifecycle(); + r = rig({ drive: true, wanted: ['a1'] }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.host.start(); + setRunningFilesSyncHost(r.host); + }); + afterEach(() => { + r.host.stop(); + resetFilesSyncLifecycleForTests(); + }); + + it('a wipe removes the drive and forgets the folders', async () => { + await runWipeCleaners('a1'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'wipe'] }); + expect(r.disk.calls).toContain('removeRoot r1'); + }); + + it('a refused login the server is sure of does the same, an unsure one nothing', async () => { + noteCredentialsRejected('a1', false, 'refused'); + noteCredentialsRejected('a1', true, 'session'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(r.disk.driveCalls.filter((call) => call.call === 'disable')).toEqual([]); + noteCredentialsRejected('a1', true, 'refused'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'refused'] }); + expect(r.disk.calls).toContain('removeRoot r1'); + }); + + it('a sign-out takes the drive down and keeps the folders', async () => { + await runBeforeSignOut('a1'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'signed-out'] }); + expect(r.disk.calls).toEqual([]); + }); +}); diff --git a/lib/files-sync/controls.ts b/lib/files-sync/controls.ts new file mode 100644 index 000000000..5f2289c5e --- /dev/null +++ b/lib/files-sync/controls.ts @@ -0,0 +1,28 @@ +/** + * The desktop app's Files on this computer calls, taken once per document in its first render + * (WindowControlsHost), as auth.claim and appLock.claim are: a same-origin plugin frame + * reaches `bulwarkDesktop` through `parent`, and main can't tell its calls from the page's. + * Whoever comes after the page gets nothing from the preload. Main also answers only the main + * window, so a pop-out's own claim is just its share of nothing. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { FilesBridge, FilesControls } from '@/lib/desktop/types'; + +let claimedFrom: FilesBridge | null | undefined; +let claimed: FilesControls | null = null; + +export function claimFilesControls(): FilesControls | null { + const member = getDesktopBridge()?.files ?? null; + if (member !== claimedFrom) { + claimedFrom = member; + claimed = member?.claim?.() ?? null; + } + return claimed; +} + +/** Tests only. */ +export function resetFilesControlsForTests(): void { + claimedFrom = undefined; + claimed = null; +} diff --git a/lib/files-sync/host.ts b/lib/files-sync/host.ts new file mode 100644 index 000000000..eb707d1fd --- /dev/null +++ b/lib/files-sync/host.ts @@ -0,0 +1,329 @@ +/** + * Files on this computer in the page: starts the engine in the desktop app's main window, for + * the signed-in accounts, publishes what it does to stores/files-sync-store.ts and registers the + * controller the settings page and the Files screen use. The React side + * (components/providers/files-sync-host.tsx) feeds it the accounts, push and the policies. + * + * Each account reaches Files through the app's own JMAP session: the session resource and + * Authorization header the app's client holds, read when a request goes and renewed through + * it (as offline mode does). Nothing here writes a token or a password anywhere. + * + * What happens to an account's roots when it leaves (FILES-SYNC-BRIEF.md): + * - sign-out: the drive goes (`signed-out` keeps the wish, so the next sign-in brings it back + * without a click), and the folders stop syncing until then; + * - a wipe (lib/offline/wipe-registry.ts): the drive goes with everything in it, and the + * folders are forgotten; their files stay, they were the user's; + * - a refused login the server was sure of: the same as a wipe for these. + */ + +import type { FilesControls, FilesDriveState, FilesResult, FilesRoot } from '@/lib/desktop/types'; +import { FetchTransport, type StoreSession } from '@/lib/store/engine/transport'; +import { FilesSyncEngine, type EngineTiming } from './engine'; +import type { FilesSyncErrorName } from './errors'; +import { namingFor } from './names'; +import { FilesRemote } from './remote'; +import type { FilesSyncController, FilesSyncSnapshot } from './types'; + +/** What the host needs of the app's JMAP client. */ +export interface SessionClient { + getSessionSnapshot(): unknown; + getAuthHeader(): string; + isOfflineOnly?(): boolean; + requestNetworkOnly?(calls: [string, Record, string][]): Promise; +} + +export interface HostAccount { + id: string; + /** Shown with the drive when there are several accounts. */ + label: string; + client: SessionClient; +} + +export interface HostDeps { + controls: FilesControls; + platform: string; + publish: (patch: Partial) => void; + setController: (controller: FilesSyncController | null) => void; + /** `FilesFolderSyncEnabled` is off. */ + folderSyncForcedOff: () => boolean; + /** The organisation said no to downloads for this account. */ + downloadsForbidden: (account: string) => boolean; + /** Tests give their own. */ + remoteFor?: (account: HostAccount) => FilesRemote | null; + timing?: Partial; + readUrl?: (url: string) => Promise; +} + +const DEVICE: Record = { win32: 'Windows', darwin: 'Mac', linux: 'Linux' }; + +/** The app's client as a transport: its session and header, read each time, renewed through it. */ +export function remoteForClient(client: SessionClient): FilesRemote | null { + if (client.isOfflineOnly?.()) return null; + const session = client.getSessionSnapshot() as StoreSession | undefined; + if (!session?.apiUrl || !session.uploadUrl || !session.downloadUrl) return null; + const transport = new FetchTransport({ + session: () => (client.getSessionSnapshot() as StoreSession | undefined) ?? session, + auth: () => client.getAuthHeader() || null, + refreshAuth: async () => { + // Through the client: a 401 there runs its own renewal or re-login. + await client.requestNetworkOnly?.([['Core/echo', {}, '0']]).catch(() => null); + return client.getAuthHeader() || null; + }, + }); + const remote = new FilesRemote(transport, () => (client.getSessionSnapshot() as StoreSession | undefined) ?? session); + return remote.supported() ? remote : null; +} + +const fail = (error: Exclude, { ok: true }>['error']): FilesResult => ({ ok: false, error }); + +export class FilesSyncHost { + readonly engine: FilesSyncEngine; + private readonly accounts = new Map(); + private current: string | null = null; + private drive: FilesDriveState | null = null; + private publishTimer: ReturnType | null = null; + private readonly unsubscribe: (() => void)[] = []; + /** Accounts the drive was turned on for at this sign-in already. */ + private readonly driveStarted = new Set(); + private stopped = false; + + constructor(private readonly deps: HostDeps) { + this.engine = new FilesSyncEngine({ + controls: deps.controls, + naming: namingFor(deps.platform), + device: DEVICE[deps.platform] ?? 'this computer', + readUrl: deps.readUrl, + timing: deps.timing, + blocked: (root) => this.blocked(root), + downloadsAllowed: (account) => !deps.downloadsForbidden(account), + onUpdate: () => this.schedulePublish(), + }); + } + + get controls(): FilesControls { + return this.deps.controls; + } + + start(): void { + this.engine.start(); + const drive = this.deps.controls.drive; + if (drive) { + void drive.state().then((state) => { + this.drive = state; + this.schedulePublish(); + this.startDrives(); + }).catch(() => {}); + this.unsubscribe.push(drive.onStateChange((state) => { + this.drive = state; + this.schedulePublish(); + void this.engine.refreshRoots(); + })); + } + this.deps.setController(this.controller()); + this.publishNow(); + } + + stop(): void { + this.stopped = true; + for (const stop of this.unsubscribe.splice(0)) stop(); + if (this.publishTimer) clearTimeout(this.publishTimer); + this.engine.stop(); + this.deps.setController(null); + } + + private blocked(root: FilesRoot): FilesSyncErrorName | null { + if (root.kind === 'folder' && this.deps.folderSyncForcedOff()) return 'policy'; + if (this.deps.downloadsForbidden(root.account)) return 'policy'; + return null; + } + + // ─── Accounts ─────────────────────────────────────────────── + + /** The signed-in accounts with a connected client, and the one on screen. */ + setAccounts(accounts: HostAccount[], current: string | null): void { + if (this.stopped) return; + this.current = current; + const now = new Map(accounts.map((account) => [account.id, account])); + for (const id of [...this.accounts.keys()]) { + if (now.has(id)) continue; + // Not connected now (signed out goes through signedOut): its roots wait. + this.accounts.delete(id); + this.engine.dropAccount(id); + } + for (const account of accounts) { + const known = this.accounts.get(account.id); + if (known && known.client === account.client) continue; + const remote = (this.deps.remoteFor ?? ((entry: HostAccount) => remoteForClient(entry.client)))(account); + if (!remote) continue; + this.accounts.set(account.id, account); + this.engine.setAccount(account.id, remote); + } + this.startDrives(); + void this.engine.refreshRoots(); + this.schedulePublish(); + } + + /** A sign-in brings back the drive of an account that had it, without a click. */ + private startDrives(): void { + const drive = this.deps.controls.drive; + const state = this.drive; + if (!drive || !state || !state.supported || state.forcedOff) return; + for (const account of this.accounts.values()) { + if (!state.wanted.includes(account.id) || this.driveStarted.has(account.id)) continue; + this.driveStarted.add(account.id); + void drive.enable(account.id, account.label).then((result) => { + if (!result.ok) this.driveStarted.delete(account.id); + void this.engine.refreshRoots(); + }, () => this.driveStarted.delete(account.id)); + } + } + + /** A push for this account said FileNode changed. */ + remoteChanged(account: string): void { + this.engine.remoteChanged(account); + } + + online(): void { + this.engine.retryNow(); + } + + policyChanged(): void { + this.engine.syncNow(); + this.schedulePublish(); + } + + private async driveRootOf(account: string): Promise { + const roots = await this.deps.controls.roots().catch(() => [] as FilesRoot[]); + return roots.find((root) => root.kind === 'drive' && root.account === account) ?? null; + } + + /** + * Signed out ('all': everyone). The drive goes, and comes back at the next sign-in; its record + * goes first, since its folder goes with it. The folders stop until then. + */ + async signedOut(account: string): Promise { + const ids = account === 'all' ? [...new Set([...this.accounts.keys(), ...this.engine.roots.map((root) => root.account)])] : [account]; + for (const id of ids) { + const drive = await this.driveRootOf(id); + if (drive) await this.engine.forgetRoot(drive.id); + this.accounts.delete(id); + this.driveStarted.delete(id); + this.engine.dropAccount(id); + await this.deps.controls.drive?.disable(id, 'signed-out').catch(() => {}); + } + await this.engine.refreshRoots(); + } + + /** + * A wipe or a refused login the server was sure of: the drive goes with everything in it, + * the folders are forgotten and their files stay. `null`: every account. + */ + async removeAccountData(account: string | null, reason: 'wipe' | 'refused'): Promise { + await removeAccountData(this.deps.controls, account, reason, [...this.accounts.keys(), ...this.engine.roots.map((root) => root.account)]); + for (const id of account === null ? [...this.accounts.keys()] : [account]) { + this.accounts.delete(id); + this.driveStarted.delete(id); + this.engine.dropAccount(id); + } + await this.engine.refreshRoots(); + } + + // ─── The store ────────────────────────────────────────────── + + private schedulePublish(): void { + if (this.publishTimer || this.stopped) return; + this.publishTimer = setTimeout(() => { + this.publishTimer = null; + this.publishNow(); + }, 50); + } + + /** What the UI shows: the account on screen's roots, their status and activity. */ + publishNow(): void { + if (this.stopped) return; + const current = this.current; + const roots = current ? this.engine.roots.filter((root) => root.account === current) : []; + const ids = new Set(roots.map((root) => root.id)); + const status = Object.fromEntries(Object.entries(this.engine.status()).filter(([id]) => ids.has(id))); + this.deps.publish({ + available: true, + account: current, + roots, + status, + activity: this.engine.activity.filter((entry) => ids.has(entry.rootId)), + drive: this.drive, + folderSyncForcedOff: this.deps.folderSyncForcedOff(), + items: current ? this.engine.items(current) : {}, + }); + } + + controller(): FilesSyncController { + const controls = this.deps.controls; + return { + // Main wants the click this came from: the bridge call goes first, before any await. + addFolder: async (remoteId, remoteLabel) => { + const account = this.current; + if (!account) return fail('inactive'); + // A folder of someone else's shared drive: the engine syncs the account's own Files. + if (remoteId !== null && remoteId.includes(':')) return fail('unsupported'); + const result = await controls.addFolder({ account, remoteId, remoteLabel }); + await this.engine.refreshRoots(); + return result; + }, + removeRoot: (rootId) => this.engine.removeRoot(rootId), + setPaused: (rootId, paused) => this.engine.setPaused(rootId, paused), + confirmMassDelete: async (rootId, allow) => { + this.engine.confirmMassDelete(rootId, allow); + }, + setDriveEnabled: async (enabled) => { + const account = this.current; + const drive = controls.drive; + if (!drive) return fail('unsupported'); + if (!account) return fail('inactive'); + if (enabled) { + const label = this.accounts.get(account)?.label ?? account; + const result = await drive.enable(account, label); + if (result.ok) this.driveStarted.add(account); + await this.engine.refreshRoots(); + return result; + } + await drive.disable(account, 'user'); + this.driveStarted.delete(account); + await this.engine.refreshRoots(); + return { ok: true, value: null }; + }, + setDriveLetter: async (letter) => { + const drive = controls.drive; + if (!drive) return fail('unsupported'); + return drive.setLetter(letter); + }, + coverage: (nodeId) => (this.current ? this.engine.coverage(this.current, nodeId) : null), + showInFolder: async (nodeId) => { + const coverage = this.current ? this.engine.coverage(this.current, nodeId) : null; + if (!coverage) return false; + return controls.showInFolder(coverage.rootId, coverage.path); + }, + clearActivity: () => this.engine.clearActivity(), + }; + } +} + +/** + * For a wipe or a refused login, with or without a running host: the drive goes with + * everything in it, the folders are forgotten (their files stay). Bridge calls only: a wipe + * often runs because the server said no. + */ +export async function removeAccountData(controls: FilesControls, account: string | null, reason: 'wipe' | 'refused', known: string[] = []): Promise { + const roots = await controls.roots().catch(() => [] as FilesRoot[]); + const drive = controls.drive; + const wanted = drive ? await drive.state().then((state) => state.wanted, () => [] as string[]) : []; + const accounts = account === null ? [...new Set([...known, ...wanted, ...roots.map((root) => root.account)])] : [account]; + for (const id of accounts) { + if (drive && (wanted.includes(id) || roots.some((root) => root.kind === 'drive' && root.account === id))) { + await drive.disable(id, reason).catch(() => {}); + } + for (const root of roots) { + if (root.kind === 'folder' && root.account === id) await controls.removeRoot(root.id).catch(() => {}); + } + } +} diff --git a/lib/files-sync/lifecycle.ts b/lib/files-sync/lifecycle.ts new file mode 100644 index 000000000..c2d0550cc --- /dev/null +++ b/lib/files-sync/lifecycle.ts @@ -0,0 +1,72 @@ +/** + * How Files on this computer follows an account out: sign-out, a wipe, a refused login. Hooked + * in where edit-in-place's copies are (lib/desktop/edit-attachments.ts): the wipe registry, the + * sign-out tasks, the auth store's word on a refused login, and the account list. + * + * - Sign-out: the drive goes (`signed-out`: the next sign-in brings it back) and the account's + * folders stop syncing until then; nothing is deleted from them. + * - A wipe (remote, from settings, the organisation's): `wipe`. The drive goes with everything + * in it; the folders are forgotten, their files stay. + * - A refused login the server was sure of (`invalid_grant`, a lasting 401): `refused`, as a + * wipe for these. An unsure one, or the organisation's idle limit, is a sign-out: a single + * bad answer at a start can be a server in trouble, and deleting the drive's unsynced edits + * for that would lose work. + */ + +import { onCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { onDeviceWipe } from '@/lib/offline/wipe-registry'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { onBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { onAccountRemoved } from '@/stores/account-store'; +import { claimFilesControls } from './controls'; +import { removeAccountData, type FilesSyncHost } from './host'; + +let running: FilesSyncHost | null = null; + +export function setRunningFilesSyncHost(host: FilesSyncHost | null): void { + running = host; +} + +/** Signed out without a host running: the drive still goes. */ +async function signedOutWithoutHost(account: string): Promise { + const drive = claimFilesControls()?.drive; + if (!drive) return; + const wanted = account === 'all' ? await drive.state().then((state) => state.wanted, () => [] as string[]) : [account]; + for (const id of wanted) await drive.disable(id, 'signed-out').catch(() => {}); +} + +async function remove(account: string | null, reason: 'wipe' | 'refused'): Promise { + if (running) { + await running.removeAccountData(account, reason); + return; + } + const controls = claimFilesControls(); + if (controls) await removeAccountData(controls, account, reason); +} + +let registered = false; + +export function registerFilesSyncLifecycle(): void { + if (registered || typeof window === 'undefined' || isPopoutWindow()) return; + registered = true; + onDeviceWipe((account) => remove(account, 'wipe').catch(() => {})); + onCredentialsRejected((account, certain, kind) => { + if (certain && kind === 'refused') void remove(account, 'refused').catch(() => {}); + }); + onBeforeSignOut(async (account) => { + if (running) await running.signedOut(account).catch(() => {}); + else await signedOutWithoutHost(account); + }); + onAccountRemoved((account, reason) => { + // A wipe has its own way out, above. + if (reason === 'wiped') return; + if (running) void running.signedOut(account).catch(() => {}); + else void signedOutWithoutHost(account); + }); +} + +/** Tests only. */ +export function resetFilesSyncLifecycleForTests(): void { + registered = false; + running = null; +}