feat: run files sync in the desktop app's main window for the signed-in accounts

This commit is contained in:
Linus Rath
2026-11-04 16:57:31 +01:00
parent b177aa4746
commit 65b1993b30
7 changed files with 753 additions and 1 deletions
+2
View File
@@ -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({
<PushBindingsHost />
<DesktopOsHost />
{!IS_LITE && <EditedFilesHost />}
{!IS_LITE && <FilesSyncHost />}
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
+5 -1
View File
@@ -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<TitlebarArea | null>(null);
useEffect(() => watchTitlebarArea((next) => {
+92
View File
@@ -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<Host | null>(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;
}
+225
View File
@@ -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<FilesSyncSnapshot>;
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<FilesSyncSnapshot> = {};
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<void> {
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([]);
});
});
+28
View File
@@ -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;
}
+329
View File
@@ -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, unknown>, string][]): Promise<unknown>;
}
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<FilesSyncSnapshot>) => 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<EngineTiming>;
readUrl?: (url: string) => Promise<Uint8Array>;
}
const DEVICE: Record<string, string> = { 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 = <T>(error: Exclude<FilesResult<T>, { ok: true }>['error']): FilesResult<T> => ({ ok: false, error });
export class FilesSyncHost {
readonly engine: FilesSyncEngine;
private readonly accounts = new Map<string, HostAccount>();
private current: string | null = null;
private drive: FilesDriveState | null = null;
private publishTimer: ReturnType<typeof setTimeout> | null = null;
private readonly unsubscribe: (() => void)[] = [];
/** Accounts the drive was turned on for at this sign-in already. */
private readonly driveStarted = new Set<string>();
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<FilesRoot | null> {
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<void> {
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<void> {
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<void> {
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(() => {});
}
}
}
+72
View File
@@ -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<void> {
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<void> {
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;
}