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;
+}