diff --git a/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts b/apps/geolibre-desktop/src/hooks/useDesktopSettings.ts index 3e290d0c6..ec2ec90e9 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,22 @@ 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 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 (!shouldPersistDesktopSettings()) 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..26315b33c --- /dev/null +++ b/apps/geolibre-desktop/src/lib/desktop-settings-url.ts @@ -0,0 +1,72 @@ +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; + +/** + * 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) { + const value = params.get(name)?.trim(); + if (value) return value; + } + return null; +} + +export async function fetchDesktopSettings( + url: string, + 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 + ? `${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 normalizeSharedDesktopSettings(parsed as Record); +} + +/** 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 fe6b30b23..4a5de5966 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -61,12 +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, + sharedSettingsLanguage, +} from "./lib/desktop-settings-url"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -188,6 +194,41 @@ registerSW({ }, }); +const sharedSettingsUrl = desktopSettingsUrl(window.location.search); +const sharedSettingsReady = sharedSettingsUrl + ? fetchDesktopSettings(sharedSettingsUrl) + .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(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) 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. @@ -197,7 +238,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, + startupLanguageReady, ]) .then(([{ default: App }, { AppErrorBoundary }, withAuthGate]) => { const app = ; diff --git a/docs/user-guide/embedding.md b/docs/user-guide/embedding.md index 895fe9ccb..9ed96d01f 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 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" @@ -48,6 +49,20 @@ 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 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 `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..261553df6 --- /dev/null +++ b/tests/desktop-settings-url.test.ts @@ -0,0 +1,121 @@ +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, + sharedSettingsLanguage, +} 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"] }, + 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; + + 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"]); + 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 () => { + await assert.rejects( + fetchDesktopSettings("https://example.com/missing.json", { + fetchImpl: async () => new Response("", { status: 404 }), + }), + /HTTP 404/, + ); + await assert.rejects( + fetchDesktopSettings("https://example.com/bad.json", { + fetchImpl: async () => new Response("{"), + }), + /not valid JSON/, + ); + await assert.rejects( + fetchDesktopSettings("https://example.com/list.json", { + fetchImpl: async () => new Response("[]"), + }), + /must be a JSON object/, + ); + }); + + 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 + // 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"); + }); + + it("keeps a shared-settings session out of persistent storage", () => { + assert.equal(shouldPersistDesktopSettings(), true); + applyTemporaryDesktopSettings({ layout: { toolbarLabels: false } }); + assert.equal(shouldPersistDesktopSettings(), false); + }); +});