diff --git a/mobile-app/ios/syncup/ScopedFolderStore.swift b/mobile-app/ios/syncup/ScopedFolderStore.swift index f7dabab..7d4def5 100644 --- a/mobile-app/ios/syncup/ScopedFolderStore.swift +++ b/mobile-app/ios/syncup/ScopedFolderStore.swift @@ -17,6 +17,31 @@ import UIKit var displayName: String var lastResolvedPath: String let addedAt: Date + var previousPaths: [String] + + init(id: String, bookmark: Data, displayName: String, + lastResolvedPath: String, addedAt: Date, previousPaths: [String] = []) { + self.id = id + self.bookmark = bookmark + self.displayName = displayName + self.lastResolvedPath = lastResolvedPath + self.addedAt = addedAt + self.previousPaths = previousPaths + } + + private enum CodingKeys: String, CodingKey { + case id, bookmark, displayName, lastResolvedPath, addedAt, previousPaths + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + bookmark = try c.decode(Data.self, forKey: .bookmark) + displayName = try c.decode(String.self, forKey: .displayName) + lastResolvedPath = try c.decode(String.self, forKey: .lastResolvedPath) + addedAt = try c.decode(Date.self, forKey: .addedAt) + previousPaths = try c.decodeIfPresent([String].self, forKey: .previousPaths) ?? [] + } } /// id -> live URL with security scope started. Acquire/release tracks @@ -157,7 +182,7 @@ import UIKit return jsonString(payload) ?? "" } - /// Returns JSON array: [{ id, path, displayName, isStale }]. + /// Returns JSON array: [{ id, path, displayName, isStale, previousPaths }]. @objc func getPersistedFoldersJSON() -> String { let entries = loadEntries() var out: [[String: Any]] = [] @@ -174,6 +199,7 @@ import UIKit "path": e.lastResolvedPath, "displayName": e.displayName, "isStale": stale, + "previousPaths": e.previousPaths, ]) } return jsonString(out) ?? "[]" @@ -225,10 +251,15 @@ import UIKit for i in 0.. %@", e.id, e.lastResolvedPath, url.path) + // Remember the old location so lookups by the path still in + // syncthing's config keep resolving to this entry, and so JS + // can discover where the folder went. + if !entries[i].previousPaths.contains(e.lastResolvedPath) { + entries[i].previousPaths.append(e.lastResolvedPath) + } + entries[i].previousPaths.removeAll(where: { $0 == url.path }) entries[i].lastResolvedPath = url.path dirty = true } @@ -269,7 +307,8 @@ import UIKit bookmark: fresh, displayName: entries[i].displayName, lastResolvedPath: url.path, - addedAt: e.addedAt + addedAt: e.addedAt, + previousPaths: entries[i].previousPaths ) dirty = true } @@ -293,10 +332,15 @@ import UIKit private func findEntry(byPath path: String) -> Entry? { let canonical = URL(fileURLWithPath: path).standardizedFileURL.path - return loadEntries().first(where: { e in - let entryCanonical = URL(fileURLWithPath: e.lastResolvedPath).standardizedFileURL.path - return entryCanonical == canonical || e.lastResolvedPath == path - }) + let entries = loadEntries() + let matches: (String) -> Bool = { candidate in + candidate == path + || URL(fileURLWithPath: candidate).standardizedFileURL.path == canonical + } + if let current = entries.first(where: { matches($0.lastResolvedPath) }) { + return current + } + return entries.first(where: { e in e.previousPaths.contains(where: matches) }) } private func deriveDisplayName(_ url: URL) -> String { diff --git a/mobile-app/src/NativeGoServerBridge.ts b/mobile-app/src/NativeGoServerBridge.ts index 04bdda2..74a49e8 100644 --- a/mobile-app/src/NativeGoServerBridge.ts +++ b/mobile-app/src/NativeGoServerBridge.ts @@ -70,9 +70,10 @@ export interface Spec extends TurboModule { readonly pickExternalFolder: () => string; /** * JSON array of currently-persisted external folders: - * [{ id, path, displayName, isStale }] - * On Android `id === path === content://...`. On iOS `id` is an opaque UUID - * and `path` is the resolved POSIX path. + * [{ id, path, displayName, isStale, previousPaths? }] + * On Android `id === path === content://...`. On iOS `id` is an opaque UUID, + * `path` is the resolved POSIX path and `previousPaths` lists earlier + * resolved paths (the owning app's container UUID changes on update). */ readonly getPersistedExternalFolders: () => string; /** Drop access for the folder; returns true if it existed. */ diff --git a/mobile-app/src/daemon/SyncthingContext.tsx b/mobile-app/src/daemon/SyncthingContext.tsx index 2f7c508..5b5dfea 100644 --- a/mobile-app/src/daemon/SyncthingContext.tsx +++ b/mobile-app/src/daemon/SyncthingContext.tsx @@ -10,6 +10,7 @@ import React, { import { AppState, Platform } from 'react-native'; import GoBridge from '../GoServerBridgeJSI'; import { SyncthingClient } from '../api/syncthing'; +import { reconcileExternalFolderPaths } from '../fs/externalFolder'; export interface DaemonInfo { port: number; @@ -68,6 +69,16 @@ function sameInfo(prev: DaemonInfo | null, next: DaemonInfo): boolean { ); } +async function relocateDriftedFolders(info: DaemonInfo): Promise { + if (Platform.OS !== 'ios') return; + try { + const client = new SyncthingClient({ apiKey: info.apiKey, guiAddress: info.guiAddress }); + await reconcileExternalFolderPaths(client, info.foldersRoot); + } catch (e) { + console.warn('external folder relocation failed:', e); + } +} + function readNativeInfo(): DaemonInfo | null { const port = GoBridge.startServer(); if (port <= 0) return null; @@ -114,7 +125,10 @@ export function SyncthingProvider({ children }: { children: React.ReactNode }) { setError(null); const alive = await pingDaemon(first.guiAddress, first.apiKey, 2000); - if (alive) return; + if (alive) { + await relocateDriftedFolders(first); + return; + } try { GoBridge.stopServer(); @@ -129,6 +143,9 @@ export function SyncthingProvider({ children }: { children: React.ReactNode }) { setInfo(prev => (sameInfo(prev, recovered) ? prev : recovered)); // Existing event long-poll / useResource retries pick up the fresh // listener on their next tick; no need to re-ping here. + if (await pingDaemon(recovered.guiAddress, recovered.apiKey, 5000)) { + await relocateDriftedFolders(recovered); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { diff --git a/mobile-app/src/fs/__tests__/externalFolderRelocation.test.ts b/mobile-app/src/fs/__tests__/externalFolderRelocation.test.ts new file mode 100644 index 0000000..10bbcbb --- /dev/null +++ b/mobile-app/src/fs/__tests__/externalFolderRelocation.test.ts @@ -0,0 +1,76 @@ +import { + classifyRegrantPick, + findRelocatedExternalFolderPath, + parsePersistedExternalFolders, + samePath, +} from '../externalFolderRelocation'; + +const OLD = '/private/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Vault'; +const NEW = '/private/var/mobile/Containers/Data/Application/BBBB-2222/Documents/Vault'; + +describe('samePath', () => { + it('treats /var and /private/var as the same directory', () => { + expect(samePath('/var/mobile/x', '/private/var/mobile/x')).toBe(true); + expect(samePath('/var/mobile/x/', '/private/var/mobile/x')).toBe(true); + expect(samePath('/var/mobile/x', '/var/mobile/y')).toBe(false); + }); +}); + +describe('parsePersistedExternalFolders', () => { + it('tolerates empty, malformed and non-array input', () => { + expect(parsePersistedExternalFolders('')).toEqual([]); + expect(parsePersistedExternalFolders('{')).toEqual([]); + expect(parsePersistedExternalFolders('{"a":1}')).toEqual([]); + expect(parsePersistedExternalFolders('[null, {"id":"x"}, {"id":"y","path":"/p"}]')).toEqual([ + { id: 'y', path: '/p' }, + ]); + }); +}); + +describe('findRelocatedExternalFolderPath', () => { + it('returns the new path when an entry drifted away from the old one', () => { + const entries = [{ id: '1', path: NEW, previousPaths: [OLD] }]; + expect(findRelocatedExternalFolderPath(entries, OLD)).toBe(NEW); + }); + + it('matches old paths across the /private prefix', () => { + const entries = [{ id: '1', path: NEW, previousPaths: [OLD] }]; + expect(findRelocatedExternalFolderPath(entries, OLD.replace('/private', ''))).toBe(NEW); + }); + + it('returns null when an entry still lives at the old path', () => { + const entries = [ + { id: '1', path: OLD }, + { id: '2', path: NEW, previousPaths: [OLD] }, + ]; + expect(findRelocatedExternalFolderPath(entries, OLD)).toBeNull(); + }); + + it('returns null when nothing references the old path', () => { + expect(findRelocatedExternalFolderPath([{ id: '1', path: NEW }], OLD)).toBeNull(); + expect(findRelocatedExternalFolderPath([], OLD)).toBeNull(); + }); +}); + +describe('classifyRegrantPick', () => { + it('is "same" when the pick equals the stored path', () => { + expect(classifyRegrantPick([], OLD, OLD)).toBe('same'); + expect(classifyRegrantPick([], OLD, OLD.replace('/private', ''))).toBe('same'); + }); + + it('is "known" when the bookmark store recorded the drift', () => { + const entries = [{ id: '1', path: NEW, previousPaths: [OLD] }]; + expect(classifyRegrantPick(entries, OLD, NEW)).toBe('known'); + }); + + it('is "likely" when the store cannot confirm but the folder name matches', () => { + expect(classifyRegrantPick([], OLD, NEW)).toBe('likely'); + // the dead old entry is still around (bookmark no longer resolves) + expect(classifyRegrantPick([{ id: '1', path: OLD, isStale: true }], OLD, NEW)).toBe('likely'); + }); + + it('is "different" when the name does not match either', () => { + const other = '/private/var/mobile/Containers/Data/Application/BBBB-2222/Documents/Notes'; + expect(classifyRegrantPick([], OLD, other)).toBe('different'); + }); +}); diff --git a/mobile-app/src/fs/externalFolder.ts b/mobile-app/src/fs/externalFolder.ts index fbcff27..7182abc 100644 --- a/mobile-app/src/fs/externalFolder.ts +++ b/mobile-app/src/fs/externalFolder.ts @@ -1,6 +1,13 @@ import { Platform, Alert } from 'react-native'; import GoBridge from '../GoServerBridgeJSI'; import type { FolderConfig } from '../api/types'; +import type { SyncthingClient } from '../api/syncthing'; +import { + findRelocatedExternalFolderPath, + parsePersistedExternalFolders, + samePath, + type PersistedExternalFolder, +} from './externalFolderRelocation'; /** * Result of a successful folder pick. `id` is opaque (a SAF tree URI on @@ -120,3 +127,52 @@ export function validateExternalFolderAccess(folder: FolderConfig): boolean { } return GoBridge.validateExternalFolder(folder.path); } + +export function getPersistedExternalFolders(): PersistedExternalFolder[] { + try { + return parsePersistedExternalFolders(GoBridge.getPersistedExternalFolders()); + } catch { + return []; + } +} + +export async function relocateExternalFolder( + client: SyncthingClient, + folder: FolderConfig, + newPath: string, +): Promise { + await client.patchFolder(folder.id, { path: newPath }); + const stale = getPersistedExternalFolders().find( + e => samePath(e.path, folder.path) && !samePath(e.path, newPath), + ); + if (stale) { + try { + GoBridge.revokeExternalFolder(folder.path); + } catch { + // best-effort cleanup; the orphaned bookmark is harmless + } + } +} + +export async function reconcileExternalFolderPaths( + client: SyncthingClient, + foldersRoot: string, +): Promise { + if (Platform.OS !== 'ios') return []; + const entries = getPersistedExternalFolders(); + if (!entries.some(e => (e.previousPaths?.length ?? 0) > 0)) return []; + const folders = await client.folders(); + const relocated: string[] = []; + for (const folder of folders) { + if (!isExternalFolder(folder, foldersRoot)) continue; + const newPath = findRelocatedExternalFolderPath(entries, folder.path); + if (!newPath) continue; + try { + await client.patchFolder(folder.id, { path: newPath }); + relocated.push(folder.id); + } catch (e) { + console.warn(`relocate ${folder.id} -> ${newPath} failed:`, e); + } + } + return relocated; +} diff --git a/mobile-app/src/fs/externalFolderRelocation.ts b/mobile-app/src/fs/externalFolderRelocation.ts new file mode 100644 index 0000000..2e7407f --- /dev/null +++ b/mobile-app/src/fs/externalFolderRelocation.ts @@ -0,0 +1,65 @@ + +/** One entry of GoBridge.getPersistedExternalFolders(). */ +export interface PersistedExternalFolder { + id: string; + path: string; + displayName?: string; + isStale?: boolean; + /** iOS only: every path this bookmark previously resolved to. */ + previousPaths?: string[]; +} + +export function parsePersistedExternalFolders(raw: string): PersistedExternalFolder[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (e): e is PersistedExternalFolder => + !!e && typeof e === 'object' && typeof (e as PersistedExternalFolder).path === 'string', + ); +} + +/** iOS reports the same directory as /var/... or /private/var/... */ +function normalizePath(p: string): string { + let out = p.startsWith('/private/') ? p.slice('/private'.length) : p; + while (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1); + return out; +} + +export function samePath(a: string, b: string): boolean { + return normalizePath(a) === normalizePath(b); +} + +export function findRelocatedExternalFolderPath( + entries: PersistedExternalFolder[], + oldPath: string, +): string | null { + // An entry currently at oldPath wins: nothing to relocate. + if (entries.some(e => samePath(e.path, oldPath))) return null; + const moved = entries.find(e => + (e.previousPaths ?? []).some(p => samePath(p, oldPath)), + ); + if (!moved || samePath(moved.path, oldPath)) return null; + return moved.path; +} + +export function basename(p: string): string { + return normalizePath(p).split('/').filter(Boolean).pop() ?? ''; +} + +export function classifyRegrantPick( + entries: PersistedExternalFolder[], + oldPath: string, + pickedPath: string, +): 'same' | 'known' | 'likely' | 'different' { + if (samePath(oldPath, pickedPath)) return 'same'; + const relocated = findRelocatedExternalFolderPath(entries, oldPath); + if (relocated && samePath(relocated, pickedPath)) return 'known'; + if (basename(oldPath) && basename(oldPath) === basename(pickedPath)) return 'likely'; + return 'different'; +} diff --git a/mobile-app/src/screens/FolderDetailModal.tsx b/mobile-app/src/screens/FolderDetailModal.tsx index be1b104..c26a280 100644 --- a/mobile-app/src/screens/FolderDetailModal.tsx +++ b/mobile-app/src/screens/FolderDetailModal.tsx @@ -27,7 +27,10 @@ import { pickExternalFolderWithICloudWarning, externalFolderNeedsAllFilesAccess, validateExternalFolderAccess, + getPersistedExternalFolders, + relocateExternalFolder, } from '../fs/externalFolder'; +import { classifyRegrantPick } from '../fs/externalFolderRelocation'; import { FolderIgnoresEditor } from './FolderIgnoresEditor'; import { FolderAdvancedEditor } from './FolderAdvancedEditor'; import { FolderVersioningEditor } from './FolderVersioningEditor'; @@ -667,6 +670,34 @@ export function FolderDetailModal({ if (!picked) return; if (validateExternalFolderAccess(folder)) { setExternalAccessValid(true); + return; + } + const verdict = classifyRegrantPick( + getPersistedExternalFolders(), + folder.path, + picked.path, + ); + const relocate = () => { + setBusy(true); + relocateExternalFolder(client, folder, picked.path) + .then(() => { + setExternalAccessValid(true); + onChanged(); + }) + .catch(e => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)); + }; + if (verdict === 'known') { + relocate(); + } else if (verdict === 'likely') { + Alert.alert( + 'Folder moved', + `“${picked.displayName || picked.path}” looks like this folder at a new location. Use it for “${folder.label || folder.id}”?`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Use folder', onPress: relocate }, + ], + ); } else { Alert.alert( 'Different folder selected',