Skip to content

Commit b472ec6

Browse files
authored
feat(notebook): drive the map from external Jupyter clients (VS Code) (#1444)
* feat(notebook): drive the map from external Jupyter clients `geolibre.connect()` sent every map command as a `display(Javascript(...))` that posts to `window.parent`, so it only reached the map when the notebook was rendered inside the app's own Notebook-panel iframe. Attaching an external frontend to the desktop app's Jupyter server (VS Code's Jupyter extension, `jupyter console`, nbclient) ran cells fine but made every `fly_to`/`add_geojson` silently do nothing. Add a transport that depends on which SERVER the kernel belongs to rather than on how the notebook is displayed: - `geolibre_server/jupyter_relay.py`, a Jupyter Server extension enabled from `jupyter_server_config.py`, exposing `POST .../geolibre/relay/command`, a `.../geolibre/relay/socket` WebSocket and `GET .../geolibre/relay/status`. At load it publishes `GEOLIBRE_RELAY_URL`/`GEOLIBRE_RELAY_TOKEN` into the server environment, which kernels inherit, so `import geolibre` finds the map with no configuration and no launcher change. - `useJupyterRelay` subscribes the app to that socket for its whole lifetime (reconnecting with backoff) and runs commands against the same `createScriptingHandlers` surface as `useNotebookBridge` and the console. - The kernel client prefers the relay and keeps postMessage as the fallback for the embedded panel and JupyterLite. The POST reports how many windows received the command, so an undeliverable call now raises a `GeoLibreNotConnectedWarning` pointing at the user's own line instead of quietly succeeding; `geolibre.is_connected()` exposes the same state. - The Notebook panel gains a button that copies the server URL an external client attaches to, since that is the only setup step left. Every endpoint requires the server's per-launch token and the socket accepts only the app's own origins, so a command can only come from something that already has kernel-execution rights on this loopback server. Fixes #1442 * fix(notebook): handle a rejected clipboard copy and modernize the abc imports Addresses CodeRabbit review on #1444: navigator.clipboard.writeText can reject, which left an unhandled rejection and could show the copied confirmation for a copy that never happened; and Iterable/Sequence now come from collections.abc (UP035).
1 parent 9d212c5 commit b472ec6

15 files changed

Lines changed: 1411 additions & 101 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { KnowledgeCardConsentDialog } from "./KnowledgeCardConsentDialog";
114114
import { MapGrid } from "./MapGrid";
115115
import { RemoteCursorsOverlay } from "./RemoteCursorsOverlay";
116116
import { useCommandBridge } from "../../hooks/useCommandBridge";
117+
import { useJupyterRelay } from "../../hooks/useJupyterRelay";
117118
import { appendDiagnostic, useDiagnosticsSnapshot } from "../../lib/diagnostics";
118119
import { SectionErrorBoundary, SilentErrorBoundary } from "../common/error-boundaries";
119120
import { AttributeTable } from "../panels/AttributeTable";
@@ -747,6 +748,10 @@ export function DesktopShell({
747748
// Request/reply + event channel backing the Python scripting API (live
748749
// queries, processing, map events). Also inert when not embedded.
749750
useCommandBridge(mapControllerRef);
751+
// Same scripting surface, reached over the desktop Jupyter server's relay, so
752+
// a kernel driven from an EXTERNAL client (VS Code's Jupyter extension) can
753+
// control the map too. Inert until that server is running.
754+
useJupyterRelay(mapControllerRef);
750755
// Routes the Layers-panel Identify action to the raster pixel inspector for
751756
// COG layers (read band values on click). Inert until a COG is identified.
752757
useRasterIdentify();

apps/geolibre-desktop/src/components/panels/NotebookPanel.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { useAppStore } from "@geolibre/core";
22
import { Button } from "@geolibre/ui";
3-
import { Loader2, NotebookPen, PanelRightClose, PanelRightOpen, X } from "lucide-react";
3+
import {
4+
Check,
5+
Link2,
6+
Loader2,
7+
NotebookPen,
8+
PanelRightClose,
9+
PanelRightOpen,
10+
X,
11+
} from "lucide-react";
412
import {
513
type PointerEvent as ReactPointerEvent,
614
type RefObject,
@@ -15,7 +23,7 @@ import { useNotebookBridge } from "../../hooks/useNotebookBridge";
1523
import { useNotebookThemeSync } from "../../hooks/useNotebookThemeSync";
1624
import type { ThemeMode } from "../../hooks/useThemeMode";
1725
import { isTauri } from "../../lib/is-tauri";
18-
import { startJupyterServer } from "../../lib/jupyter";
26+
import { type JupyterServerInfo, startJupyterServer } from "../../lib/jupyter";
1927

2028
/**
2129
* Resolve the notebook iframe URL for the current environment:
@@ -24,13 +32,24 @@ import { startJupyterServer } from "../../lib/jupyter";
2432
* embed its token-authenticated `/lab` URL.
2533
* - **Web:** load the self-hosted JupyterLite site (in-browser Pyodide kernel)
2634
* built by `npm run build:jupyterlite` into `public/jupyterlite/`.
35+
*
36+
* @returns The iframe `src`, plus the desktop server it belongs to (null on web,
37+
* where there is no server an external client could attach to).
2738
*/
28-
async function resolveNotebookUrl(): Promise<string> {
39+
async function resolveNotebook(): Promise<{ src: string; server: JupyterServerInfo | null }> {
2940
if (isTauri()) {
3041
const info = await startJupyterServer();
31-
return `${info.url}/lab?token=${encodeURIComponent(info.token)}`;
42+
return {
43+
src: `${info.url}/lab?token=${encodeURIComponent(info.token)}`,
44+
server: info,
45+
};
3246
}
33-
return `${import.meta.env.BASE_URL}jupyterlite/lab/index.html`;
47+
return { src: `${import.meta.env.BASE_URL}jupyterlite/lab/index.html`, server: null };
48+
}
49+
50+
/** The URL an external Jupyter client (VS Code…) attaches to this server with. */
51+
function externalClientUrl(server: JupyterServerInfo): string {
52+
return `${server.url}/?token=${encodeURIComponent(server.token)}`;
3453
}
3554

3655
interface NotebookPanelProps {
@@ -62,6 +81,8 @@ export function NotebookPanel({ onResizeStart, mapControllerRef, themeMode }: No
6281
const [isCollapsed, setIsCollapsed] = useState(getIsMobileViewport);
6382
const [loaded, setLoaded] = useState(false);
6483
const [src, setSrc] = useState<string | null>(null);
84+
const [server, setServer] = useState<JupyterServerInfo | null>(null);
85+
const [copied, setCopied] = useState(false);
6586
const [error, setError] = useState<string | null>(null);
6687
const iframeRef = useRef<HTMLIFrameElement>(null);
6788

@@ -74,9 +95,11 @@ export function NotebookPanel({ onResizeStart, mapControllerRef, themeMode }: No
7495
// which can take a moment on first run while uv syncs the environment).
7596
useEffect(() => {
7697
let cancelled = false;
77-
resolveNotebookUrl()
78-
.then((url) => {
79-
if (!cancelled) setSrc(url);
98+
resolveNotebook()
99+
.then(({ src: url, server: info }) => {
100+
if (cancelled) return;
101+
setSrc(url);
102+
setServer(info);
80103
})
81104
.catch((err: unknown) => {
82105
if (cancelled) return;
@@ -96,6 +119,13 @@ export function NotebookPanel({ onResizeStart, mapControllerRef, themeMode }: No
96119
};
97120
}, [t]);
98121

122+
// Revert the copy button's confirmation so it reads as reusable.
123+
useEffect(() => {
124+
if (!copied) return;
125+
const timer = setTimeout(() => setCopied(false), 2000);
126+
return () => clearTimeout(timer);
127+
}, [copied]);
128+
99129
return (
100130
<aside
101131
aria-label={t("notebook.title")}
@@ -137,6 +167,36 @@ export function NotebookPanel({ onResizeStart, mapControllerRef, themeMode }: No
137167
<NotebookPen className="h-4 w-4 text-muted-foreground" />
138168
<span className="text-sm font-semibold">{t("notebook.title")}</span>
139169
<div className="ms-auto flex items-center gap-1">
170+
{/* Desktop only: the loopback server URL + token an external
171+
Jupyter client (VS Code's Jupyter extension, `jupyter
172+
console`) attaches to. Such a client drives the map through the
173+
relay (useJupyterRelay), not through this iframe. */}
174+
{server ? (
175+
<Button
176+
variant="ghost"
177+
size="icon"
178+
className="h-7 w-7"
179+
title={copied ? t("notebook.serverUrlCopied") : t("notebook.copyServerUrl")}
180+
aria-label={t("notebook.copyServerUrl")}
181+
onClick={() => {
182+
void navigator.clipboard
183+
?.writeText(externalClientUrl(server))
184+
.then(() => setCopied(true))
185+
.catch((error: unknown) => {
186+
// Clipboard access can be denied; never leave the button
187+
// claiming a copy that did not happen.
188+
setCopied(false);
189+
console.error("Could not copy the Jupyter server URL", error);
190+
});
191+
}}
192+
>
193+
{copied ? (
194+
<Check className="h-4 w-4 text-primary" />
195+
) : (
196+
<Link2 className="h-4 w-4" />
197+
)}
198+
</Button>
199+
) : null}
140200
<Button
141201
variant="ghost"
142202
size="icon"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { type RefObject, useEffect } from "react";
2+
import type { MapController } from "@geolibre/map";
3+
import { subscribeJupyterServer } from "../lib/jupyter";
4+
import {
5+
type RelayCommand,
6+
parseRelayMessage,
7+
relayReconnectDelay,
8+
relaySocketUrl,
9+
} from "../lib/jupyter-relay";
10+
import { createScriptingHandlers } from "../lib/scripting/scriptingApi";
11+
12+
// The app side of the desktop Jupyter map-command relay. This is the SIBLING of
13+
// useNotebookBridge: that one receives commands over postMessage from the
14+
// notebook iframe the app embeds, which only works while the notebook is
15+
// rendered inside the Notebook panel. This one subscribes to a loopback
16+
// WebSocket on the Jupyter server itself, so a kernel driven by ANY frontend —
17+
// notably VS Code's Jupyter extension attached to the same server — reaches the
18+
// same map (issue #1442).
19+
//
20+
// Both feed the SAME createScriptingHandlers surface used by the in-app Python
21+
// console and the Jupyter widget, so behaviour cannot drift between transports.
22+
23+
/**
24+
* Subscribe to the desktop Jupyter server's map-command relay for the app's
25+
* lifetime, running each relayed command against the live map.
26+
*
27+
* Connects whenever a Jupyter server is running (started by the Notebook panel)
28+
* and reconnects with backoff if the socket drops. Inert on web and on desktop
29+
* until a server has been started.
30+
*
31+
* @param mapControllerRef - Ref to the live map controller, read lazily by the
32+
* command handlers.
33+
*/
34+
export function useJupyterRelay(mapControllerRef: RefObject<MapController | null>): void {
35+
useEffect(() => {
36+
const handlers = createScriptingHandlers({
37+
getController: () => mapControllerRef.current,
38+
});
39+
40+
let socket: WebSocket | null = null;
41+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
42+
let attempt = 0;
43+
// Bumped on every server change/unmount so a socket opened for a previous
44+
// server can never resurrect the reconnect loop after we moved on.
45+
let generation = 0;
46+
47+
const run = async (command: RelayCommand) => {
48+
// Own-property only, so an inherited member ("constructor", …) can never be
49+
// invoked as a command.
50+
if (!Object.hasOwn(handlers, command.method)) {
51+
console.warn(`Jupyter relay: unknown command "${command.method}"`);
52+
return;
53+
}
54+
try {
55+
await handlers[command.method](command.params);
56+
} catch (error) {
57+
console.error(`Jupyter relay: command "${command.method}" failed`, error);
58+
}
59+
};
60+
61+
const close = () => {
62+
generation += 1;
63+
if (retryTimer !== null) {
64+
clearTimeout(retryTimer);
65+
retryTimer = null;
66+
}
67+
// Drop the handlers first: onclose must not schedule a reconnect for a
68+
// socket we are deliberately tearing down.
69+
if (socket) {
70+
socket.onclose = null;
71+
socket.onerror = null;
72+
socket.onmessage = null;
73+
socket.close();
74+
socket = null;
75+
}
76+
};
77+
78+
const unsubscribe = subscribeJupyterServer((info) => {
79+
close();
80+
if (!info) return;
81+
const mine = generation;
82+
attempt = 0;
83+
84+
const connect = () => {
85+
if (generation !== mine) return;
86+
let next: WebSocket;
87+
try {
88+
next = new WebSocket(relaySocketUrl(info));
89+
} catch (error) {
90+
console.warn("Jupyter relay: could not open the command socket", error);
91+
return;
92+
}
93+
socket = next;
94+
next.onopen = () => {
95+
attempt = 0;
96+
};
97+
next.onmessage = (event: MessageEvent) => {
98+
const command = parseRelayMessage(event.data);
99+
if (command) void run(command);
100+
};
101+
next.onclose = () => {
102+
if (generation !== mine) return;
103+
socket = null;
104+
// The server outlives a dropped socket (it runs for the app's
105+
// lifetime), so keep retrying rather than giving up on the channel.
106+
retryTimer = setTimeout(connect, relayReconnectDelay(attempt));
107+
attempt += 1;
108+
};
109+
};
110+
111+
connect();
112+
});
113+
114+
return () => {
115+
unsubscribe();
116+
close();
117+
};
118+
// Mount-only: the ref is stable and read lazily inside the handlers.
119+
}, [mapControllerRef]);
120+
}

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2882,6 +2882,8 @@
28822882
"collapse": "Collapse notebook",
28832883
"expand": "Expand notebook",
28842884
"close": "Close notebook",
2885+
"copyServerUrl": "Copy the server URL for external clients (VS Code…)",
2886+
"serverUrlCopied": "Server URL copied",
28852887
"loading": "Loading notebook…",
28862888
"loadFailed": "Failed to load the notebook."
28872889
},
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { JupyterServerInfo } from "./jupyter";
2+
3+
// Wire format for the desktop Jupyter map-command relay
4+
// (backend/geolibre_server/geolibre_server/jupyter_relay.py). The relay lets a
5+
// kernel drive the map regardless of which *frontend* is running the cell — the
6+
// embedded Notebook panel, or an external client such as VS Code's Jupyter
7+
// extension (issue #1442) — where the postMessage transport in useNotebookBridge
8+
// only reaches the map from inside the app's own iframe.
9+
//
10+
// Pure helpers live here (not in the hook) so the protocol is unit-testable.
11+
12+
/** One scripting command relayed from a kernel, in the shared bridge envelope. */
13+
export interface RelayCommand {
14+
method: string;
15+
params: Record<string, unknown>;
16+
}
17+
18+
/** URL path the relay's endpoints are mounted under, mirroring `RELAY_PATH`. */
19+
const RELAY_PATH = "geolibre/relay";
20+
21+
/**
22+
* Build the app-side WebSocket URL for a running Jupyter server.
23+
*
24+
* The token rides in the query string because a WebSocket handshake cannot carry
25+
* an `Authorization` header, and the server's session cookie is unavailable to
26+
* us (the app is a different origin than the loopback server).
27+
*
28+
* @param info - The running server's connection details.
29+
* @returns A `ws://` URL for the relay socket.
30+
*/
31+
export function relaySocketUrl(info: JupyterServerInfo): string {
32+
const url = new URL(`${info.url.replace(/\/+$/, "")}/${RELAY_PATH}/socket`);
33+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
34+
if (info.token) url.searchParams.set("token", info.token);
35+
return url.toString();
36+
}
37+
38+
/**
39+
* Parse one relay frame into a command, rejecting anything malformed.
40+
*
41+
* @param data - The raw WebSocket payload.
42+
* @returns The command, or null for a non-command frame (e.g. the relay's
43+
* `geolibre:relay-ready` greeting) or an unparseable one.
44+
*/
45+
export function parseRelayMessage(data: unknown): RelayCommand | null {
46+
if (typeof data !== "string") return null;
47+
let payload: unknown;
48+
try {
49+
payload = JSON.parse(data);
50+
} catch {
51+
return null;
52+
}
53+
if (!payload || typeof payload !== "object") return null;
54+
const message = payload as { type?: unknown; method?: unknown; params?: unknown };
55+
if (message.type !== "geolibre:command") return null;
56+
if (typeof message.method !== "string" || !message.method) return null;
57+
const params =
58+
message.params && typeof message.params === "object" && !Array.isArray(message.params)
59+
? (message.params as Record<string, unknown>)
60+
: {};
61+
return { method: message.method, params };
62+
}
63+
64+
/** Reconnect backoff (ms) after a dropped socket, capped so it stays responsive. */
65+
export const RELAY_RECONNECT_MIN_MS = 1_000;
66+
export const RELAY_RECONNECT_MAX_MS = 15_000;
67+
68+
/**
69+
* Next reconnect delay for a given consecutive-failure count (exponential).
70+
*
71+
* @param attempt - How many reconnects have already failed (0 for the first).
72+
* @returns The delay in milliseconds, capped at {@link RELAY_RECONNECT_MAX_MS}.
73+
*/
74+
export function relayReconnectDelay(attempt: number): number {
75+
const delay = RELAY_RECONNECT_MIN_MS * 2 ** Math.max(0, attempt);
76+
return Math.min(delay, RELAY_RECONNECT_MAX_MS);
77+
}

0 commit comments

Comments
 (0)