Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions apps/geolibre-desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
"identifier": "fs:allow-remove",
"allow": [{ "path": "$TEMP/geolibre-gdb-*.geojson" }]
},
{
"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. Only a scope entry is needed, no new command permission: `mkdir` is already granted by `fs:default` (its `create-app-specific-dirs` set), and `read_text_file`/`write_text_file` by the entries above. It must be `fs:scope` rather than a path on those entries because 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) — without this the copies fail with \"forbidden path\". Two entries because the directory itself is the mkdir target and its files are the read/write targets. See lib/startup-project-snapshot.ts.",
"identifier": "fs:scope",
"allow": [
{ "path": "$APPLOCALDATA/startup-projects" },
{ "path": "$APPLOCALDATA/startup-projects/*" }
]
},
Comment thread
giswqs marked this conversation as resolved.
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/th
import { IS_MAS_BUILD } from "../../lib/build-flags";
import { resolveShareHost, shareHostLabel } from "../../lib/share-geolibre";
import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates";
import { openProjectFile } from "../../lib/tauri-io";
import { ensureStartupProjectSnapshot, openProjectFile } from "../../lib/tauri-io";
import {
DATA_SOURCE_CATALOG,
DATA_SOURCE_SECTION_LABEL_KEYS,
Expand Down Expand Up @@ -1189,6 +1189,13 @@ export function SettingsDialog({
updates: draftDesktopSettings.updates,
startup: draftDesktopSettings.startup,
});
// On Android the project behind the preference just saved is reachable only
// until this process ends, so keep a copy the next launch can open
// (GeoLibre#1948). A no-op on every other platform.
void ensureStartupProjectSnapshot(
draftDesktopSettings.startup,
useAppStore.getState().recentProjects,
);
// The dockable panels are the one layout row nothing renders from the store:
// the registry owns what is on screen, so move it to match what was just
// saved (a no-op for a panel already there, so an untouched Save cannot
Expand Down
26 changes: 26 additions & 0 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import {
RecentProjectGoneError,
saveProjectFile,
saveProjectFileToPath,
saveStartupProjectSnapshot,
saveTextFileWithFallback,
} from "../lib/tauri-io";
import { useDesktopSettingsStore } from "./useDesktopSettings";
import { buildProjectHtml } from "../lib/html-export";
import { ensureHtmlFileName, ensureProjectFileName } from "../lib/file-names";
import { mergeStringLists } from "../lib/string-lists";
Expand Down Expand Up @@ -338,13 +340,27 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
settleSaveNamePrompt,
]);

// On Android a project lives behind a `content://` URI whose read grant dies
// with the process, so the startup restore has nothing to reopen on the next
// launch (GeoLibre#1948). Keep a copy in the app's own storage whenever the
// startup preference points at the project being opened or saved. Fire and
// forget: a failed copy is logged inside and must not fail the open or save.
const rememberStartupProjectSnapshot = (path: string, text: string) => {
void saveStartupProjectSnapshot(
path,
text,
useDesktopSettingsStore.getState().desktopSettings.startup,
);
};

const handleOpenFromFile = async () => {
const result = await openProjectFile();
if (result) {
try {
loadProject(await resolveProjectXyzLayers(result.project), result.path, {
rememberRecent: isTauri(),
});
rememberStartupProjectSnapshot(result.path, result.text);
Comment thread
giswqs marked this conversation as resolved.
} catch (error) {
console.error("Failed to open project", error);
setActionError(
Expand Down Expand Up @@ -685,6 +701,13 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const project = await resolveProjectXyzLayers(result.project, controller.signal);
if (controller.signal.aborted) return null;
loadProject(project, result.path);
// `loadProject` moves this path to the front of the recent list, so in
// "last" mode it is now the project the next launch will reopen. Without
// this, reopening an older project from the recent list would leave the
// copy on disk holding whichever project was opened through the picker
// last, and the next cold start would find no copy matching the path it
// resolves (GeoLibre#1948 review).
rememberStartupProjectSnapshot(result.path, result.text);
Comment thread
giswqs marked this conversation as resolved.
return null;
} catch (error) {
if (controller.signal.aborted) return null;
Expand Down Expand Up @@ -1107,6 +1130,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
name: project.name,
openedAt: new Date().toISOString(),
});
// Refresh the restorable copy so a startup restore reopens what was just
// saved rather than the state the project was opened in.
rememberStartupProjectSnapshot(path, contentToSave);
markSaved();
recordExplicitProjectSave();
return true;
Expand Down
272 changes: 272 additions & 0 deletions apps/geolibre-desktop/src/lib/startup-project-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// Durable copies of the project a startup restore is meant to reopen, for the
// one platform where the stored path stops working: Android.
//
// `tauri-plugin-dialog`'s `open()` launches `ACTION_GET_CONTENT`, so a project
// picked from device storage is identified by a `content://` SAF URI rather
// than a filesystem path. That URI carries a one-off read grant tied to the
// activity that received it: it reads fine for the rest of the session, and is
// dead the moment the process is gone. Nothing in the app can renew it -- a
// persistable grant needs `ACTION_OPEN_DOCUMENT` plus
// `takePersistableUriPermission`, neither of which the plugin issues (see
// `android-content-uri.ts` for the write-side half of the same problem).
//
// So "Reopen the last project" and "Open a specific project" could never work
// on Android: the restore runs exactly once per cold start, which is exactly
// when the grant is gone (GeoLibre#1948). The fix is to keep our own copy. When
// a project that the startup preference will reopen is opened or saved from a
// content URI, its text is written to the app's private data directory --
// always readable, no grant involved -- and the restore falls back to that copy
// when the original URI can no longer be read.
//
// Kept free of Tauri and React imports so the rules can be unit-tested in Node;
// `tauri-io.ts` binds the two I/O calls to `@tauri-apps/plugin-fs`.

import type { StartupSettings } from "../hooks/useDesktopSettings";
import { isAndroidContentUri } from "./android-content-uri";
import { STARTUP_SNAPSHOTS_STORAGE_KEY } from "./storage-keys";

/**
* Which startup preference a snapshot serves. One file per slot rather than one
* per project: the preference can only ever restore two projects (the one named
* by "specific" and whichever was used last), so a fixed pair needs no pruning
* -- and pruning would need a `fs:allow-remove` scope the app deliberately does
* not grant outside its own temp files.
*/
Comment on lines +28 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor data-retention note, not really a bug: because there's deliberately no cleanup path (no fs:allow-remove scope for this directory), a slot's file is only ever overwritten, never deleted. If a user pins a "specific"/"last" project (possibly with credentials they chose to "keep" during Save), then switches the startup preference back to "default" or stops opening/saving that Android project, the last copy written to that slot stays in $APPLOCALDATA/startup-projects indefinitely — outliving the preference that created it, with no UI affordance to clear it short of clearing app data. Same trust boundary as the original file (app-private storage), so not a new exposure to other apps, but worth being aware it's a persistent, unbounded-in-time copy of potentially sensitive project content. Confidence: low — flagging for awareness rather than as something that necessarily needs fixing in this PR.

export type StartupSnapshotSlot = "specific" | "last";

/** Where a slot's copy came from, so a restore only uses a copy of *that* project. */
export interface StartupSnapshotEntry {
/** The path or content URI the project was opened from. */
sourcePath: string;
/** Snapshot file, relative to {@link STARTUP_SNAPSHOT_DIR}. */
file: string;
/** When the copy was written, for diagnostics. */
savedAt: string;
}

export type StartupSnapshotIndex = Partial<Record<StartupSnapshotSlot, StartupSnapshotEntry>>;

/**
* Snapshot directory, relative to the app's private data directory.
*
* SYNC: the `fs:scope` entry in `src-tauri/capabilities/default.json` names this
* path literally — the fs plugin refuses anything outside its scope, so renaming
* the directory here alone makes every copy fail with "forbidden path".
*/
export const STARTUP_SNAPSHOT_DIR = "startup-projects";

/**
* Above this the copy is skipped and the restore keeps today's behaviour (the
* "startup project is unavailable" banner). A project with embedded vector data
* can run to hundreds of megabytes, and silently doubling that on a phone's
* internal storage would be a worse bug than the one being fixed.
*
* Its own limit, deliberately: `openRecentProjectFile` happens to cap a project
* fetched by URL at the same number, but that one bounds a download buffered
* into memory and this one bounds a file kept on disk. Neither has to move when
* the other does.
*/
export const MAX_STARTUP_SNAPSHOT_BYTES = 25 * 1024 * 1024;

/** The subset of `Storage` this module uses, so tests can pass a plain fake. */
export interface SnapshotStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}

/** The two file operations {@link writeStartupSnapshot} and {@link readStartupSnapshot} need. */
export interface StartupSnapshotIo {
/** Write `content` to `file` under {@link STARTUP_SNAPSHOT_DIR}, creating the directory. */
write: (file: string, content: string) => Promise<void>;
/** Read `file` under {@link STARTUP_SNAPSHOT_DIR}. Rejects when it is not there. */
read: (file: string) => Promise<string>;
}

/** The snapshot file name for a slot. `.geolibre.json` so the copy is recognizable on disk. */
export function startupSnapshotFile(slot: StartupSnapshotSlot): string {
return `${slot}.geolibre.json`;
}

/**
* Whether a project is too large to keep a copy of, measured as the UTF-8 bytes
* the file would actually occupy.
*
* `text.length` counts UTF-16 code units, so a project full of non-ASCII (CJK
* layer names, accented attribute values in embedded GeoJSON) can be up to three
* times the size of the string that passed the check. The bounds below settle
* most projects without encoding anything: the byte count is never below the
* code-unit count and never above three times it, and encoding is a full second
* copy of the text -- which for the very projects this guard exists to reject
* would mean allocating hundreds of megabytes on a phone just to confirm they
* are too big.
*
* @param text - The serialized project.
* @returns True when the copy would exceed {@link MAX_STARTUP_SNAPSHOT_BYTES}.
*/
export function exceedsStartupSnapshotLimit(text: string): boolean {
if (text.length > MAX_STARTUP_SNAPSHOT_BYTES) return true;
if (text.length * 3 <= MAX_STARTUP_SNAPSHOT_BYTES) return false;
return new TextEncoder().encode(text).byteLength > MAX_STARTUP_SNAPSHOT_BYTES;
}

function defaultStorage(): SnapshotStorage | null {
try {
return typeof window === "undefined" ? null : window.localStorage;
} catch {
// Storage can throw outright when it is disabled (private browsing).
return null;
}
}

function isSnapshotEntry(value: unknown): value is StartupSnapshotEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<StartupSnapshotEntry>;
return (
typeof entry.sourcePath === "string" &&
entry.sourcePath.length > 0 &&
typeof entry.file === "string" &&
entry.file.length > 0 &&
typeof entry.savedAt === "string"
);
}

/**
* The persisted index of snapshots, dropping anything that does not parse.
*
* @param storage - Storage to read from; defaults to `window.localStorage`.
* @returns The stored index, or an empty one.
*/
export function readStartupSnapshotIndex(
storage: SnapshotStorage | null = defaultStorage(),
): StartupSnapshotIndex {
if (!storage) return {};
let parsed: unknown;
try {
const raw = storage.getItem(STARTUP_SNAPSHOTS_STORAGE_KEY);
if (!raw) return {};
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object") return {};
const candidate = parsed as Record<string, unknown>;
const index: StartupSnapshotIndex = {};
for (const slot of ["specific", "last"] as const) {
if (isSnapshotEntry(candidate[slot])) index[slot] = candidate[slot];
}
return index;
}

function writeStartupSnapshotIndex(
index: StartupSnapshotIndex,
storage: SnapshotStorage | null,
): void {
if (!storage) return;
try {
storage.setItem(STARTUP_SNAPSHOTS_STORAGE_KEY, JSON.stringify(index));
} catch {
// A full or disabled storage costs the fallback, not the save itself.
}
}

/**
* The slot a project should be copied into, or null when it is not one the
* startup preference would reopen.
*
* "specific" only ever holds the project the user named, so opening something
* else must not overwrite it. "last" tracks whatever was opened or saved most
* recently, which is what `startupProjectPath` resolves that mode to.
*
* @param path - The path or content URI the project was opened from or saved to.
* @param settings - The committed startup preference.
* @returns The slot to write, or null to write nothing.
*/
export function startupSnapshotSlot(
path: string,
settings: StartupSettings,
): StartupSnapshotSlot | null {
if (!isAndroidContentUri(path)) return null;
if (settings.mode === "specific") return settings.projectPath === path ? "specific" : null;
Comment thread
giswqs marked this conversation as resolved.
if (settings.mode === "last") return "last";
return null;
}

/**
* Keep a durable copy of a project the startup preference will reopen.
*
Comment thread
giswqs marked this conversation as resolved.
* A failure is logged and swallowed: this runs alongside opening or saving a
* project, and the copy is a fallback for a later launch, so it must never turn
* a successful save into a visible error.
*
* @param path - The path or content URI the project was opened from or saved to.
* @param text - The serialized project to copy.
* @param settings - The committed startup preference.
* @param io - Snapshot file access.
* @param options - `storage` overrides where the index is kept.
* @returns The slot written, or null when nothing was.
*/
export async function writeStartupSnapshot(
path: string,
text: string,
settings: StartupSettings,
io: StartupSnapshotIo,
options?: { storage?: SnapshotStorage | null },
): Promise<StartupSnapshotSlot | null> {
const slot = startupSnapshotSlot(path, settings);
if (!slot) return null;
if (exceedsStartupSnapshotLimit(text)) {
console.warn(
`Startup project is too large to keep a restorable copy (over ${MAX_STARTUP_SNAPSHOT_BYTES} bytes).`,
Comment thread
giswqs marked this conversation as resolved.
path,
);
return null;
}

const file = startupSnapshotFile(slot);
try {
await io.write(file, text);
} catch (error) {
console.warn("Could not keep a restorable copy of the startup project.", error);
return null;
}

const storage = options?.storage === undefined ? defaultStorage() : options.storage;
writeStartupSnapshotIndex(
{
...readStartupSnapshotIndex(storage),
[slot]: { sourcePath: path, file, savedAt: new Date().toISOString() },
},
storage,
);
return slot;
}

/**
* The stored copy of the project at `path`, when there is one.
*
* The source path must match exactly: a copy of a different project is never a
* stand-in for the one the user asked for, so a stale slot yields null and the
* caller reports the original read failure.
*
* @param path - The path or content URI the restore could not read.
* @param io - Snapshot file access.
* @param storage - Storage holding the index; defaults to `window.localStorage`.
* @returns The copied project text, or null when there is none to use.
*/
export async function readStartupSnapshot(
path: string,
io: StartupSnapshotIo,
storage: SnapshotStorage | null = defaultStorage(),
): Promise<string | null> {
const index = readStartupSnapshotIndex(storage);
const entry = Object.values(index).find((candidate) => candidate.sourcePath === path);
if (!entry) return null;
Comment thread
giswqs marked this conversation as resolved.
Outdated
try {
return await io.read(entry.file);
} catch (error) {
// The index outlived its file (cleared app storage, a write that never
// landed). Nothing to restore, so let the caller report the real failure.
console.warn("Could not read the stored copy of the startup project.", error);
return null;
}
Comment on lines +302 to +310

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop only guards against io.read rejecting (missing file); it doesn't validate that the bytes it returns actually parse as a project. The caller (openRecentProjectFile in tauri-io.ts, around its parseProject(snapshot) call) doesn't wrap that call in try/catch either.

If the newest slot's file was left truncated/corrupted by an interrupted write — plausible here specifically, since a snapshot write happens right before the very kind of process death this feature exists to survive — io.read succeeds (the file exists) but parseProject throws downstream. That exception isn't caught here or in the caller, so:

  • the loop never falls through to try an older, possibly-valid entry for the same sourcePath, and
  • the caller's RecentProjectGoneError/generic-error handling is bypassed entirely, surfacing an unhandled parse/schema error instead of the intended graceful fallback.

Worth validating the content before returning it (e.g. try parseProject/JSON.parse here and continue to the next entry on failure) so a corrupted newest copy can't shadow a good older one or crash the restore path. Confidence: medium — plausible but not exercised by the current test suite.

}
7 changes: 7 additions & 0 deletions apps/geolibre-desktop/src/lib/storage-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ export const UPDATE_DISMISSED_VERSION_STORAGE_KEY = "geolibre.updateDismissedVer
* exhaust the per-IP rate limit (desktop only).
*/
export const UPDATE_LAST_CHECK_STORAGE_KEY = "geolibre.lastUpdateCheck";

/**
* Which project each durable startup snapshot was copied from. Only the index
* lives here; the project text itself is a file in the app's private data
* directory. See `lib/startup-project-snapshot.ts`.
*/
export const STARTUP_SNAPSHOTS_STORAGE_KEY = "geolibre.startupProjectSnapshots";
Loading
Loading