diff --git a/docs/wiki/2.09-Configure-Sync-Backend.md b/docs/wiki/2.09-Configure-Sync-Backend.md index 5361dce719..6ff4005f36 100755 --- a/docs/wiki/2.09-Configure-Sync-Backend.md +++ b/docs/wiki/2.09-Configure-Sync-Backend.md @@ -83,7 +83,7 @@ Then in Super Productivity: 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. -- **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. --- diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 523eedf763..a7d718396c 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -59,16 +59,39 @@ export interface ElectronAPI { checkDirExists(args: { relativePath?: string }): Promise; /** - * Opens the native folder picker for the sync folder. Resolves to: - * - `string`: the canonicalized, persisted folder path on success + * Opens the native folder picker for the sync folder. Prepare-only (#9075): + * 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 - * - `Error`: the pick succeeded but main could not canonicalize/persist it - * (e.g. the folder was deleted between pick and commit, EACCES, or the - * folder lives inside the app's private dir). Nothing is persisted in + * - `Error`: the pick succeeded but main could not canonicalize/validate it + * (e.g. the folder was deleted right after picking, EACCES, or the + * 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. */ pickDirectory(): Promise; + /** + * 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; + /** * 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 diff --git a/electron/local-file-sync.test.cjs b/electron/local-file-sync.test.cjs index ce37f7a1f4..29a98ed456 100644 --- a/electron/local-file-sync.test.cjs +++ b/electron/local-file-sync.test.cjs @@ -24,6 +24,16 @@ let handlers = {}; let userDataDir; let externalDir; let nextDialogResult = { canceled: true, filePaths: [] }; +let winStub; + +const makeWinStub = () => ({ + webContents: { + _handlers: {}, + on(evt, fn) { + this._handlers[evt] = fn; + }, + }, +}); const installMocks = () => { Module._load = function patchedLoad(request, parent, isMain) { @@ -40,7 +50,7 @@ const installMocks = () => { return { log: () => {}, error: () => {} }; } if (/[/\\]main-window$/.test(request)) { - return { getWin: () => ({}) }; + return { getWin: () => winStub }; } return originalModuleLoad.call(this, request, parent, isMain); }; @@ -59,17 +69,20 @@ const load = () => { }; const configureSyncFolder = async (folder) => { - // Drive the real PICK_DIRECTORY path so the test exercises the same - // canonicalize-and-persist code production uses. + // Drive the real pick → commit path so the test exercises the same + // prepare-then-persist code production uses (#9075). nextDialogResult = { canceled: false, filePaths: [folder] }; - const result = await handlers['PICK_DIRECTORY']({}); - if (result instanceof Error) throw result; - return result; + const picked = await handlers['PICK_DIRECTORY']({}); + if (picked instanceof Error) throw picked; + const committed = await handlers['COMMIT_PICKED_DIRECTORY']({}); + if (committed instanceof Error) throw committed; + return committed.path; }; test.beforeEach(() => { userDataDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ud-'))); externalDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ext-'))); + winStub = makeWinStub(); installMocks(); 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); }); -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] }; const returned = await handlers['PICK_DIRECTORY']({}); - assert.equal(returned, externalDir); + assert.equal(returned, externalDir, 'returns the candidate for display'); assert.equal( await handlers['GET_SYNC_FOLDER_PATH']({}), - externalDir, - 'main-side store is updated; renderer does not have to echo back', + null, + '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 // → 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-')); fs.rmSync(gone, { recursive: true, force: true }); nextDialogResult = { canceled: false, filePaths: [gone] }; const result = await handlers['PICK_DIRECTORY']({}); 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); }); +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 () => { nextDialogResult = { canceled: true, filePaths: [] }; const result = await handlers['PICK_DIRECTORY']({}); diff --git a/electron/local-file-sync.ts b/electron/local-file-sync.ts index 36f03a1a0a..09646e7c2a 100644 --- a/electron/local-file-sync.ts +++ b/electron/local-file-sync.ts @@ -34,6 +34,41 @@ const getAppPrivateDir = (): string => app.getPath('userData'); let _cachedSyncFolder: string | null | undefined = undefined; let _loadPromise: Promise | 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): 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 => { const all = await loadSimpleStoreAll(); const raw = all[SimpleStoreKey.SYNC_FOLDER_PATH]; @@ -287,7 +322,10 @@ export const initLocalFileSyncAdapter = (): void => { ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise => { 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', buttonLabel: 'Select Folder', properties: [ @@ -300,19 +338,69 @@ export const initLocalFileSyncAdapter = (): void => { if (canceled || !filePaths[0]) { return undefined; } - // Persist main-side BEFORE returning the display string. If - // canonicalization or persistence fails (deleted between pick and - // commit, EACCES on userData, etc.), surface a safe error rather than - // a silent undefined — otherwise the renderer cannot distinguish - // failure from user-cancel and the user is left wondering why their - // pick didn't take. - return await setSyncFolderPath(filePaths[0]); + if (_discardGeneration !== discardGenAtOpen) { + // The settings UI that owned this pick was torn down (its discard + // already ran) while the native dialog was open — nobody is left to + // commit or discard the result. Treat as cancel instead of arming an + // ownerless candidate. + return undefined; + } + // 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) { - 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); } }); + 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 => { + _discardPendingSyncFolder(); + }); + ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise => { return getSyncFolderPath(); }); diff --git a/electron/preload.ts b/electron/preload.ts index ee8b8428aa..402f2c53b6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -60,6 +60,11 @@ const ea: ElectronAPI = { checkDirExists: (args) => _invoke('CHECK_DIR_EXISTS', args) as Promise, pickDirectory: () => _invoke('PICK_DIRECTORY') as Promise, + commitPickedDirectory: () => + _invoke('COMMIT_PICKED_DIRECTORY') as Promise< + { path: string; isChanged: boolean } | null | Error + >, + discardPickedDirectory: () => _invoke('DISCARD_PICKED_DIRECTORY') as Promise, getSyncFolderPath: () => _invoke('GET_SYNC_FOLDER_PATH') as Promise, showOpenDialog: (options: { diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index e79d0093c7..7578c5885e 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -47,6 +47,8 @@ export enum IPC { CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS', PICK_DIRECTORY = 'PICK_DIRECTORY', + COMMIT_PICKED_DIRECTORY = 'COMMIT_PICKED_DIRECTORY', + DISCARD_PICKED_DIRECTORY = 'DISCARD_PICKED_DIRECTORY', GET_SYNC_FOLDER_PATH = 'GET_SYNC_FOLDER_PATH', IMAGE_PICK_AND_IMPORT = 'IMAGE_PICK_AND_IMPORT', IMAGE_CACHE_GET_DATA_URL = 'IMAGE_CACHE_GET_DATA_URL', diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts index 23bc75102e..99ee570c0a 100644 --- a/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts @@ -28,13 +28,16 @@ export class LocalFileSyncAndroid extends LocalFileSyncBase { 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 { try { - const uri = await this._deps.saf.selectFolder(); - await this.privateCfg.upsertPartial({ - safFolderUri: uri, - }); - return uri; + return await this._deps.saf.selectFolder(); } catch (error) { this.logger.err('Failed to setup SAF', toSyncLogError(error)); throw error; diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts index 40cc66c795..d9053b88b5 100644 --- a/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts @@ -4,11 +4,13 @@ import { LocalFileSyncBase, type LocalFileSyncBaseDeps } from './local-file-sync export interface LocalFileSyncElectronDeps extends LocalFileSyncBaseDeps { isElectron: boolean; /** - * Show the system folder picker and persist the result main-side. Returns - * the picked path for display only, or undefined if the user cancelled. - * Rejects when the pick succeeded but main could not canonicalize/persist - * the folder (e.g. it was deleted between pick and commit, EACCES, or it - * lives inside the app's private dir); nothing is persisted in that case. + * Show the system folder picker. Prepare-only (#9075): main holds the pick + * as a pending candidate; the live sync target is untouched until + * `commitPickedDirectory()` (settings Save). Returns the candidate path for + * display only, or undefined if the user cancelled. Rejects when the pick + * 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 * 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()`); try { - // Main-side handler persists the result; we just relay the display - // value to the caller (typically a settings form). + // Main-side handler holds the pick as a pending candidate (#9075); we + // 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(); } catch (e) { this.logger.error( diff --git a/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts b/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts index cd162ed3e4..b738458b52 100644 --- a/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts +++ b/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts @@ -69,13 +69,16 @@ describe('LocalFileSyncAndroid', () => { 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 { provider, selectFolder } = makeProvider(store); await expect(provider.setupSaf()).resolves.toBe('content://uri'); 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 () => { diff --git a/src/app/features/config/form-cfgs/sync-form.const.spec.ts b/src/app/features/config/form-cfgs/sync-form.const.spec.ts new file mode 100644 index 0000000000..cf12798e58 --- /dev/null +++ b/src/app/features/config/form-cfgs/sync-form.const.spec.ts @@ -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: `
+ +
`, +}) +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 = { 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; + 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 => { + 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(); + }); +}); diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 46a34bc2b0..ffe1bfe834 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -2,7 +2,6 @@ import { T } from '../../../t.const'; import { ConfigFormSection, SyncConfig } from '../global-config.model'; 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_ELECTRON } from '../../../app.constants'; import { @@ -227,6 +226,9 @@ export const SYNC_FORM: ConfigFormSection = { // No `key` on purpose: post-#8228 the sync folder path is owned // main-side. The picker's return value is for display only and // 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', templateOptions: { text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, @@ -236,16 +238,7 @@ export const SYNC_FORM: ConfigFormSection = { const localProvider = providers.find( (p) => p.id === SyncProviderId.LocalFile, ); - const pickedPath = await ( - 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; + return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory(); }, }, }, @@ -272,21 +265,16 @@ export const SYNC_FORM: ConfigFormSection = { text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, btnStyle: 'stroked', 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 localProvider = providers.find( (p) => p.id === SyncProviderId.LocalFile, ); - const safUri = await ( - 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; + return (localProvider as LocalFileSyncPicker | undefined)?.setupSaf(); }, }, expressions: { diff --git a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts index dd15d35bec..e082e2b762 100644 --- a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts +++ b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts @@ -103,6 +103,139 @@ describe('DialogSyncCfgComponent', () => { 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)', () => { it('should NOT close dialog when Dropbox auth is cancelled and provider is not ready', async () => { const providerNeedingAuth = { diff --git a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts index 4027867c40..40e8e9a829 100644 --- a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts +++ b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts @@ -420,6 +420,12 @@ export class DialogSyncCfgComponent implements AfterViewInit { private _selectedProviderWasConfigured = false; 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$ .pipe(first(), takeUntilDestroyed(this._destroyRef)) .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); 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 { + 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 * setup password for a file-based provider. Returns the password, or `null` diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts index 208115d66c..a24531a45b 100644 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts +++ b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts @@ -26,7 +26,7 @@ const buildLocalFileSyncElectronDeps = (): LocalFileSyncElectronDeps => ({ isElectron: IS_ELECTRON, pickDirectory: async () => { // 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 + // rethrow) instead of masquerading as a picked path string. const result = await getElectronApi().pickDirectory(); diff --git a/src/app/op-log/sync-providers/provider-manager.service.spec.ts b/src/app/op-log/sync-providers/provider-manager.service.spec.ts index 72d18e9f0f..eae6f64971 100644 --- a/src/app/op-log/sync-providers/provider-manager.service.spec.ts +++ b/src/app/op-log/sync-providers/provider-manager.service.spec.ts @@ -1,10 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; import { provideMockStore } from '@ngrx/store/testing'; -import { - SyncProviderManager, - notifyFileProviderTargetChanged, -} from './provider-manager.service'; +import { SyncProviderManager } from './provider-manager.service'; import { DataInitStateService } from '../../core/data-init/data-init-state.service'; import { SnackService } from '../../core/snack/snack.service'; 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 * flag drives invalidateAllTargets(), which wipes the seq cursor — so a false * 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. */ describe('SyncProviderManager target-change notification', () => { @@ -72,13 +69,6 @@ describe('SyncProviderManager target-change notification', () => { 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()', () => { diff --git a/src/app/op-log/sync-providers/provider-manager.service.ts b/src/app/op-log/sync-providers/provider-manager.service.ts index 3ec34273bc..e491cd3765 100644 --- a/src/app/op-log/sync-providers/provider-manager.service.ts +++ b/src/app/op-log/sync-providers/provider-manager.service.ts @@ -39,31 +39,6 @@ export interface ProviderConfigChange { 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. * @@ -173,10 +148,6 @@ export class SyncProviderManager { ); 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 this._syncConfig$.subscribe((cfg) => { try { @@ -315,14 +286,15 @@ export class SyncProviderManager { /** * 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 - * Electron LocalFile folder picker, Android `setupSaf()`, and the OneDrive - * pre-auth cfg write in `dialog-sync-cfg.component`. Callers that fire on every - * save (OneDrive) MUST gate this on `isSyncTargetChanged` — an unconditional - * notify reintroduces the cursor wipe this signal exists to avoid. + * no config diff is available to infer it from. Two such ingresses exist, + * both in `dialog-sync-cfg.component`: the Electron LocalFile folder commit + * (main-side store, post-#8228/#9075) and the OneDrive pre-auth cfg write. + * Callers MUST gate this on an actual move (`isSyncTargetChanged` for + * 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 - * already persisted the new target). See `notifyFileProviderTargetChanged()`. + * already persisted the new target). */ notifyProviderTargetChanged(): void { this._providerConfigChanged$.next({ isTargetChanged: true }); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index b1183756f2..cc83980d49 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1519,6 +1519,7 @@ const T = { 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', + S_COMMIT_FOLDER_ERROR: 'F.SYNC.FORM.LOCAL_FILE.S_COMMIT_FOLDER_ERROR', }, PASSWORD_SET_INFO: 'F.SYNC.FORM.PASSWORD_SET_INFO', SUPER_SYNC: { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3324dff136..4df195b9e7 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1484,7 +1484,8 @@ "LOCAL_FILE": { "INFO_TEXT": "Warning: Do not use file syncing tools like Syncthing, Resilio, or similar to sync this folder between devices — they will 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_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.", "SUPER_SYNC": {