Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions mobile-app/ios/syncup/ScopedFolderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = []
Expand All @@ -174,6 +199,7 @@ import UIKit
"path": e.lastResolvedPath,
"displayName": e.displayName,
"isStale": stale,
"previousPaths": e.previousPaths,
])
}
return jsonString(out) ?? "[]"
Expand Down Expand Up @@ -225,10 +251,15 @@ import UIKit
for i in 0..<entries.count {
let e = entries[i]
// Don't double-acquire on re-entry (e.g. BG launches that hit a
// still-running daemon process).
if let existing = (queue.sync { self.acquired[e.id] }) {
result[existing.path] = existing.path
continue
if FileManager.default.fileExists(atPath: existing.path) {
result[existing.path] = existing.path
continue
}
NSLog("ScopedFolderStore: acquired path vanished for %@: %@",
e.id, existing.path)
existing.stopAccessingSecurityScopedResource()
queue.sync { _ = self.acquired.removeValue(forKey: e.id) }
}
var stale = false
let url: URL
Expand All @@ -255,6 +286,13 @@ import UIKit
if url.path != e.lastResolvedPath {
NSLog("ScopedFolderStore: path drift for %@: %@ -> %@",
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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions mobile-app/src/NativeGoServerBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
19 changes: 18 additions & 1 deletion mobile-app/src/daemon/SyncthingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +69,16 @@ function sameInfo(prev: DaemonInfo | null, next: DaemonInfo): boolean {
);
}

async function relocateDriftedFolders(info: DaemonInfo): Promise<void> {
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;
Expand Down Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions mobile-app/src/fs/__tests__/externalFolderRelocation.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
56 changes: 56 additions & 0 deletions mobile-app/src/fs/externalFolder.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<void> {
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<string[]> {
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;
}
65 changes: 65 additions & 0 deletions mobile-app/src/fs/externalFolderRelocation.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading