Skip to content
Merged
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
16 changes: 16 additions & 0 deletions apps/geolibre-desktop/src/hooks/useDesktopSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ interface DesktopSettingsState {
setDesktopSettings: (settings: DesktopSettings) => void;
}

let desktopSettingsAreTemporary = false;

export const DEFAULT_DESKTOP_LAYOUT_SETTINGS: DesktopLayoutSettings = {
browserPanelVisible: true,
commentsPanelVisible: true,
Expand Down Expand Up @@ -481,8 +483,22 @@ export const useDesktopSettingsStore = create<DesktopSettingsState>((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));
}
Comment thread
giswqs marked this conversation as resolved.

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) => {
Comment thread
giswqs marked this conversation as resolved.
Expand Down
72 changes: 72 additions & 0 deletions apps/geolibre-desktop/src/lib/desktop-settings-url.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<DesktopSettings> {
const { fetchImpl = fetch, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = options;
const response = await fetchImpl(url, {
cache: "no-cache",
credentials: "same-origin",
signal: AbortSignal.timeout(timeoutMs),
});
Comment thread
giswqs marked this conversation as resolved.
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<string, unknown>);
}

/** Resolve a shared language only when a valid locale/lang URL override is absent. */
Comment thread
giswqs marked this conversation as resolved.
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);
}
45 changes: 43 additions & 2 deletions apps/geolibre-desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
},
Comment thread
giswqs marked this conversation as resolved.
);
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.

// 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.
Expand All @@ -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 = <App />;
Expand Down
15 changes: 15 additions & 0 deletions docs/user-guide/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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=<id>` opens the Processing (Whitebox toolbox) dialog preselected to a tool.
Expand Down
121 changes: 121 additions & 0 deletions tests/desktop-settings-url.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<Response>((_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);
});
});
Loading