One URL Convention for Three Surfaces: How to Deep Link a Multi-Tool Web App Without Forking the Tools
TLDR
Three surfaces, a standalone page, an OS shell iframe, and a VS Code webview, need to open the same tool in a specific mode. The cleanest contract: ?view=<mode> as the universal signal. Each tool reads it once on init. The OS shell appends it to iframe src. The VS Code extension injects it as a global before scripts run. The URL bar reflects the active mode via replaceState. One convention, no per-surface fork.
The Problem
A multi-tool web app often ships tools at canonical URLs that work standalone, and also embeds those same tools in a shell or container for a richer experience. Each surface needs to open a tool in a specific mode (editor, diff, analyzer). The naive approach is per-surface initialization code: the shell injects a global, the VS Code extension injects a different global, the standalone page reads yet another mechanism. The tool ends up with three init paths and three places to maintain.
The correct approach: one signal that all three surfaces write and one reader that handles all three.
The Universal Signal: ?view=
Each tool reads a single query parameter on init:
const queryView = new URLSearchParams(location.search).get('view');
if (queryView) {
const btn = document.querySelector(.tab-btn[data-view="${queryView}"]);
if (btn && !btn.disabled) btn.click();
}
This is the entire mode-routing path. No per-surface check, no global variable, no postMessage listener needed at the tool level.
Three callers write to this contract:
- Direct URL:
https://example.com/tools/pdf/?view=editor - OS shell: appends
?view=editorto the iframe src when opening a tool in a specific mode - VS Code extension: sets
window.__GINEXYS_INITIAL_MODE__before scripts run; the tool checks this global first, falls back to?view=
OS Shell: Appending the View to iframe src
The shell's openApp function accepts an opts argument:
function buildToolSrc(src, view) {
if (!view) return src;
const sep = src.includes('?') ? '&' : '?';
return src + sep + 'view=' + encodeURIComponent(view);
}
function openApp(app, opts) { if (windowMap.has(app.id)) { const win = windowMap.get(app.id); if (opts?.view && win.currentView !== opts.view) { const iframe = win.el.querySelector('iframe'); if (iframe) iframe.src = buildToolSrc(app.src, opts.view); win.currentView = opts.view; } bringToFront(app.id); return; } // new window creation: buildToolSrc(app.src, opts?.view) }
A second openApp call with a different view re-points the iframe src. Same window, new mode. No DOM swap, no re-injection of globals.
Bidirectional URL Sync
The URL bar should reflect the active tool and mode. This is implemented with replaceState, not pushState.
function syncUrl(appId, view) {
const slug = PUBLIC_SLUG[appId]; // internal id → public url segment
let path;
if (!slug) path = '/';
else if (view) path = '/app/' + slug + '/' + view + '/';
else path = '/app/' + slug + '/';
if (location.pathname !== path) { history.replaceState({ appId, view }, '', path); } }
replaceState not pushState because every focus change updates the URL. pushState would create a history entry for every dock click, turning the back button into a tour through every window activation. replaceState keeps the URL accurate without polluting history. Only real navigations (direct URL entry, back/forward) create history entries.
Real navigation is handled by the popstate listener:
window.addEventListener('popstate', () => {
const parsed = parseDeepLink(location.pathname);
if (!parsed) return;
const appId = ALIAS_MAP[parsed.app] || parsed.app;
const app = APPS.find(a => a.id === appId);
if (app) openApp(app, { view: parsed.view });
});
Server-Side Routing for Path-Form Deep Links
Path-form deep links (/app/pdf/editor/) need the server to serve the shell HTML without a redirect. A redirect loses the path before JavaScript runs.
Cloudflare Pages _redirects:
/app/* /index.html 200
Status 200 is critical. A 302 would redirect to /index.html at a different URL, losing the original path from window.location.pathname before parseDeepLink can read it.
What Stays Unchanged
Standalone canonical URLs work exactly as before. Existing bookmarks to /tools/pdf-processor/ open the standalone tool with no shell, no chrome, no query parameter. The VS Code extension is unaffected. It loads the canonical index.html into a webview and injects its own global. The ?view= reader checks the global first and falls back to the query string, so the extension's path wins when present.
Three surfaces, one convention, no per-surface tool fork.