fix(sync): defer LocalFile folder pick commit to settings Save (#9075) (#9085)

* fix(sync): defer LocalFile folder pick commit to settings Save (#9075)

* test(sync): pin Android SAF pick route through form value (#9075)
This commit is contained in:
Johannes Millan 2026-07-16 19:11:02 +02:00 committed by GitHub
parent 5a50297215
commit 6da578aa8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 721 additions and 113 deletions

View file

@ -83,7 +83,7 @@ Then in Super Productivity:
Available on **desktop (Electron)** and **Android** only; not in the web app. Available on **desktop (Electron)** and **Android** only; not in the web app.
- **Info:** The form states that local file sync stores data in a folder on your device and that you should **not** use file-syncing tools (e.g. Syncthing, Resilio) to sync that folder between devices—they can cause conflicts. For multi-device sync it recommends WebDAV, Dropbox, or SuperSync. - **Info:** The form states that local file sync stores data in a folder on your device and that you should **not** use file-syncing tools (e.g. Syncthing, Resilio) to sync that folder between devices—they can cause conflicts. For multi-device sync it recommends WebDAV, Dropbox, or SuperSync.
- **Sync folder path:** Required. On desktop, use the **Sync folder path** button to open a folder picker and choose where the sync file is stored. On Android, use the same button to open the Storage Access Framework (SAF) and select the folder. - **Sync folder path:** Required. On desktop, use the **Sync folder path** button to open a folder picker and choose where the sync file is stored. On Android, use the same button to open the Storage Access Framework (SAF) and select the folder. The picked folder only takes effect when you press **Save**; closing the dialog without saving keeps the previous folder.
- **Sync interval** and **Only sync manually** apply. **Encryption** is available under **Advanced** for file-based providers. - **Sync interval** and **Only sync manually** apply. **Encryption** is available under **Advanced** for file-based providers.
--- ---

View file

@ -59,16 +59,39 @@ export interface ElectronAPI {
checkDirExists(args: { relativePath?: string }): Promise<true | Error>; checkDirExists(args: { relativePath?: string }): Promise<true | Error>;
/** /**
* Opens the native folder picker for the sync folder. Resolves to: * Opens the native folder picker for the sync folder. Prepare-only (#9075):
* - `string`: the canonicalized, persisted folder path on success * the pick is held main-side as a pending candidate and does NOT become the
* live sync target until `commitPickedDirectory()` (settings Save).
* Resolves to:
* - `string`: the canonicalized candidate folder path (display only)
* - `undefined`: the user cancelled the picker * - `undefined`: the user cancelled the picker
* - `Error`: the pick succeeded but main could not canonicalize/persist it * - `Error`: the pick succeeded but main could not canonicalize/validate it
* (e.g. the folder was deleted between pick and commit, EACCES, or the * (e.g. the folder was deleted right after picking, EACCES, or the
* folder lives inside the app's private dir). Nothing is persisted in * folder lives inside the app's private dir). No candidate is stored in
* this case; the renderer must treat it as a failure, not a picked path. * this case; the renderer must treat it as a failure, not a picked path.
*/ */
pickDirectory(): Promise<string | Error | undefined>; pickDirectory(): Promise<string | Error | undefined>;
/**
* Persists the pending picked folder (see `pickDirectory`) as the live sync
* target. Call from settings Save only. Resolves to:
* - `{ path, isChanged }`: committed; `isChanged` is false when the user
* re-picked the folder that was already configured (callers must skip
* target-change invalidation in that case)
* - `null`: no pending candidate (routine save without a pick) a no-op
* - `Error`: validation/persistence failed (e.g. folder deleted between
* pick and Save). The candidate is kept so a retry fails loudly instead
* of silently saving without the folder change.
*/
commitPickedDirectory(): Promise<{ path: string; isChanged: boolean } | null | Error>;
/**
* Drops the pending picked folder without touching the live sync target.
* Call when the settings UI closes without a save. No-op if nothing is
* pending.
*/
discardPickedDirectory(): Promise<void>;
/** /**
* Returns the main-owned sync folder path for display, or null if not yet * Returns the main-owned sync folder path for display, or null if not yet
* configured. The renderer must not pass this value back to file-sync IPCs * configured. The renderer must not pass this value back to file-sync IPCs

View file

@ -24,6 +24,16 @@ let handlers = {};
let userDataDir; let userDataDir;
let externalDir; let externalDir;
let nextDialogResult = { canceled: true, filePaths: [] }; let nextDialogResult = { canceled: true, filePaths: [] };
let winStub;
const makeWinStub = () => ({
webContents: {
_handlers: {},
on(evt, fn) {
this._handlers[evt] = fn;
},
},
});
const installMocks = () => { const installMocks = () => {
Module._load = function patchedLoad(request, parent, isMain) { Module._load = function patchedLoad(request, parent, isMain) {
@ -40,7 +50,7 @@ const installMocks = () => {
return { log: () => {}, error: () => {} }; return { log: () => {}, error: () => {} };
} }
if (/[/\\]main-window$/.test(request)) { if (/[/\\]main-window$/.test(request)) {
return { getWin: () => ({}) }; return { getWin: () => winStub };
} }
return originalModuleLoad.call(this, request, parent, isMain); return originalModuleLoad.call(this, request, parent, isMain);
}; };
@ -59,17 +69,20 @@ const load = () => {
}; };
const configureSyncFolder = async (folder) => { const configureSyncFolder = async (folder) => {
// Drive the real PICK_DIRECTORY path so the test exercises the same // Drive the real pick → commit path so the test exercises the same
// canonicalize-and-persist code production uses. // prepare-then-persist code production uses (#9075).
nextDialogResult = { canceled: false, filePaths: [folder] }; nextDialogResult = { canceled: false, filePaths: [folder] };
const result = await handlers['PICK_DIRECTORY']({}); const picked = await handlers['PICK_DIRECTORY']({});
if (result instanceof Error) throw result; if (picked instanceof Error) throw picked;
return result; const committed = await handlers['COMMIT_PICKED_DIRECTORY']({});
if (committed instanceof Error) throw committed;
return committed.path;
}; };
test.beforeEach(() => { test.beforeEach(() => {
userDataDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ud-'))); userDataDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ud-')));
externalDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ext-'))); externalDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ext-')));
winStub = makeWinStub();
installMocks(); installMocks();
load(); load();
}); });
@ -263,30 +276,189 @@ test('GET_SYNC_FOLDER_PATH returns null when unconfigured, then the configured p
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir); assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir);
}); });
test('PICK_DIRECTORY persists the selection main-side', async () => { test('PICK_DIRECTORY is prepare-only: nothing goes live until commit (#9075)', async () => {
nextDialogResult = { canceled: false, filePaths: [externalDir] }; nextDialogResult = { canceled: false, filePaths: [externalDir] };
const returned = await handlers['PICK_DIRECTORY']({}); const returned = await handlers['PICK_DIRECTORY']({});
assert.equal(returned, externalDir); assert.equal(returned, externalDir, 'returns the candidate for display');
assert.equal( assert.equal(
await handlers['GET_SYNC_FOLDER_PATH']({}), await handlers['GET_SYNC_FOLDER_PATH']({}),
externalDir, null,
'main-side store is updated; renderer does not have to echo back', 'live target untouched by the pick',
); );
const committed = await handlers['COMMIT_PICKED_DIRECTORY']({});
assert.deepEqual(committed, { path: externalDir, isChanged: true });
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir);
}); });
test('PICK_DIRECTORY surfaces a safe error when persistence fails', async () => { test('a sync between pick and commit still resolves against the OLD folder (#9075)', async () => {
const oldDir = externalDir;
const newDir = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-new-')),
);
try {
await configureSyncFolder(oldDir);
nextDialogResult = { canceled: false, filePaths: [newDir] };
await handlers['PICK_DIRECTORY']({});
const result = await handlers['FILE_SYNC_SAVE'](
{},
{ relativePath: 'main.json', dataStr: '{"ok":1}', localRev: null },
);
assert.ok(!(result instanceof Error));
assert.ok(fs.existsSync(path.join(oldDir, 'main.json')), 'written to old folder');
assert.equal(fs.existsSync(path.join(newDir, 'main.json')), false);
} finally {
fs.rmSync(newDir, { recursive: true, force: true });
}
});
test('DISCARD_PICKED_DIRECTORY abandons the pick; the old target stays live (#9075)', async () => {
const oldDir = externalDir;
const newDir = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-new-')),
);
try {
await configureSyncFolder(oldDir);
nextDialogResult = { canceled: false, filePaths: [newDir] };
await handlers['PICK_DIRECTORY']({});
await handlers['DISCARD_PICKED_DIRECTORY']({});
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), oldDir);
assert.equal(
await handlers['COMMIT_PICKED_DIRECTORY']({}),
null,
'a later save has nothing to commit',
);
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), oldDir);
} finally {
fs.rmSync(newDir, { recursive: true, force: true });
}
});
test('COMMIT_PICKED_DIRECTORY without a pick is a null no-op (routine save)', async () => {
await configureSyncFolder(externalDir);
assert.equal(await handlers['COMMIT_PICKED_DIRECTORY']({}), null);
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir);
});
test('picking twice keeps only the last candidate', async () => {
const otherDir = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-other-')),
);
try {
nextDialogResult = { canceled: false, filePaths: [otherDir] };
await handlers['PICK_DIRECTORY']({});
nextDialogResult = { canceled: false, filePaths: [externalDir] };
await handlers['PICK_DIRECTORY']({});
const committed = await handlers['COMMIT_PICKED_DIRECTORY']({});
assert.equal(committed.path, externalDir);
} finally {
fs.rmSync(otherDir, { recursive: true, force: true });
}
});
test('re-picking the configured folder commits with isChanged:false', async () => {
// The renderer gates its target-change invalidation (cursor wipe) on
// isChanged — a same-folder re-pick must not report a move.
await configureSyncFolder(externalDir);
nextDialogResult = { canceled: false, filePaths: [externalDir] };
await handlers['PICK_DIRECTORY']({});
const committed = await handlers['COMMIT_PICKED_DIRECTORY']({});
assert.deepEqual(committed, { path: externalDir, isChanged: false });
});
test('PICK_DIRECTORY surfaces a safe error when validation fails', async () => {
// Picker returns a path that no longer exists on disk → realpath throws // Picker returns a path that no longer exists on disk → realpath throws
// → PICK_DIRECTORY must return a distinct Error, not undefined, so the // → PICK_DIRECTORY must return a distinct Error, not undefined, so the
// renderer doesn't confuse persist-failure with user-cancel. // renderer doesn't confuse validation failure with user-cancel.
const gone = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-gone-')); const gone = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-gone-'));
fs.rmSync(gone, { recursive: true, force: true }); fs.rmSync(gone, { recursive: true, force: true });
nextDialogResult = { canceled: false, filePaths: [gone] }; nextDialogResult = { canceled: false, filePaths: [gone] };
const result = await handlers['PICK_DIRECTORY']({}); const result = await handlers['PICK_DIRECTORY']({});
assert.ok(result instanceof Error); assert.ok(result instanceof Error);
// Cache must not be poisoned with a non-existent path. // No candidate stored: a later save must not commit the bad path.
assert.equal(await handlers['COMMIT_PICKED_DIRECTORY']({}), null);
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), null); assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), null);
}); });
test('COMMIT_PICKED_DIRECTORY errors when the folder vanished between pick and save', async () => {
const oldDir = externalDir;
const doomed = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-doomed-')),
);
await configureSyncFolder(oldDir);
nextDialogResult = { canceled: false, filePaths: [doomed] };
await handlers['PICK_DIRECTORY']({});
fs.rmSync(doomed, { recursive: true, force: true });
const result = await handlers['COMMIT_PICKED_DIRECTORY']({});
assert.ok(result instanceof Error);
assert.equal(
await handlers['GET_SYNC_FOLDER_PATH']({}),
oldDir,
'live target not poisoned by the failed commit',
);
// The candidate is kept so a retried save fails loudly instead of
// silently saving without the folder change.
const retry = await handlers['COMMIT_PICKED_DIRECTORY']({});
assert.ok(retry instanceof Error);
});
test('a discard while the native picker is open prevents arming an orphaned candidate (#9075)', async () => {
// closeAllDialogs()/reload can tear down the settings dialog (running its
// discard) while the native folder picker is still open. The resolving
// pick must NOT arm a candidate nobody owns — a later unrelated save would
// silently commit it.
await configureSyncFolder(externalDir);
let resolveDialog;
nextDialogResult = new Promise((resolve) => (resolveDialog = resolve));
const newDir = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-new-')),
);
try {
const pickPromise = handlers['PICK_DIRECTORY']({});
await handlers['DISCARD_PICKED_DIRECTORY']({});
resolveDialog({ canceled: false, filePaths: [newDir] });
assert.equal(await pickPromise, undefined, 'disowned pick reads as cancel');
assert.equal(await handlers['COMMIT_PICKED_DIRECTORY']({}), null);
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir);
} finally {
fs.rmSync(newDir, { recursive: true, force: true });
}
});
test('a renderer reload clears the pending pick — no surprise commit on a later save (#9075)', async () => {
const newDir = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-new-')),
);
try {
await configureSyncFolder(externalDir);
nextDialogResult = { canceled: false, filePaths: [newDir] };
await handlers['PICK_DIRECTORY']({});
// The dialog's renderer-side discard hook dies with the page on reload;
// main must drop the candidate itself.
winStub.webContents._handlers['did-navigate']();
assert.equal(await handlers['COMMIT_PICKED_DIRECTORY']({}), null);
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), externalDir);
} finally {
fs.rmSync(newDir, { recursive: true, force: true });
}
});
test('PICK_DIRECTORY rejects a folder inside userData without storing a candidate', async () => {
const inside = path.join(userDataDir, 'sneaky');
fs.mkdirSync(inside);
nextDialogResult = { canceled: false, filePaths: [inside] };
const result = await handlers['PICK_DIRECTORY']({});
assert.ok(result instanceof Error);
assert.equal(await handlers['COMMIT_PICKED_DIRECTORY']({}), null);
});
test('PICK_DIRECTORY returns undefined on user-cancel (distinct from error)', async () => { test('PICK_DIRECTORY returns undefined on user-cancel (distinct from error)', async () => {
nextDialogResult = { canceled: true, filePaths: [] }; nextDialogResult = { canceled: true, filePaths: [] };
const result = await handlers['PICK_DIRECTORY']({}); const result = await handlers['PICK_DIRECTORY']({});

View file

@ -34,6 +34,41 @@ const getAppPrivateDir = (): string => app.getPath('userData');
let _cachedSyncFolder: string | null | undefined = undefined; let _cachedSyncFolder: string | null | undefined = undefined;
let _loadPromise: Promise<string | null> | null = null; let _loadPromise: Promise<string | null> | null = null;
// Folder picked but not yet committed via settings Save (#9075). Memory-only
// on purpose: a crash or close-without-save must leave the live sync target
// untouched. Main-owned so the renderer never round-trips an absolute path
// (#8228) — commit/discard reference this slot, they don't carry a path.
let _pendingSyncFolder: string | null = null;
// Bumped by every discard (IPC or renderer-death reset below). A pick
// snapshots it before opening the native dialog and refuses to arm the slot
// if it moved meanwhile: a discard during an open picker means the owning
// settings UI is gone (closeAllDialogs(), reload, non-modal WM quirks), and
// arming anyway would orphan a candidate that a later unrelated Save would
// silently commit.
let _discardGeneration = 0;
const _discardPendingSyncFolder = (): void => {
_pendingSyncFolder = null;
_discardGeneration++;
};
// A renderer reload/crash destroys the settings dialog without running its
// discard hook, which would leave a picked-but-never-saved folder armed to
// commit on an unrelated Save much later. Drop the candidate whenever the
// renderer navigates away or dies (in-app Angular routing is same-document
// and does not fire did-navigate). Installed lazily on first pick — the main
// window is guaranteed to exist there.
let _isPendingResetHookInstalled = false;
const _installPendingResetHook = (win: ReturnType<typeof getWin>): void => {
if (_isPendingResetHookInstalled || !win?.webContents?.on) {
return;
}
win.webContents.on('did-navigate', _discardPendingSyncFolder);
win.webContents.on('render-process-gone', _discardPendingSyncFolder);
_isPendingResetHookInstalled = true;
};
const _loadSyncFolderFromDisk = async (): Promise<string | null> => { const _loadSyncFolderFromDisk = async (): Promise<string | null> => {
const all = await loadSimpleStoreAll(); const all = await loadSimpleStoreAll();
const raw = all[SimpleStoreKey.SYNC_FOLDER_PATH]; const raw = all[SimpleStoreKey.SYNC_FOLDER_PATH];
@ -287,7 +322,10 @@ export const initLocalFileSyncAdapter = (): void => {
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | Error | undefined> => { ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | Error | undefined> => {
try { try {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), { const win = getWin();
_installPendingResetHook(win);
const discardGenAtOpen = _discardGeneration;
const { canceled, filePaths } = (await dialog.showOpenDialog(win, {
title: 'Select sync folder', title: 'Select sync folder',
buttonLabel: 'Select Folder', buttonLabel: 'Select Folder',
properties: [ properties: [
@ -300,19 +338,69 @@ export const initLocalFileSyncAdapter = (): void => {
if (canceled || !filePaths[0]) { if (canceled || !filePaths[0]) {
return undefined; return undefined;
} }
// Persist main-side BEFORE returning the display string. If if (_discardGeneration !== discardGenAtOpen) {
// canonicalization or persistence fails (deleted between pick and // The settings UI that owned this pick was torn down (its discard
// commit, EACCES on userData, etc.), surface a safe error rather than // already ran) while the native dialog was open — nobody is left to
// a silent undefined — otherwise the renderer cannot distinguish // commit or discard the result. Treat as cancel instead of arming an
// failure from user-cancel and the user is left wondering why their // ownerless candidate.
// pick didn't take. return undefined;
return await setSyncFolderPath(filePaths[0]); }
// Prepare-only (#9075): validate NOW so a bad pick errors at pick time,
// but do NOT persist or swap the live root — a sync firing between pick
// and settings Save must still hit the old folder, and Cancel must be
// able to abandon the pick. COMMIT_PICKED_DIRECTORY makes it live.
// Surfacing validation failures as a safe error (not undefined) keeps
// them distinguishable from user-cancel.
const canonical = realpathSync.native(filePaths[0]);
assertPathOutside(getAppPrivateDir(), canonical);
_pendingSyncFolder = canonical;
return canonical;
} catch (e) { } catch (e) {
error('PICK_DIRECTORY failed to persist sync folder', getSafeErrorMeta(e)); error('PICK_DIRECTORY failed to validate picked folder', getSafeErrorMeta(e));
return createSafeIpcError(IPC.PICK_DIRECTORY, e); return createSafeIpcError(IPC.PICK_DIRECTORY, e);
} }
}); });
ipcMain.handle(
IPC.COMMIT_PICKED_DIRECTORY,
async (): Promise<{ path: string; isChanged: boolean } | null | Error> => {
try {
// Capture the slot before any await: a DISCARD or PICK landing while
// `prev` loads must not turn this commit into a null-deref or swap
// the path under it mid-flight.
const pending = _pendingSyncFolder;
if (pending === null) {
// Nothing picked this session (or already committed/discarded) —
// a routine settings Save without a pick. Not an error.
return null;
}
const prev = await getSyncFolderPath();
// Re-canonicalize + re-validate at commit time: the folder can be
// deleted or symlink-swapped between pick and Save. On failure the
// pending slot is kept so a retry stays loud (errors again) instead
// of silently saving without the folder change; the user re-picks
// or cancels.
const committed = await setSyncFolderPath(pending);
// Only clear the slot if it still holds what we committed — a newer
// pick that raced in must survive for its own commit.
if (_pendingSyncFolder === pending) {
_pendingSyncFolder = null;
}
// isChanged lets the renderer fire its target-change invalidation
// only on a real move — re-picking the same folder must not wipe
// per-target sync state (see notifyProviderTargetChanged docs).
return { path: committed, isChanged: committed !== prev };
} catch (e) {
error('COMMIT_PICKED_DIRECTORY failed to persist', getSafeErrorMeta(e));
return createSafeIpcError(IPC.COMMIT_PICKED_DIRECTORY, e);
}
},
);
ipcMain.handle(IPC.DISCARD_PICKED_DIRECTORY, async (): Promise<void> => {
_discardPendingSyncFolder();
});
ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => { ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => {
return getSyncFolderPath(); return getSyncFolderPath();
}); });

View file

@ -60,6 +60,11 @@ const ea: ElectronAPI = {
checkDirExists: (args) => _invoke('CHECK_DIR_EXISTS', args) as Promise<true | Error>, checkDirExists: (args) => _invoke('CHECK_DIR_EXISTS', args) as Promise<true | Error>,
pickDirectory: () => _invoke('PICK_DIRECTORY') as Promise<string | Error | undefined>, pickDirectory: () => _invoke('PICK_DIRECTORY') as Promise<string | Error | undefined>,
commitPickedDirectory: () =>
_invoke('COMMIT_PICKED_DIRECTORY') as Promise<
{ path: string; isChanged: boolean } | null | Error
>,
discardPickedDirectory: () => _invoke('DISCARD_PICKED_DIRECTORY') as Promise<void>,
getSyncFolderPath: () => _invoke('GET_SYNC_FOLDER_PATH') as Promise<string | null>, getSyncFolderPath: () => _invoke('GET_SYNC_FOLDER_PATH') as Promise<string | null>,
showOpenDialog: (options: { showOpenDialog: (options: {

View file

@ -47,6 +47,8 @@ export enum IPC {
CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS', CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS',
PICK_DIRECTORY = 'PICK_DIRECTORY', PICK_DIRECTORY = 'PICK_DIRECTORY',
COMMIT_PICKED_DIRECTORY = 'COMMIT_PICKED_DIRECTORY',
DISCARD_PICKED_DIRECTORY = 'DISCARD_PICKED_DIRECTORY',
GET_SYNC_FOLDER_PATH = 'GET_SYNC_FOLDER_PATH', GET_SYNC_FOLDER_PATH = 'GET_SYNC_FOLDER_PATH',
IMAGE_PICK_AND_IMPORT = 'IMAGE_PICK_AND_IMPORT', IMAGE_PICK_AND_IMPORT = 'IMAGE_PICK_AND_IMPORT',
IMAGE_CACHE_GET_DATA_URL = 'IMAGE_CACHE_GET_DATA_URL', IMAGE_CACHE_GET_DATA_URL = 'IMAGE_CACHE_GET_DATA_URL',

View file

@ -28,13 +28,16 @@ export class LocalFileSyncAndroid extends LocalFileSyncBase {
return false; return false;
} }
/**
* Opens the SAF folder picker and returns the picked URI WITHOUT persisting
* it (#9075). The caller (sync settings form) holds the URI in its model as
* `safFolderUri` and persists it via the normal settings-Save path
* (`setProviderConfig`), so Cancel abandons the pick and a sync firing
* between pick and Save still targets the old folder. Throws on cancel.
*/
async setupSaf(): Promise<string> { async setupSaf(): Promise<string> {
try { try {
const uri = await this._deps.saf.selectFolder(); return await this._deps.saf.selectFolder();
await this.privateCfg.upsertPartial({
safFolderUri: uri,
});
return uri;
} catch (error) { } catch (error) {
this.logger.err('Failed to setup SAF', toSyncLogError(error)); this.logger.err('Failed to setup SAF', toSyncLogError(error));
throw error; throw error;

View file

@ -4,11 +4,13 @@ import { LocalFileSyncBase, type LocalFileSyncBaseDeps } from './local-file-sync
export interface LocalFileSyncElectronDeps extends LocalFileSyncBaseDeps { export interface LocalFileSyncElectronDeps extends LocalFileSyncBaseDeps {
isElectron: boolean; isElectron: boolean;
/** /**
* Show the system folder picker and persist the result main-side. Returns * Show the system folder picker. Prepare-only (#9075): main holds the pick
* the picked path for display only, or undefined if the user cancelled. * as a pending candidate; the live sync target is untouched until
* Rejects when the pick succeeded but main could not canonicalize/persist * `commitPickedDirectory()` (settings Save). Returns the candidate path for
* the folder (e.g. it was deleted between pick and commit, EACCES, or it * display only, or undefined if the user cancelled. Rejects when the pick
* lives inside the app's private dir); nothing is persisted in that case. * succeeded but main could not canonicalize/validate the folder (e.g. it
* was deleted right after picking, EACCES, or it lives inside the app's
* private dir); no candidate is stored in that case.
* The renderer must NOT pass the returned value back into FS IPCs those * The renderer must NOT pass the returned value back into FS IPCs those
* take a relative path and main resolves against its own stored copy. * take a relative path and main resolves against its own stored copy.
*/ */
@ -68,8 +70,10 @@ export class LocalFileSyncElectron extends LocalFileSyncBase {
this.logger.normal(`${LocalFileSyncElectron.L}.pickDirectory()`); this.logger.normal(`${LocalFileSyncElectron.L}.pickDirectory()`);
try { try {
// Main-side handler persists the result; we just relay the display // Main-side handler holds the pick as a pending candidate (#9075); we
// value to the caller (typically a settings form). // just relay the display value to the caller (typically a settings
// form). Nothing goes live until the settings dialog commits the pick
// on Save (host-app concern, straight over the Electron bridge).
return await this._deps.pickDirectory(); return await this._deps.pickDirectory();
} catch (e) { } catch (e) {
this.logger.error( this.logger.error(

View file

@ -69,13 +69,16 @@ describe('LocalFileSyncAndroid', () => {
expect(await store.load()).toEqual({ safFolderUri: undefined }); expect(await store.load()).toEqual({ safFolderUri: undefined });
}); });
it('setupSaf stores selected URI', async () => { it('setupSaf returns the selected URI WITHOUT persisting it (#9075)', async () => {
// Prepare-only: the URI lives in the settings form model until Save
// persists it via setProviderConfig. Persisting here would flip the live
// sync target before Save, with no rollback on Cancel.
const store = fakeStore(null); const store = fakeStore(null);
const { provider, selectFolder } = makeProvider(store); const { provider, selectFolder } = makeProvider(store);
await expect(provider.setupSaf()).resolves.toBe('content://uri'); await expect(provider.setupSaf()).resolves.toBe('content://uri');
expect(selectFolder).toHaveBeenCalledTimes(1); expect(selectFolder).toHaveBeenCalledTimes(1);
expect(await store.load()).toEqual({ safFolderUri: 'content://uri' }); expect(await store.load()).toBeNull();
}); });
it('uses targetPath directly as file adapter path', async () => { it('uses targetPath directly as file adapter path', async () => {

View file

@ -0,0 +1,146 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { TranslateModule } from '@ngx-translate/core';
import { SYNC_FORM } from './sync-form.const';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { FormlyBtnComponent } from '../../../ui/formly-button/formly-btn.component';
import { Log } from '../../../core/log';
@Component({
selector: 'saf-pick-host',
standalone: true,
imports: [ReactiveFormsModule, FormlyModule],
template: `<form [formGroup]="form">
<formly-form
[form]="form"
[fields]="fields"
[model]="model"
></formly-form>
</form>`,
})
class SafPickHostComponent {
form = new UntypedFormGroup({});
// Root model carries the selected provider so the field's REAL
// 'props.required' expression (isSyncProvider at depth 2) evaluates live.
model: Record<string, unknown> = { syncProvider: SyncProviderId.LocalFile };
fields: FormlyFieldConfig[] = [];
}
/**
* #9075 made the Android SAF pick prepare-only: `setupSaf()` no longer
* persists, so the ONLY route from picker to credential store is
* `form.value.localFileSync.safFolderUri` settings Save
* `setProviderConfig`. These tests mount the REAL field config from
* SYNC_FORM with the REAL `btn` Formly type (only the platform gate and the
* provider-loading onClick are stubbed) and pin that route end to end
* the dialog spec stubs its template, so nothing else executes it.
*/
describe('SYNC_FORM Android SAF pick → form value (#9075)', () => {
const PICKED_URI = 'content://com.android.externalstorage.documents/tree/primary';
const findAndroidSafField = (): FormlyFieldConfig => {
const field = (SYNC_FORM.items as FormlyFieldConfig[])
.filter((f) => f.key === 'localFileSync')
.flatMap((f) => f.fieldGroup ?? [])
.find((f) => f.key === 'safFolderUri');
if (!field) {
throw new Error('Android SAF picker field not found in SYNC_FORM');
}
return field;
};
let fixture: ComponentFixture<SafPickHostComponent>;
let host: SafPickHostComponent;
let onClickSpy: jasmine.Spy;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SafPickHostComponent,
FormlyBtnComponent,
// Same registration shape as formly-config.module.ts.
FormlyModule.forRoot({
types: [{ name: 'btn', component: FormlyBtnComponent, wrappers: [] }],
}),
TranslateModule.forRoot(),
],
providers: [provideNoopAnimations()],
}).compileComponents();
const realField = findAndroidSafField();
onClickSpy = jasmine.createSpy('setupSaf onClick').and.resolveTo(PICKED_URI);
fixture = TestBed.createComponent(SafPickHostComponent);
host = fixture.componentInstance;
// The real group wrapper is platform-gated (hidden off-Android), so
// rebuild it with the same key; the field keeps its real key, type,
// and expressions — only onClick (which loads providers and opens the
// native SAF picker) is stubbed.
host.fields = [
{
key: 'localFileSync',
fieldGroup: [
{
...realField,
templateOptions: { ...realField.templateOptions, onClick: onClickSpy },
},
],
},
];
fixture.detectChanges();
});
const clickPickButton = async (): Promise<void> => {
fixture.debugElement.query(By.css('button')).nativeElement.click();
await fixture.whenStable();
fixture.detectChanges();
};
it('keeps the picker at localFileSync.safFolderUri as a btn field (the path Save persists)', () => {
const field = findAndroidSafField();
expect(field.type).toBe('btn');
// updateSettingsFromForm reads settings.localFileSync (PROP_MAP_TO_FORM)
// and persists it via setProviderConfig — renaming either key silently
// severs the only persistence route left after #9075.
expect(field.key).toBe('safFolderUri');
});
it('writes the picked URI into form.value at the path Save reads, and dirties the form', async () => {
await clickPickButton();
expect(onClickSpy).toHaveBeenCalledTimes(1);
expect(
(host.form.value as { localFileSync?: { safFolderUri?: string } }).localFileSync
?.safFolderUri,
).toBe(PICKED_URI);
expect(host.form.dirty).toBe(true);
});
it('blocks Save (required → invalid) until a folder is picked, then validates', async () => {
expect(host.form.invalid)
.withContext('no folder picked yet — the real required expression must gate Save')
.toBe(true);
await clickPickButton();
expect(host.form.valid).toBe(true);
});
it('leaves the form untouched when the picker is cancelled (setupSaf rejects)', async () => {
const logErrSpy = spyOn(Log, 'err');
onClickSpy.and.rejectWith(new Error('User cancelled folder selection'));
await clickPickButton();
expect(
(host.form.value as { localFileSync?: { safFolderUri?: string } }).localFileSync
?.safFolderUri,
).toBeFalsy();
expect(host.form.invalid).toBe(true);
expect(logErrSpy).toHaveBeenCalled();
});
});

View file

@ -2,7 +2,6 @@
import { T } from '../../../t.const'; import { T } from '../../../t.const';
import { ConfigFormSection, SyncConfig } from '../global-config.model'; import { ConfigFormSection, SyncConfig } from '../global-config.model';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const'; import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { notifyFileProviderTargetChanged } from '../../../op-log/sync-providers/provider-manager.service';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IS_ELECTRON } from '../../../app.constants'; import { IS_ELECTRON } from '../../../app.constants';
import { import {
@ -227,6 +226,9 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
// No `key` on purpose: post-#8228 the sync folder path is owned // No `key` on purpose: post-#8228 the sync folder path is owned
// main-side. The picker's return value is for display only and // main-side. The picker's return value is for display only and
// must not write back into the renderer credential store. // must not write back into the renderer credential store.
// Prepare-only (#9075): main holds the pick as a pending candidate;
// DialogSyncCfgComponent.save() commits it (and fires the
// target-change invalidation there); closing without save discards.
type: 'btn', type: 'btn',
templateOptions: { templateOptions: {
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
@ -236,16 +238,7 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
const localProvider = providers.find( const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile, (p) => p.id === SyncProviderId.LocalFile,
); );
const pickedPath = await ( return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
localProvider as LocalFileSyncPicker | undefined
)?.pickDirectory();
// The picker persists the folder main-side (post-#8228) without
// going through setProviderConfig, so invalidate file-provider
// target state here (Task 2). Only on an actual pick, not cancel.
if (pickedPath) {
notifyFileProviderTargetChanged();
}
return pickedPath;
}, },
}, },
}, },
@ -272,21 +265,16 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
btnStyle: 'stroked', btnStyle: 'stroked',
onClick: async () => { onClick: async () => {
// NOTE: this actually sets the value in the model // NOTE: this actually sets the value in the model — and ONLY
// the model (#9075). The URI is persisted by settings Save via
// setProviderConfig, whose config diff detects the target move
// (safFolderUri is identity-affecting), so Cancel abandons the
// pick. setupSaf throws on cancel, so a value = success.
const providers = await loadSyncProviders(); const providers = await loadSyncProviders();
const localProvider = providers.find( const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile, (p) => p.id === SyncProviderId.LocalFile,
); );
const safUri = await ( return (localProvider as LocalFileSyncPicker | undefined)?.setupSaf();
localProvider as LocalFileSyncPicker | undefined
)?.setupSaf();
// setupSaf writes safFolderUri to privateCfg directly (outside
// setProviderConfig), so invalidate file-provider target state
// here (Task 2). setupSaf throws on cancel, so a value = success.
if (safUri) {
notifyFileProviderTargetChanged();
}
return safUri;
}, },
}, },
expressions: { expressions: {

View file

@ -103,6 +103,139 @@ describe('DialogSyncCfgComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
}); });
describe('save() — LocalFile pending folder commit (#9075)', () => {
let commitSpy: jasmine.Spy;
let originalEa: unknown;
const setupSaveWithProvider = (providerId: SyncProviderId): void => {
const providerStub = {
id: providerId,
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
privateCfg: { load: jasmine.createSpy('load').and.resolveTo(null) },
};
mockProviderManager.getProviderById.and.resolveTo(providerStub as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: true,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: providerId,
isEnabled: true,
};
};
beforeEach(() => {
commitSpy = jasmine
.createSpy('commitPickedDirectory')
.and.resolveTo({ path: '/new-folder', isChanged: true });
originalEa = (window as { ea?: unknown }).ea;
(window as { ea?: unknown }).ea = {
commitPickedDirectory: commitSpy,
discardPickedDirectory: jasmine
.createSpy('discardPickedDirectory')
.and.resolveTo(),
};
});
afterEach(() => {
// window.ea is global — a leaked stub would flip other specs into
// "Electron mode".
(window as { ea?: unknown }).ea = originalEa;
});
it('commits the pending folder and asserts the target change on a real move', async () => {
setupSaveWithProvider(SyncProviderId.LocalFile);
await component.save();
expect(commitSpy).toHaveBeenCalledTimes(1);
expect(mockProviderManager.notifyProviderTargetChanged).toHaveBeenCalledTimes(1);
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('does NOT wipe per-target state when re-picking the same folder or without a pick', async () => {
setupSaveWithProvider(SyncProviderId.LocalFile);
commitSpy.and.resolveTo({ path: '/same-folder', isChanged: false });
await component.save();
expect(mockProviderManager.notifyProviderTargetChanged).not.toHaveBeenCalled();
// Routine save without a pick this session → main returns null.
mockDialogRef.close.calls.reset();
commitSpy.and.resolveTo(null);
await component.save();
expect(mockProviderManager.notifyProviderTargetChanged).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('keeps the dialog open and saves nothing when the commit fails, then retries cleanly', async () => {
// Folder deleted between pick and Save: the old target must stay live
// and the user must get the chance to re-pick or cancel.
setupSaveWithProvider(SyncProviderId.LocalFile);
commitSpy.and.rejectWith(new Error('ENOENT'));
await component.save();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'ERROR' }),
);
expect(mockSyncConfigService.updateSettingsFromForm).not.toHaveBeenCalled();
expect(mockDialogRef.close).not.toHaveBeenCalled();
// A second Save after the user re-picked must go through normally.
commitSpy.and.resolveTo({ path: '/recovered', isChanged: true });
await component.save();
expect(mockProviderManager.notifyProviderTargetChanged).toHaveBeenCalledTimes(1);
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalledTimes(1);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('treats a safe Error VALUE from main as the failure path (IPC contract)', async () => {
setupSaveWithProvider(SyncProviderId.LocalFile);
commitSpy.and.resolveTo(new Error('COMMIT_PICKED_DIRECTORY failed: Error'));
await component.save();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'ERROR' }),
);
expect(mockSyncConfigService.updateSettingsFromForm).not.toHaveBeenCalled();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
it('does not commit when saving a different provider after a LocalFile pick', async () => {
setupSaveWithProvider(SyncProviderId.WebDAV);
await component.save();
expect(commitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.notifyProviderTargetChanged).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('is a no-op on platforms without a main-side pending slot (Android/web)', async () => {
(window as { ea?: unknown }).ea = undefined;
setupSaveWithProvider(SyncProviderId.LocalFile);
await component.save();
expect(mockProviderManager.notifyProviderTargetChanged).not.toHaveBeenCalled();
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('discards the pending pick when the dialog is destroyed without a save', async () => {
const discardSpy = (
window as unknown as { ea: { discardPickedDirectory: jasmine.Spy } }
).ea.discardPickedDirectory;
fixture.destroy();
expect(discardSpy).toHaveBeenCalledTimes(1);
expect(commitSpy).not.toHaveBeenCalled();
});
});
describe('save() — auth cancelled (reproduces issue #7131)', () => { describe('save() — auth cancelled (reproduces issue #7131)', () => {
it('should NOT close dialog when Dropbox auth is cancelled and provider is not ready', async () => { it('should NOT close dialog when Dropbox auth is cancelled and provider is not ready', async () => {
const providerNeedingAuth = { const providerNeedingAuth = {

View file

@ -420,6 +420,12 @@ export class DialogSyncCfgComponent implements AfterViewInit {
private _selectedProviderWasConfigured = false; private _selectedProviderWasConfigured = false;
constructor() { constructor() {
// A pending LocalFile folder pick must never outlive this settings
// session (#9075). save() commits it (clearing the main-side slot), so
// every other exit — Cancel, Escape, backdrop click, navigation — lands
// here with the slot still set and discards it. Runs after a save too,
// where it is a no-op.
this._destroyRef.onDestroy(() => this._discardPendingLocalFileDir());
this.syncConfigService.syncSettingsForm$ this.syncConfigService.syncSettingsForm$
.pipe(first(), takeUntilDestroyed(this._destroyRef)) .pipe(first(), takeUntilDestroyed(this._destroyRef))
.subscribe((v) => { .subscribe((v) => {
@ -721,6 +727,21 @@ export class DialogSyncCfgComponent implements AfterViewInit {
} }
} }
// Commit a pending Electron LocalFile folder pick (#9075) directly before
// the config save and the closing sync(true) — picking is prepare-only
// and Save is the commit point. Placed here (after auth checks and the
// encryption prompt) so no other save step can throw or dead-end between
// the folder going live and the settings being persisted. On failure,
// keep the dialog open: the old folder stays live and the user can
// re-pick or cancel. Saving a different provider skips this; the
// untouched candidate is discarded when the dialog closes.
if (providerId === SyncProviderId.LocalFile) {
const isCommitOk = await this._commitPendingLocalFileDir();
if (!isCommitOk) {
return;
}
}
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true); await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
this._matDialogRef.close(); this._matDialogRef.close();
@ -738,6 +759,62 @@ export class DialogSyncCfgComponent implements AfterViewInit {
} }
} }
/**
* Commits the main-side pending LocalFile folder pick (#9075) straight over
* the Electron bridge like the picker itself, the pick lifecycle is
* main-owned and never round-trips a path through the renderer (#8228).
* Returns false when persisting failed (folder deleted between pick and
* Save, EACCES) the candidate is kept main-side so a retry stays loud.
* No-op (true) on platforms without a main-side pending slot (Android/web)
* and when nothing was picked this session.
*/
private async _commitPendingLocalFileDir(): Promise<boolean> {
if (!window.ea?.commitPickedDirectory) {
return true;
}
try {
const committed = await window.ea.commitPickedDirectory();
// Main reports persist/validation failure as a safe Error VALUE (same
// contract as the other file-sync IPCs) — treat it as the failure path.
if (committed instanceof Error) {
throw committed;
}
if (committed?.isChanged) {
// The commit persists main-side, bypassing setProviderConfig(), so no
// config diff exists to infer the target move from — assert it
// explicitly. Gated on isChanged: re-picking the already-configured
// folder must not wipe per-target sync state (cursor/rev caches).
// On first-ever setup this double-fires (setProviderConfig's !prevCfg
// diff also reports a move) — benign, as no per-target state exists
// yet to wipe; do not "fix" it by skipping this notify, or an
// existing-config folder move would go unsignalled.
this._providerManager.notifyProviderTargetChanged();
}
return true;
} catch (e) {
SyncLog.err('LocalFile folder commit failed', { name: _redactErrorName(e) });
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.FORM.LOCAL_FILE.S_COMMIT_FOLDER_ERROR,
});
return false;
}
}
/**
* Fire-and-forget: dropping the candidate must never block dialog close.
* Straight over the Electron bridge on purpose routing through
* getProviderById() would lazy-load every provider bundle on each dialog
* close just to clear a slot that is usually empty. No-op outside Electron.
*/
private _discardPendingLocalFileDir(): void {
window.ea?.discardPickedDirectory?.().catch((e) => {
SyncLog.err('LocalFile pending folder discard failed', {
name: _redactErrorName(e),
});
});
}
/** /**
* Open the encryption dialog in collect-only mode to gather an optional * Open the encryption dialog in collect-only mode to gather an optional
* setup password for a file-based provider. Returns the password, or `null` * setup password for a file-based provider. Returns the password, or `null`

View file

@ -26,7 +26,7 @@ const buildLocalFileSyncElectronDeps = (): LocalFileSyncElectronDeps => ({
isElectron: IS_ELECTRON, isElectron: IS_ELECTRON,
pickDirectory: async () => { pickDirectory: async () => {
// Main returns an Error (not a thrown rejection) when the pick succeeded // Main returns an Error (not a thrown rejection) when the pick succeeded
// but persisting/canonicalizing the folder failed (#8228). Re-throw so it // but validating/canonicalizing the folder failed (#8228). Re-throw so it
// flows into LocalFileSyncElectron.pickDirectory()'s catch path (log + // flows into LocalFileSyncElectron.pickDirectory()'s catch path (log +
// rethrow) instead of masquerading as a picked path string. // rethrow) instead of masquerading as a picked path string.
const result = await getElectronApi().pickDirectory(); const result = await getElectronApi().pickDirectory();

View file

@ -1,10 +1,7 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { import { SyncProviderManager } from './provider-manager.service';
SyncProviderManager,
notifyFileProviderTargetChanged,
} from './provider-manager.service';
import { DataInitStateService } from '../../core/data-init/data-init-state.service'; import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { SnackService } from '../../core/snack/snack.service'; import { SnackService } from '../../core/snack/snack.service';
import { SyncProviderId } from './provider.const'; import { SyncProviderId } from './provider.const';
@ -15,7 +12,7 @@ import { SyncProviderBase } from './provider.interface';
* and carries `isTargetChanged`: true only when the save moved the TARGET. That * and carries `isTargetChanged`: true only when the save moved the TARGET. That
* flag drives invalidateAllTargets(), which wipes the seq cursor so a false * flag drives invalidateAllTargets(), which wipes the seq cursor so a false
* positive sends the next sync down the sinceSeq===0 snapshot-bootstrap path. * positive sends the next sync down the sinceSeq===0 snapshot-bootstrap path.
* The picker / Android setupSaf / OneDrive pre-auth write bypass * The Electron LocalFile folder commit / OneDrive pre-auth write bypass
* setProviderConfig() entirely and assert a target change directly. * setProviderConfig() entirely and assert a target change directly.
*/ */
describe('SyncProviderManager target-change notification', () => { describe('SyncProviderManager target-change notification', () => {
@ -72,13 +69,6 @@ describe('SyncProviderManager target-change notification', () => {
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true }); expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true });
}); });
it('routes the module-level notifyFileProviderTargetChanged() to the registered instance', () => {
// Injecting the service self-registered it as the module singleton.
notifyFileProviderTargetChanged();
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true });
});
}); });
describe('setProviderConfig()', () => { describe('setProviderConfig()', () => {

View file

@ -39,31 +39,6 @@ export interface ProviderConfigChange {
isTargetChanged: boolean; isTargetChanged: boolean;
} }
// Module-level reference so static sync-form handlers can signal a target
// change without an injector (mirrors the encryption-dialog-opener pattern).
let providerManagerInstance: SyncProviderManager | null = null;
const setProviderManagerInstance = (instance: SyncProviderManager): void => {
providerManagerInstance = instance;
};
/**
* Signal that the active file-provider target changed through an ingress that
* bypasses `setProviderConfig()` the Electron LocalFile folder picker (which
* persists the folder main-side, post-#8228) and Android `setupSaf()` (which
* writes `safFolderUri` straight to the credential store). Both mutate the
* target without firing `providerTargetChanged$`, so the file adapter would keep
* the previous folder's revs/clocks/caches keyed by the (unchanged) `LocalFile`
* provider id. Both ingresses are unambiguous target moves (the user picked a
* different folder), so this asserts a target change directly rather than
* inferring one from a config diff. It no-ops if the manager was never
* instantiated nothing is cached to leak.
* (Task 2, docs/plans/2026-07-13-sync-simplification-plan.md.)
*/
export const notifyFileProviderTargetChanged = (): void => {
providerManagerInstance?.notifyProviderTargetChanged();
};
/** /**
* Service for managing sync providers. * Service for managing sync providers.
* *
@ -173,10 +148,6 @@ export class SyncProviderManager {
); );
constructor() { constructor() {
// Self-register so the module-level notifyFileProviderTargetChanged() can
// reach this singleton from static form config handlers.
setProviderManagerInstance(this);
// Listen to sync config changes and update active provider // Listen to sync config changes and update active provider
this._syncConfig$.subscribe((cfg) => { this._syncConfig$.subscribe((cfg) => {
try { try {
@ -315,14 +286,15 @@ export class SyncProviderManager {
/** /**
* Asserts a target change for a write that bypassed `setProviderConfig()`, so * Asserts a target change for a write that bypassed `setProviderConfig()`, so
* no config diff is available to infer it from. Three such ingresses exist: the * no config diff is available to infer it from. Two such ingresses exist,
* Electron LocalFile folder picker, Android `setupSaf()`, and the OneDrive * both in `dialog-sync-cfg.component`: the Electron LocalFile folder commit
* pre-auth cfg write in `dialog-sync-cfg.component`. Callers that fire on every * (main-side store, post-#8228/#9075) and the OneDrive pre-auth cfg write.
* save (OneDrive) MUST gate this on `isSyncTargetChanged` an unconditional * Callers MUST gate this on an actual move (`isSyncTargetChanged` for
* notify reintroduces the cursor wipe this signal exists to avoid. * OneDrive, `isChanged` from the LocalFile commit) an unconditional notify
* reintroduces the cursor wipe this signal exists to avoid.
* *
* Kept minimal on purpose: it does not reload provider config (the caller * Kept minimal on purpose: it does not reload provider config (the caller
* already persisted the new target). See `notifyFileProviderTargetChanged()`. * already persisted the new target).
*/ */
notifyProviderTargetChanged(): void { notifyProviderTargetChanged(): void {
this._providerConfigChanged$.next({ isTargetChanged: true }); this._providerConfigChanged$.next({ isTargetChanged: true });

View file

@ -1519,6 +1519,7 @@ const T = {
L_SYNC_FILE_PATH_PERMISSION_VALIDATION: L_SYNC_FILE_PATH_PERMISSION_VALIDATION:
'F.SYNC.FORM.LOCAL_FILE.L_SYNC_FILE_PATH_PERMISSION_VALIDATION', 'F.SYNC.FORM.LOCAL_FILE.L_SYNC_FILE_PATH_PERMISSION_VALIDATION',
L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH', L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH',
S_COMMIT_FOLDER_ERROR: 'F.SYNC.FORM.LOCAL_FILE.S_COMMIT_FOLDER_ERROR',
}, },
PASSWORD_SET_INFO: 'F.SYNC.FORM.PASSWORD_SET_INFO', PASSWORD_SET_INFO: 'F.SYNC.FORM.PASSWORD_SET_INFO',
SUPER_SYNC: { SUPER_SYNC: {

View file

@ -1484,7 +1484,8 @@
"LOCAL_FILE": { "LOCAL_FILE": {
"INFO_TEXT": "<strong>Warning:</strong> Do <strong>not</strong> use file syncing tools like Syncthing, Resilio, or similar to sync this folder between devices — they <strong>will</strong> cause data corruption and conflicts. For multi-device sync, use SuperSync, Nextcloud, or Dropbox instead.", "INFO_TEXT": "<strong>Warning:</strong> Do <strong>not</strong> use file syncing tools like Syncthing, Resilio, or similar to sync this folder between devices — they <strong>will</strong> cause data corruption and conflicts. For multi-device sync, use SuperSync, Nextcloud, or Dropbox instead.",
"L_SYNC_FILE_PATH_PERMISSION_VALIDATION": "Needs file access permission", "L_SYNC_FILE_PATH_PERMISSION_VALIDATION": "Needs file access permission",
"L_SYNC_FOLDER_PATH": "Sync folder path" "L_SYNC_FOLDER_PATH": "Sync folder path",
"S_COMMIT_FOLDER_ERROR": "Could not save the selected sync folder. It may have been moved or deleted — please pick it again."
}, },
"PASSWORD_SET_INFO": "🔒 Encryption password is set.", "PASSWORD_SET_INFO": "🔒 Encryption password is set.",
"SUPER_SYNC": { "SUPER_SYNC": {