From 205c3af59eb891347021f5d9d988ff4c481ebeab Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:14:10 -0400 Subject: [PATCH 1/4] feat: load shared desktop settings from URL --- .../src/hooks/useDesktopSettings.ts | 9 +++ .../src/lib/desktop-settings-url.ts | 41 +++++++++++++ apps/geolibre-desktop/src/main.tsx | 15 +++++ docs/user-guide/embedding.md | 8 +++ tests/desktop-settings-url.test.ts | 61 +++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 apps/geolibre-desktop/src/lib/desktop-settings-url.ts create mode 100644 tests/desktop-settings-url.test.ts diff --git a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts index 3e290d0c6..281e851c3 100644 --- a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts +++ b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts @@ -169,6 +169,8 @@ interface DesktopSettingsState { setDesktopSettings: (settings: DesktopSettings) => void; } +let desktopSettingsAreTemporary = false; + export const DEFAULT_DESKTOP_LAYOUT_SETTINGS: DesktopLayoutSettings = { browserPanelVisible: true, commentsPanelVisible: true, @@ -481,8 +483,15 @@ export const useDesktopSettingsStore = create((set) => ({ setDesktopSettings: (settings) => set({ desktopSettings: normalizeDesktopSettings(settings) }), })); +/** Apply settings supplied by an embed URL without replacing this browser's saved preferences. */ +export function applyTemporaryDesktopSettings(settings: unknown): void { + desktopSettingsAreTemporary = true; + useDesktopSettingsStore.getState().setDesktopSettings(normalizeDesktopSettings(settings)); +} + export function useDesktopSettingsPersistence() { useEffect(() => { + if (desktopSettingsAreTemporary) return; saveDesktopSettings(useDesktopSettingsStore.getState().desktopSettings); return useDesktopSettingsStore.subscribe((state, previous) => { diff --git a/apps/geolibre-desktop/src/lib/desktop-settings-url.ts b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts new file mode 100644 index 000000000..8eb2aeeb3 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts @@ -0,0 +1,41 @@ +import { normalizeDesktopSettings, type DesktopSettings } from "../hooks/useDesktopSettings"; + +export const DESKTOP_SETTINGS_URL_PARAMS = ["settingsUrl", "settingUrl"] as const; + +export function desktopSettingsUrl(search: string): string | null { + const params = new URLSearchParams(search); + for (const name of DESKTOP_SETTINGS_URL_PARAMS) { + const value = params.get(name)?.trim(); + if (value) return value; + } + return null; +} + +export async function fetchDesktopSettings( + url: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(url, { + cache: "no-cache", + credentials: "same-origin", + }); + if (!response.ok) { + const status = response.statusText + ? `${response.status} ${response.statusText}` + : String(response.status); + throw new Error(`Could not load desktop settings from ${url} (HTTP ${status}).`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(await response.text()) as unknown; + } catch (error) { + throw new Error(`Desktop settings at ${url} are not valid JSON.`, { + cause: error, + }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Desktop settings at ${url} must be a JSON object.`); + } + return normalizeDesktopSettings(parsed); +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index fe6b30b23..f141bd741 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -67,6 +67,8 @@ import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; import { resolveAuthGate, type AuthGateConfig } from "./lib/auth-gate"; import { getInitialThemeMode } from "./hooks/useThemeMode"; +import { applyTemporaryDesktopSettings } from "./hooks/useDesktopSettings"; +import { desktopSettingsUrl, fetchDesktopSettings } from "./lib/desktop-settings-url"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -191,6 +193,18 @@ registerSW({ // Fetch both chunks in parallel rather than waterfalling the boundary import // after App resolves — a free win, and it matters over the network in the web // build where these are separate fetches. +const sharedSettingsUrl = desktopSettingsUrl(window.location.search); +const sharedSettingsReady = sharedSettingsUrl + ? fetchDesktopSettings(sharedSettingsUrl) + .then((settings) => applyTemporaryDesktopSettings(settings)) + .catch((error: unknown) => { + // A shared settings file is optional configuration. Keep the app usable + // with the visitor's local settings, but make a bad URL visible in the + // diagnostics capture and developer console. + console.error("[GeoLibre] Failed to load shared desktop settings", error); + }) + : Promise.resolve(); + void Promise.all([ import("./App"), import("./components/common/error-boundaries"), @@ -198,6 +212,7 @@ void Promise.all([ // Gate the first render on i18next being initialized with the active locale's // (lazily loaded) catalog, so the UI never paints raw translation keys. i18nReady, + sharedSettingsReady, ]) .then(([{ default: App }, { AppErrorBoundary }, withAuthGate]) => { const app = ; diff --git a/docs/user-guide/embedding.md b/docs/user-guide/embedding.md index 895fe9ccb..9c71ba70e 100644 --- a/docs/user-guide/embedding.md +++ b/docs/user-guide/embedding.md @@ -32,6 +32,7 @@ A chrome-free `maponly` embed shows only the map, as in this shared 3D Tiles pro | `maponly` | `maponly` | Hides all chrome (toolbar, panels, and status bar), leaving only the map. The bare flag or `true`, `1`, `yes`, `on` enable it. | | `welcome` | `welcome=0` | Hides the first-launch welcome wizard. Accepts `0`, `false`, `off`, or `no`. A `url=` or `data=` deep link already suppresses it automatically. | | `theme` | `theme=dark` | Sets the initial color theme, overriding the OS preference. Accepts `dark` or `light`; the in-app toggle still works afterward. | +| `settingsUrl` | `settingsUrl=https://example.com/desktop-settings.json` | Loads a complete `geolibre.desktopSettings` JSON object before the first render. The override lasts for this page only and does not replace the visitor's locally saved settings. `settingUrl` is accepted as an alias. | | `tool` | `tool=adaptive_filter` | Opens the Processing (Whitebox toolbox) dialog on a specific tool by its id. Unknown ids open the dialog without preselecting a tool. | !!! note "Private projects and data" @@ -48,6 +49,13 @@ Parameters combine. For a narrow, chrome-free, dark embed of a shared project: https://web.geolibre.app/?url=https://share.geolibre.app/you/project.geolibre.json&maponly&theme=dark ``` +The settings document must be public or same-origin, return valid JSON, and +allow cross-origin browser requests when hosted elsewhere. Its fields use the +same shape as the `geolibre.desktopSettings` local-storage value. Missing or +invalid fields are replaced with GeoLibre defaults. URL parameters such as +`theme`, `layout`, and `maponly` continue to take precedence over corresponding +settings where they overlap. + ### Deep-linking a Processing tool `tool=` opens the Processing (Whitebox toolbox) dialog preselected to a tool. diff --git a/tests/desktop-settings-url.test.ts b/tests/desktop-settings-url.test.ts new file mode 100644 index 000000000..8461c48e5 --- /dev/null +++ b/tests/desktop-settings-url.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + desktopSettingsUrl, + fetchDesktopSettings, +} from "../apps/geolibre-desktop/src/lib/desktop-settings-url"; + +describe("desktop settings URL", () => { + it("prefers settingsUrl and accepts the discussion's settingUrl spelling", () => { + assert.equal( + desktopSettingsUrl("?settingsUrl=https%3A%2F%2Fexample.com%2Fa.json"), + "https://example.com/a.json", + ); + assert.equal( + desktopSettingsUrl("?settingUrl=https%3A%2F%2Fexample.com%2Fb.json"), + "https://example.com/b.json", + ); + assert.equal( + desktopSettingsUrl("?settingsUrl=&settingUrl=https%3A%2F%2Fexample.com%2Fb.json"), + "https://example.com/b.json", + ); + assert.equal(desktopSettingsUrl("?url=project.json"), null); + }); + + it("fetches without trusting a stale cached settings file and normalizes it", async () => { + let init: RequestInit | undefined; + const fetchImpl = (async (_url: string, requestInit?: RequestInit) => { + init = requestInit; + return new Response( + JSON.stringify({ + layout: { toolbarLabels: false }, + uiProfile: { enabled: true, hiddenMenus: ["help", "help"] }, + }), + ); + }) as typeof fetch; + + const settings = await fetchDesktopSettings("https://example.com/settings.json", fetchImpl); + assert.equal(init?.cache, "no-cache"); + assert.equal(init?.credentials, "same-origin"); + assert.equal(settings.layout.toolbarLabels, false); + assert.deepEqual(settings.uiProfile.hiddenMenus, ["help"]); + }); + + it("reports HTTP, malformed JSON, and non-object documents", async () => { + await assert.rejects( + fetchDesktopSettings( + "https://example.com/missing.json", + async () => new Response("", { status: 404 }), + ), + /HTTP 404/, + ); + await assert.rejects( + fetchDesktopSettings("https://example.com/bad.json", async () => new Response("{")), + /not valid JSON/, + ); + await assert.rejects( + fetchDesktopSettings("https://example.com/list.json", async () => new Response("[]")), + /must be a JSON object/, + ); + }); +}); From eb4dc09757086e17b0c31682b8c37b497c17a8f5 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:21:38 -0400 Subject: [PATCH 2/4] Address CodeRabbit review feedback - Apply the validated shared language before React mounts while preserving locale and lang URL priority. - Bound the optional settings fetch so an unresponsive host cannot block startup indefinitely. --- .../src/lib/desktop-settings-url.ts | 20 +++++++++- apps/geolibre-desktop/src/main.tsx | 31 ++++++++++++--- docs/user-guide/embedding.md | 4 +- tests/desktop-settings-url.test.ts | 38 +++++++++++++++---- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/desktop-settings-url.ts b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts index 8eb2aeeb3..370224ed3 100644 --- a/apps/geolibre-desktop/src/lib/desktop-settings-url.ts +++ b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts @@ -1,6 +1,9 @@ import { normalizeDesktopSettings, type DesktopSettings } from "../hooks/useDesktopSettings"; +import { resolveLanguage } from "../i18n/languages"; export const DESKTOP_SETTINGS_URL_PARAMS = ["settingsUrl", "settingUrl"] as const; +const LANGUAGE_URL_PARAMS = ["locale", "lang"] as const; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; export function desktopSettingsUrl(search: string): string | null { const params = new URLSearchParams(search); @@ -13,11 +16,13 @@ export function desktopSettingsUrl(search: string): string | null { export async function fetchDesktopSettings( url: string, - fetchImpl: typeof fetch = fetch, + options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, ): Promise { + const { fetchImpl = fetch, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = options; const response = await fetchImpl(url, { cache: "no-cache", credentials: "same-origin", + signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) { const status = response.statusText @@ -39,3 +44,16 @@ export async function fetchDesktopSettings( } return normalizeDesktopSettings(parsed); } + +/** Resolve a shared language only when a valid locale/lang URL override is absent. */ +export function sharedSettingsLanguage( + search: string, + language: string, + availableLanguages: readonly string[], +): string | null { + const params = new URLSearchParams(search); + for (const name of LANGUAGE_URL_PARAMS) { + if (resolveLanguage(params.get(name), availableLanguages)) return null; + } + return resolveLanguage(language, availableLanguages); +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index f141bd741..e0892e880 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -61,14 +61,18 @@ import "./lib/auth-return-url-boot"; // paint is already in the right language. English is bundled; other locales are // lazily imported, so `i18nReady` resolves once the initial locale's catalog has // loaded and init has run — the render below awaits it. -import i18n, { i18nReady } from "./i18n"; +import i18n, { AVAILABLE_LANGUAGES, i18nReady, setActiveLanguage } from "./i18n"; import { installDiagnosticsCapture } from "./lib/diagnostics"; import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; import { resolveAuthGate, type AuthGateConfig } from "./lib/auth-gate"; import { getInitialThemeMode } from "./hooks/useThemeMode"; import { applyTemporaryDesktopSettings } from "./hooks/useDesktopSettings"; -import { desktopSettingsUrl, fetchDesktopSettings } from "./lib/desktop-settings-url"; +import { + desktopSettingsUrl, + fetchDesktopSettings, + sharedSettingsLanguage, +} from "./lib/desktop-settings-url"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -196,14 +200,30 @@ registerSW({ const sharedSettingsUrl = desktopSettingsUrl(window.location.search); const sharedSettingsReady = sharedSettingsUrl ? fetchDesktopSettings(sharedSettingsUrl) - .then((settings) => applyTemporaryDesktopSettings(settings)) + .then((settings) => { + applyTemporaryDesktopSettings(settings); + return settings; + }) .catch((error: unknown) => { // A shared settings file is optional configuration. Keep the app usable // with the visitor's local settings, but make a bad URL visible in the // diagnostics capture and developer console. console.error("[GeoLibre] Failed to load shared desktop settings", error); + return null; }) - : Promise.resolve(); + : Promise.resolve(null); + +const startupLanguageReady = Promise.all([i18nReady, sharedSettingsReady]).then( + async ([, settings]) => { + if (!settings) return; + const language = sharedSettingsLanguage( + window.location.search, + settings.language, + AVAILABLE_LANGUAGES, + ); + if (language) await setActiveLanguage(language); + }, +); void Promise.all([ import("./App"), @@ -211,8 +231,7 @@ void Promise.all([ loadAuthGate(authGate), // Gate the first render on i18next being initialized with the active locale's // (lazily loaded) catalog, so the UI never paints raw translation keys. - i18nReady, - sharedSettingsReady, + startupLanguageReady, ]) .then(([{ default: App }, { AppErrorBoundary }, withAuthGate]) => { const app = ; diff --git a/docs/user-guide/embedding.md b/docs/user-guide/embedding.md index 9c71ba70e..553798b9e 100644 --- a/docs/user-guide/embedding.md +++ b/docs/user-guide/embedding.md @@ -52,7 +52,9 @@ https://web.geolibre.app/?url=https://share.geolibre.app/you/project.geolibre.js The settings document must be public or same-origin, return valid JSON, and allow cross-origin browser requests when hosted elsewhere. Its fields use the same shape as the `geolibre.desktopSettings` local-storage value. Missing or -invalid fields are replaced with GeoLibre defaults. URL parameters such as +invalid fields are replaced with GeoLibre defaults. If the document fails to +load within ten seconds, GeoLibre starts with the visitor's local settings. +URL parameters such as `theme`, `layout`, and `maponly` continue to take precedence over corresponding settings where they overlap. diff --git a/tests/desktop-settings-url.test.ts b/tests/desktop-settings-url.test.ts index 8461c48e5..ef31e8b53 100644 --- a/tests/desktop-settings-url.test.ts +++ b/tests/desktop-settings-url.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { desktopSettingsUrl, fetchDesktopSettings, + sharedSettingsLanguage, } from "../apps/geolibre-desktop/src/lib/desktop-settings-url"; describe("desktop settings URL", () => { @@ -34,28 +35,51 @@ describe("desktop settings URL", () => { ); }) as typeof fetch; - const settings = await fetchDesktopSettings("https://example.com/settings.json", fetchImpl); + const settings = await fetchDesktopSettings("https://example.com/settings.json", { + fetchImpl, + timeoutMs: 500, + }); assert.equal(init?.cache, "no-cache"); assert.equal(init?.credentials, "same-origin"); + assert.ok(init?.signal); assert.equal(settings.layout.toolbarLabels, false); assert.deepEqual(settings.uiProfile.hiddenMenus, ["help"]); }); it("reports HTTP, malformed JSON, and non-object documents", async () => { await assert.rejects( - fetchDesktopSettings( - "https://example.com/missing.json", - async () => new Response("", { status: 404 }), - ), + fetchDesktopSettings("https://example.com/missing.json", { + fetchImpl: async () => new Response("", { status: 404 }), + }), /HTTP 404/, ); await assert.rejects( - fetchDesktopSettings("https://example.com/bad.json", async () => new Response("{")), + fetchDesktopSettings("https://example.com/bad.json", { + fetchImpl: async () => new Response("{"), + }), /not valid JSON/, ); await assert.rejects( - fetchDesktopSettings("https://example.com/list.json", async () => new Response("[]")), + fetchDesktopSettings("https://example.com/list.json", { + fetchImpl: async () => new Response("[]"), + }), /must be a JSON object/, ); }); + + it("lets a remote language replace the saved language before render", () => { + const available = ["en", "de", "fr"]; + // The app may have initialized from a saved `de`; main.tsx switches to the + // resolved remote `fr` before mounting React. + assert.equal(sharedSettingsLanguage("?settingsUrl=settings.json", "fr", available), "fr"); + assert.equal(sharedSettingsLanguage("?settingsUrl=settings.json", "unknown", available), null); + }); + + it("keeps locale and lang URL parameters above a remote language", () => { + const available = ["en", "de", "fr"]; + assert.equal(sharedSettingsLanguage("?locale=de", "fr", available), null); + assert.equal(sharedSettingsLanguage("?lang=de", "fr", available), null); + // An unsupported explicit locale follows the existing fallback behavior. + assert.equal(sharedSettingsLanguage("?locale=unknown", "fr", available), "fr"); + }); }); From eb42eca4c30b67cdc65601d6c1c8fd06d8490cde Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:23:26 -0400 Subject: [PATCH 3/4] Address Claude review feedback - Restrict URL settings to presentation fields so shared links cannot inject credentials, plugins, or local paths. - Clarify nested URL encoding and the independent roles of embed parameters and desktop preferences. - Document why shared-settings sessions remain entirely ephemeral. --- .../src/hooks/useDesktopSettings.ts | 3 +++ .../src/lib/desktop-settings-url.ts | 15 ++++++++++++- docs/user-guide/embedding.md | 21 ++++++++++++------- tests/desktop-settings-url.test.ts | 10 +++++++++ 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts index 281e851c3..2cba9537d 100644 --- a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts +++ b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts @@ -491,6 +491,9 @@ export function applyTemporaryDesktopSettings(settings: unknown): void { export function useDesktopSettingsPersistence() { useEffect(() => { + // Keep the entire shared-settings session ephemeral. Persisting a later + // user edit would serialize the remote baseline along with that edit and + // silently replace unrelated local preferences. if (desktopSettingsAreTemporary) return; saveDesktopSettings(useDesktopSettingsStore.getState().desktopSettings); diff --git a/apps/geolibre-desktop/src/lib/desktop-settings-url.ts b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts index 370224ed3..26315b33c 100644 --- a/apps/geolibre-desktop/src/lib/desktop-settings-url.ts +++ b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts @@ -5,6 +5,19 @@ export const DESKTOP_SETTINGS_URL_PARAMS = ["settingsUrl", "settingUrl"] as cons const LANGUAGE_URL_PARAMS = ["locale", "lang"] as const; const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +/** + * Keep URL-controlled settings limited to presentation. In particular, a + * shared link must never supply credentials, plugin sources, or local paths. + */ +export function normalizeSharedDesktopSettings(settings: Record): DesktopSettings { + return normalizeDesktopSettings({ + language: settings.language, + layout: settings.layout, + theme: settings.theme, + uiProfile: settings.uiProfile, + }); +} + export function desktopSettingsUrl(search: string): string | null { const params = new URLSearchParams(search); for (const name of DESKTOP_SETTINGS_URL_PARAMS) { @@ -42,7 +55,7 @@ export async function fetchDesktopSettings( if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`Desktop settings at ${url} must be a JSON object.`); } - return normalizeDesktopSettings(parsed); + return normalizeSharedDesktopSettings(parsed as Record); } /** Resolve a shared language only when a valid locale/lang URL override is absent. */ diff --git a/docs/user-guide/embedding.md b/docs/user-guide/embedding.md index 553798b9e..9ed96d01f 100644 --- a/docs/user-guide/embedding.md +++ b/docs/user-guide/embedding.md @@ -32,7 +32,7 @@ A chrome-free `maponly` embed shows only the map, as in this shared 3D Tiles pro | `maponly` | `maponly` | Hides all chrome (toolbar, panels, and status bar), leaving only the map. The bare flag or `true`, `1`, `yes`, `on` enable it. | | `welcome` | `welcome=0` | Hides the first-launch welcome wizard. Accepts `0`, `false`, `off`, or `no`. A `url=` or `data=` deep link already suppresses it automatically. | | `theme` | `theme=dark` | Sets the initial color theme, overriding the OS preference. Accepts `dark` or `light`; the in-app toggle still works afterward. | -| `settingsUrl` | `settingsUrl=https://example.com/desktop-settings.json` | Loads a complete `geolibre.desktopSettings` JSON object before the first render. The override lasts for this page only and does not replace the visitor's locally saved settings. `settingUrl` is accepted as an alias. | +| `settingsUrl` | `settingsUrl=https://example.com/desktop-settings.json` | Loads shared presentation settings before the first render. Supports `language`, `layout`, accent `theme`, and `uiProfile`. The override lasts for this page only and does not replace locally saved settings. `settingUrl` is accepted as an alias. | | `tool` | `tool=adaptive_filter` | Opens the Processing (Whitebox toolbox) dialog on a specific tool by its id. Unknown ids open the dialog without preselecting a tool. | !!! note "Private projects and data" @@ -50,13 +50,18 @@ https://web.geolibre.app/?url=https://share.geolibre.app/you/project.geolibre.js ``` The settings document must be public or same-origin, return valid JSON, and -allow cross-origin browser requests when hosted elsewhere. Its fields use the -same shape as the `geolibre.desktopSettings` local-storage value. Missing or -invalid fields are replaced with GeoLibre defaults. If the document fails to -load within ten seconds, GeoLibre starts with the visitor's local settings. -URL parameters such as -`theme`, `layout`, and `maponly` continue to take precedence over corresponding -settings where they overlap. +allow cross-origin browser requests when hosted elsewhere. Its supported fields +use the same shape as their counterparts in the `geolibre.desktopSettings` +local-storage value. Credential-bearing fields, plugin sources, local startup +paths, and update settings are ignored. Missing or invalid fields are replaced +with GeoLibre defaults. If the document fails to load within ten seconds, +GeoLibre starts with the visitor's local settings. The `locale` and `lang` URL +parameters take precedence over a shared `language`. Other embed parameters, +including `theme`, `layout`, and `maponly`, independently control the initial +light/dark mode and chrome rather than the desktop accent and panel preferences. + +Encode the settings URL with `encodeURIComponent` when it contains `&`, `+`, +`%`, or `#`, just as for a nested `data` URL. ### Deep-linking a Processing tool diff --git a/tests/desktop-settings-url.test.ts b/tests/desktop-settings-url.test.ts index ef31e8b53..c4aff5a20 100644 --- a/tests/desktop-settings-url.test.ts +++ b/tests/desktop-settings-url.test.ts @@ -31,6 +31,11 @@ describe("desktop settings URL", () => { JSON.stringify({ layout: { toolbarLabels: false }, uiProfile: { enabled: true, hiddenMenus: ["help", "help"] }, + shareToken: "remote-token-must-not-load", + cesiumIonToken: "remote-cesium-token-must-not-load", + aiProfiles: [{ id: "remote", fieldValues: { API_KEY: "secret" } }], + pluginManifestUrls: ["https://evil.example/plugin.json"], + startup: { mode: "specific", projectPath: "/remote/path" }, }), ); }) as typeof fetch; @@ -44,6 +49,11 @@ describe("desktop settings URL", () => { assert.ok(init?.signal); assert.equal(settings.layout.toolbarLabels, false); assert.deepEqual(settings.uiProfile.hiddenMenus, ["help"]); + assert.equal(settings.shareToken, ""); + assert.equal(settings.cesiumIonToken, ""); + assert.deepEqual(settings.aiProfiles, []); + assert.deepEqual(settings.pluginManifestUrls, []); + assert.equal(settings.startup.mode, "default"); }); it("reports HTTP, malformed JSON, and non-object documents", async () => { From 6d2bb1dbac3be3356576ce2336d881d7b50ff48c Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 20 Aug 2026 08:38:03 -0400 Subject: [PATCH 4/4] Address review feedback - Fall back to the initialized locale when a shared language catalog cannot load. - Verify timeout cancellation and temporary-settings persistence behavior. - Keep the parallel chunk-loading comment beside the code it describes. --- .../src/hooks/useDesktopSettings.ts | 6 ++++- apps/geolibre-desktop/src/main.tsx | 15 ++++++++--- tests/desktop-settings-url.test.ts | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts index 2cba9537d..ec2ec90e9 100644 --- a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts +++ b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts @@ -489,12 +489,16 @@ export function applyTemporaryDesktopSettings(settings: unknown): void { useDesktopSettingsStore.getState().setDesktopSettings(normalizeDesktopSettings(settings)); } +export function shouldPersistDesktopSettings(): boolean { + return !desktopSettingsAreTemporary; +} + export function useDesktopSettingsPersistence() { useEffect(() => { // Keep the entire shared-settings session ephemeral. Persisting a later // user edit would serialize the remote baseline along with that edit and // silently replace unrelated local preferences. - if (desktopSettingsAreTemporary) return; + if (!shouldPersistDesktopSettings()) return; saveDesktopSettings(useDesktopSettingsStore.getState().desktopSettings); return useDesktopSettingsStore.subscribe((state, previous) => { diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index e0892e880..4a5de5966 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -194,9 +194,6 @@ registerSW({ }, }); -// Fetch both chunks in parallel rather than waterfalling the boundary import -// after App resolves — a free win, and it matters over the network in the web -// build where these are separate fetches. const sharedSettingsUrl = desktopSettingsUrl(window.location.search); const sharedSettingsReady = sharedSettingsUrl ? fetchDesktopSettings(sharedSettingsUrl) @@ -221,10 +218,20 @@ const startupLanguageReady = Promise.all([i18nReady, sharedSettingsReady]).then( settings.language, AVAILABLE_LANGUAGES, ); - if (language) await setActiveLanguage(language); + if (!language) return; + try { + await setActiveLanguage(language); + } catch (error) { + // Shared language is optional presentation configuration. If its lazy + // catalog cannot load, retain the language i18next already initialized. + console.error("[GeoLibre] Failed to apply shared settings language", error); + } }, ); +// Fetch both chunks in parallel rather than waterfalling the boundary import +// after App resolves — a free win, and it matters over the network in the web +// build where these are separate fetches. void Promise.all([ import("./App"), import("./components/common/error-boundaries"), diff --git a/tests/desktop-settings-url.test.ts b/tests/desktop-settings-url.test.ts index c4aff5a20..261553df6 100644 --- a/tests/desktop-settings-url.test.ts +++ b/tests/desktop-settings-url.test.ts @@ -1,5 +1,9 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { + applyTemporaryDesktopSettings, + shouldPersistDesktopSettings, +} from "../apps/geolibre-desktop/src/hooks/useDesktopSettings"; import { desktopSettingsUrl, fetchDesktopSettings, @@ -77,6 +81,22 @@ describe("desktop settings URL", () => { ); }); + it("actually aborts a settings request after its timeout", async () => { + let signal: AbortSignal | null = null; + const fetchImpl = ((_url: string, init?: RequestInit) => { + signal = init?.signal ?? null; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal?.reason), { once: true }); + }); + }) as typeof fetch; + + await assert.rejects( + fetchDesktopSettings("https://example.com/stalled.json", { fetchImpl, timeoutMs: 5 }), + (error: Error) => error.name === "TimeoutError", + ); + assert.equal(signal?.aborted, true); + }); + it("lets a remote language replace the saved language before render", () => { const available = ["en", "de", "fr"]; // The app may have initialized from a saved `de`; main.tsx switches to the @@ -92,4 +112,10 @@ describe("desktop settings URL", () => { // An unsupported explicit locale follows the existing fallback behavior. assert.equal(sharedSettingsLanguage("?locale=unknown", "fr", available), "fr"); }); + + it("keeps a shared-settings session out of persistent storage", () => { + assert.equal(shouldPersistDesktopSettings(), true); + applyTemporaryDesktopSettings({ layout: { toolbarLabels: false } }); + assert.equal(shouldPersistDesktopSettings(), false); + }); });