mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-07 00:13:56 +00:00
feat: run files sync in the desktop app's main window for the signed-in accounts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user