Skip to content

Commit 2771f74

Browse files
committed
fix(android): reopen the startup project after the app process restarts (#1948)
On Android, both startup modes fell back to the default workspace on every cold start and showed "The startup project is unavailable." Two things were in the way. `openRecentProjectFile` sends the stored path to the `read_project_file` Tauri command, whose guard requires a filesystem path, so a `content://` URI was refused before anything was read. Routing content URIs through `tauri-plugin-fs` instead (it resolves them via Android's ContentResolver) gets past that, but only until the process ends: `tauri-plugin-dialog`'s `open()` launches `ACTION_GET_CONTENT`, whose read grant is tied to the activity that received it and cannot be renewed from inside the app - a persistable grant needs `ACTION_OPEN_DOCUMENT` plus `takePersistableUriPermission`, neither of which the plugin issues. The startup restore runs exactly once per cold start, which is exactly when that grant is gone, so the feature could never work there. This is the read-side half of the problem #1833 fixed for saving. So GeoLibre keeps its own copy. `lib/startup-project-snapshot.ts` writes the project text into the app's private data directory whenever the startup preference points at the project being opened or saved, and `openRecentProjectFile` falls back to that copy when the original URI can no longer be read. Two fixed slots ("specific" and "last") rather than one file per project: the preference can only ever restore two projects, and a fixed pair needs no pruning - which would need an `fs:allow-remove` scope the app deliberately does not grant outside its own temp files. Committing the preference in Settings also copies the project right then, via `ensureStartupProjectSnapshot`. That is the path the report describes - open a project, then ask for it back on the next launch - and nothing else re-reads the project in between, so without it the preference would be saved with no copy behind it. Everything is gated on the path being a content URI, so desktop keeps re-reading the real file and never doubles a project on disk. A copy over 25 MB is skipped (the ceiling already applied to a project fetched by URL) rather than duplicating hundreds of megabytes of embedded vector data on a phone. The source path must match exactly, so a copy can never stand in for a different project, and a `RecentProjectGoneError` still means gone - a deleted file is not resurrected from a stale copy. Every failure is logged and swallowed: the copy is a fallback for a later launch and must never fail the open or save that triggered it. The `fs:scope` entry in the capability is required, not incidental. An fs permission's scope applies only to the commands that permission grants, so `fs:default`'s app-directory scope reaches `read_text_file` but not `mkdir` or `write_text_file`; without the entry every copy fails with "forbidden path" (caught on the emulator, not by inspection). Verified on an Android 16 emulator with the reporter's own steps and a project at the path from the report, /sdcard/Documents/json/General_Project.geolibre.json: open it through the document picker, set the startup preference, force-stop, cold start. Before the change the log shows "Could not restore the startup project ... requires that you obtain access using ACTION_OPEN_DOCUMENT"; after it, both "Reopen the last project" and "Open a specific project" come back with the project, its layer, and its camera, no banner, and the log shows the fallback taking over from the dead URI. Fixes #1948
1 parent 5f28f0c commit 2771f74

9 files changed

Lines changed: 692 additions & 6 deletions

File tree

apps/geolibre-desktop/src-tauri/capabilities/default.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
"identifier": "fs:allow-remove",
2525
"allow": [{ "path": "$TEMP/geolibre-gdb-*.geojson" }]
2626
},
27+
{
28+
"comment": "Restorable copies of the startup project (GeoLibre#1948). On Android a project picked from device storage is a content:// URI whose read grant dies with the process, so the startup restore has nothing to reopen; GeoLibre keeps its own copy here instead. This must be `fs:scope` rather than a path on the read/write permissions above: an fs permission's scope applies only to the commands that permission grants, so `fs:default`'s app-directory scope reaches `read_text_file` but not `mkdir` or `write_text_file` (the app's other writes are to paths a dialog just added to the runtime scope). Two entries because the directory itself is the mkdir target and its files are the read/write targets. See lib/startup-project-snapshot.ts.",
29+
"identifier": "fs:scope",
30+
"allow": [
31+
{ "path": "$APPLOCALDATA/startup-projects" },
32+
{ "path": "$APPLOCALDATA/startup-projects/*" }
33+
]
34+
},
2735
{
2836
"identifier": "opener:allow-open-url",
2937
"allow": [{ "url": "http://*" }, { "url": "https://*" }]

apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/th
9696
import { IS_MAS_BUILD } from "../../lib/build-flags";
9797
import { resolveShareHost, shareHostLabel } from "../../lib/share-geolibre";
9898
import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates";
99-
import { openProjectFile } from "../../lib/tauri-io";
99+
import { ensureStartupProjectSnapshot, openProjectFile } from "../../lib/tauri-io";
100100
import {
101101
DATA_SOURCE_CATALOG,
102102
DATA_SOURCE_SECTION_LABEL_KEYS,
@@ -1189,6 +1189,13 @@ export function SettingsDialog({
11891189
updates: draftDesktopSettings.updates,
11901190
startup: draftDesktopSettings.startup,
11911191
});
1192+
// On Android the project behind the preference just saved is reachable only
1193+
// until this process ends, so keep a copy the next launch can open
1194+
// (GeoLibre#1948). A no-op on every other platform.
1195+
void ensureStartupProjectSnapshot(
1196+
draftDesktopSettings.startup,
1197+
useAppStore.getState().recentProjects,
1198+
);
11921199
// The dockable panels are the one layout row nothing renders from the store:
11931200
// the registry owns what is on screen, so move it to match what was just
11941201
// saved (a no-op for a panel already there, so an untouched Save cannot

apps/geolibre-desktop/src/hooks/useProjectFileActions.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import {
3333
RecentProjectGoneError,
3434
saveProjectFile,
3535
saveProjectFileToPath,
36+
saveStartupProjectSnapshot,
3637
saveTextFileWithFallback,
3738
} from "../lib/tauri-io";
39+
import { useDesktopSettingsStore } from "./useDesktopSettings";
3840
import { buildProjectHtml } from "../lib/html-export";
3941
import { ensureHtmlFileName, ensureProjectFileName } from "../lib/file-names";
4042
import { mergeStringLists } from "../lib/string-lists";
@@ -338,13 +340,27 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
338340
settleSaveNamePrompt,
339341
]);
340342

343+
// On Android a project lives behind a `content://` URI whose read grant dies
344+
// with the process, so the startup restore has nothing to reopen on the next
345+
// launch (GeoLibre#1948). Keep a copy in the app's own storage whenever the
346+
// startup preference points at the project being opened or saved. Fire and
347+
// forget: a failed copy is logged inside and must not fail the open or save.
348+
const rememberStartupProjectSnapshot = (path: string, text: string) => {
349+
void saveStartupProjectSnapshot(
350+
path,
351+
text,
352+
useDesktopSettingsStore.getState().desktopSettings.startup,
353+
);
354+
};
355+
341356
const handleOpenFromFile = async () => {
342357
const result = await openProjectFile();
343358
if (result) {
344359
try {
345360
loadProject(await resolveProjectXyzLayers(result.project), result.path, {
346361
rememberRecent: isTauri(),
347362
});
363+
rememberStartupProjectSnapshot(result.path, result.text);
348364
} catch (error) {
349365
console.error("Failed to open project", error);
350366
setActionError(
@@ -1107,6 +1123,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
11071123
name: project.name,
11081124
openedAt: new Date().toISOString(),
11091125
});
1126+
// Refresh the restorable copy so a startup restore reopens what was just
1127+
// saved rather than the state the project was opened in.
1128+
rememberStartupProjectSnapshot(path, contentToSave);
11101129
markSaved();
11111130
recordExplicitProjectSave();
11121131
return true;
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Durable copies of the project a startup restore is meant to reopen, for the
2+
// one platform where the stored path stops working: Android.
3+
//
4+
// `tauri-plugin-dialog`'s `open()` launches `ACTION_GET_CONTENT`, so a project
5+
// picked from device storage is identified by a `content://` SAF URI rather
6+
// than a filesystem path. That URI carries a one-off read grant tied to the
7+
// activity that received it: it reads fine for the rest of the session, and is
8+
// dead the moment the process is gone. Nothing in the app can renew it -- a
9+
// persistable grant needs `ACTION_OPEN_DOCUMENT` plus
10+
// `takePersistableUriPermission`, neither of which the plugin issues (see
11+
// `android-content-uri.ts` for the write-side half of the same problem).
12+
//
13+
// So "Reopen the last project" and "Open a specific project" could never work
14+
// on Android: the restore runs exactly once per cold start, which is exactly
15+
// when the grant is gone (GeoLibre#1948). The fix is to keep our own copy. When
16+
// a project that the startup preference will reopen is opened or saved from a
17+
// content URI, its text is written to the app's private data directory --
18+
// always readable, no grant involved -- and the restore falls back to that copy
19+
// when the original URI can no longer be read.
20+
//
21+
// Kept free of Tauri and React imports so the rules can be unit-tested in Node;
22+
// `tauri-io.ts` binds the two I/O calls to `@tauri-apps/plugin-fs`.
23+
24+
import type { StartupSettings } from "../hooks/useDesktopSettings";
25+
import { isAndroidContentUri } from "./android-content-uri";
26+
import { STARTUP_SNAPSHOTS_STORAGE_KEY } from "./storage-keys";
27+
28+
/**
29+
* Which startup preference a snapshot serves. One file per slot rather than one
30+
* per project: the preference can only ever restore two projects (the one named
31+
* by "specific" and whichever was used last), so a fixed pair needs no pruning
32+
* -- and pruning would need a `fs:allow-remove` scope the app deliberately does
33+
* not grant outside its own temp files.
34+
*/
35+
export type StartupSnapshotSlot = "specific" | "last";
36+
37+
/** Where a slot's copy came from, so a restore only uses a copy of *that* project. */
38+
export interface StartupSnapshotEntry {
39+
/** The path or content URI the project was opened from. */
40+
sourcePath: string;
41+
/** Snapshot file, relative to {@link STARTUP_SNAPSHOT_DIR}. */
42+
file: string;
43+
/** When the copy was written, for diagnostics. */
44+
savedAt: string;
45+
}
46+
47+
export type StartupSnapshotIndex = Partial<Record<StartupSnapshotSlot, StartupSnapshotEntry>>;
48+
49+
/**
50+
* Snapshot directory, relative to the app's private data directory.
51+
*
52+
* SYNC: the `fs:scope` entry in `src-tauri/capabilities/default.json` names this
53+
* path literally — the fs plugin refuses anything outside its scope, so renaming
54+
* the directory here alone makes every copy fail with "forbidden path".
55+
*/
56+
export const STARTUP_SNAPSHOT_DIR = "startup-projects";
57+
58+
/**
59+
* Above this the copy is skipped and the restore keeps today's behaviour (the
60+
* "startup project is unavailable" banner). A project with embedded vector data
61+
* can run to hundreds of megabytes, and silently doubling that on a phone's
62+
* internal storage would be a worse bug than the one being fixed. Matches the
63+
* ceiling `openRecentProjectFile` already applies to a project fetched by URL.
64+
*/
65+
export const MAX_STARTUP_SNAPSHOT_BYTES = 25 * 1024 * 1024;
66+
67+
/** The subset of `Storage` this module uses, so tests can pass a plain fake. */
68+
export interface SnapshotStorage {
69+
getItem: (key: string) => string | null;
70+
setItem: (key: string, value: string) => void;
71+
}
72+
73+
/** The two file operations {@link writeStartupSnapshot} and {@link readStartupSnapshot} need. */
74+
export interface StartupSnapshotIo {
75+
/** Write `content` to `file` under {@link STARTUP_SNAPSHOT_DIR}, creating the directory. */
76+
write: (file: string, content: string) => Promise<void>;
77+
/** Read `file` under {@link STARTUP_SNAPSHOT_DIR}. Rejects when it is not there. */
78+
read: (file: string) => Promise<string>;
79+
}
80+
81+
/** The snapshot file name for a slot. `.geolibre.json` so the copy is recognizable on disk. */
82+
export function startupSnapshotFile(slot: StartupSnapshotSlot): string {
83+
return `${slot}.geolibre.json`;
84+
}
85+
86+
function defaultStorage(): SnapshotStorage | null {
87+
try {
88+
return typeof window === "undefined" ? null : window.localStorage;
89+
} catch {
90+
// Storage can throw outright when it is disabled (private browsing).
91+
return null;
92+
}
93+
}
94+
95+
function isSnapshotEntry(value: unknown): value is StartupSnapshotEntry {
96+
if (!value || typeof value !== "object") return false;
97+
const entry = value as Partial<StartupSnapshotEntry>;
98+
return (
99+
typeof entry.sourcePath === "string" &&
100+
entry.sourcePath.length > 0 &&
101+
typeof entry.file === "string" &&
102+
entry.file.length > 0 &&
103+
typeof entry.savedAt === "string"
104+
);
105+
}
106+
107+
/**
108+
* The persisted index of snapshots, dropping anything that does not parse.
109+
*
110+
* @param storage - Storage to read from; defaults to `window.localStorage`.
111+
* @returns The stored index, or an empty one.
112+
*/
113+
export function readStartupSnapshotIndex(
114+
storage: SnapshotStorage | null = defaultStorage(),
115+
): StartupSnapshotIndex {
116+
if (!storage) return {};
117+
let parsed: unknown;
118+
try {
119+
const raw = storage.getItem(STARTUP_SNAPSHOTS_STORAGE_KEY);
120+
if (!raw) return {};
121+
parsed = JSON.parse(raw);
122+
} catch {
123+
return {};
124+
}
125+
if (!parsed || typeof parsed !== "object") return {};
126+
const candidate = parsed as Record<string, unknown>;
127+
const index: StartupSnapshotIndex = {};
128+
for (const slot of ["specific", "last"] as const) {
129+
if (isSnapshotEntry(candidate[slot])) index[slot] = candidate[slot];
130+
}
131+
return index;
132+
}
133+
134+
function writeStartupSnapshotIndex(
135+
index: StartupSnapshotIndex,
136+
storage: SnapshotStorage | null,
137+
): void {
138+
if (!storage) return;
139+
try {
140+
storage.setItem(STARTUP_SNAPSHOTS_STORAGE_KEY, JSON.stringify(index));
141+
} catch {
142+
// A full or disabled storage costs the fallback, not the save itself.
143+
}
144+
}
145+
146+
/**
147+
* The slot a project should be copied into, or null when it is not one the
148+
* startup preference would reopen.
149+
*
150+
* "specific" only ever holds the project the user named, so opening something
151+
* else must not overwrite it. "last" tracks whatever was opened or saved most
152+
* recently, which is what `startupProjectPath` resolves that mode to.
153+
*
154+
* @param path - The path or content URI the project was opened from or saved to.
155+
* @param settings - The committed startup preference.
156+
* @returns The slot to write, or null to write nothing.
157+
*/
158+
export function startupSnapshotSlot(
159+
path: string,
160+
settings: StartupSettings,
161+
): StartupSnapshotSlot | null {
162+
if (!isAndroidContentUri(path)) return null;
163+
if (settings.mode === "specific") return settings.projectPath === path ? "specific" : null;
164+
if (settings.mode === "last") return "last";
165+
return null;
166+
}
167+
168+
/**
169+
* Keep a durable copy of a project the startup preference will reopen.
170+
*
171+
* A failure is logged and swallowed: this runs alongside opening or saving a
172+
* project, and the copy is a fallback for a later launch, so it must never turn
173+
* a successful save into a visible error.
174+
*
175+
* @param path - The path or content URI the project was opened from or saved to.
176+
* @param text - The serialized project to copy.
177+
* @param settings - The committed startup preference.
178+
* @param io - Snapshot file access.
179+
* @param options - `storage` overrides where the index is kept.
180+
* @returns The slot written, or null when nothing was.
181+
*/
182+
export async function writeStartupSnapshot(
183+
path: string,
184+
text: string,
185+
settings: StartupSettings,
186+
io: StartupSnapshotIo,
187+
options?: { storage?: SnapshotStorage | null },
188+
): Promise<StartupSnapshotSlot | null> {
189+
const slot = startupSnapshotSlot(path, settings);
190+
if (!slot) return null;
191+
if (text.length > MAX_STARTUP_SNAPSHOT_BYTES) {
192+
console.warn(
193+
`Startup project is too large to keep a restorable copy (${text.length} bytes).`,
194+
path,
195+
);
196+
return null;
197+
}
198+
199+
const file = startupSnapshotFile(slot);
200+
try {
201+
await io.write(file, text);
202+
} catch (error) {
203+
console.warn("Could not keep a restorable copy of the startup project.", error);
204+
return null;
205+
}
206+
207+
const storage = options?.storage === undefined ? defaultStorage() : options.storage;
208+
writeStartupSnapshotIndex(
209+
{
210+
...readStartupSnapshotIndex(storage),
211+
[slot]: { sourcePath: path, file, savedAt: new Date().toISOString() },
212+
},
213+
storage,
214+
);
215+
return slot;
216+
}
217+
218+
/**
219+
* The stored copy of the project at `path`, when there is one.
220+
*
221+
* The source path must match exactly: a copy of a different project is never a
222+
* stand-in for the one the user asked for, so a stale slot yields null and the
223+
* caller reports the original read failure.
224+
*
225+
* @param path - The path or content URI the restore could not read.
226+
* @param io - Snapshot file access.
227+
* @param storage - Storage holding the index; defaults to `window.localStorage`.
228+
* @returns The copied project text, or null when there is none to use.
229+
*/
230+
export async function readStartupSnapshot(
231+
path: string,
232+
io: StartupSnapshotIo,
233+
storage: SnapshotStorage | null = defaultStorage(),
234+
): Promise<string | null> {
235+
const index = readStartupSnapshotIndex(storage);
236+
const entry = Object.values(index).find((candidate) => candidate.sourcePath === path);
237+
if (!entry) return null;
238+
try {
239+
return await io.read(entry.file);
240+
} catch (error) {
241+
// The index outlived its file (cleared app storage, a write that never
242+
// landed). Nothing to restore, so let the caller report the real failure.
243+
console.warn("Could not read the stored copy of the startup project.", error);
244+
return null;
245+
}
246+
}

apps/geolibre-desktop/src/lib/storage-keys.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ export const UPDATE_DISMISSED_VERSION_STORAGE_KEY = "geolibre.updateDismissedVer
2424
* exhaust the per-IP rate limit (desktop only).
2525
*/
2626
export const UPDATE_LAST_CHECK_STORAGE_KEY = "geolibre.lastUpdateCheck";
27+
28+
/**
29+
* Which project each durable startup snapshot was copied from. Only the index
30+
* lives here; the project text itself is a file in the app's private data
31+
* directory. See `lib/startup-project-snapshot.ts`.
32+
*/
33+
export const STARTUP_SNAPSHOTS_STORAGE_KEY = "geolibre.startupProjectSnapshots";

0 commit comments

Comments
 (0)