Engineering Journal
Ginexys
Ginexys

Deeplinks Deepdive

2026-05-30

Deep linking a multi-tool browser OS without breaking the standalone canonical apps

TLDR

GINEXYS ships three tools (PDF processor, TAFNE, Schema editor) at canonical URLs that anyone can deep-link directly. They also load inside an OS shell at ginexys.com where users open them in windows like a desktop. This week I unified both surfaces with a single ?view= query convention and added /app/<tool>/<view>/ deep links that update the URL bar bidirectionally. The architecture borrows from how the GINEXYS VSCode extension already loads tools into webviews and avoids a DOM-swap antipattern that was silently breaking my web sub-pages.

The three URL surfaces

After Phase 2, GINEXYS has three deep-link surfaces:

1. Canonical standalone:  /tools/pdf-processor/         (?view=editor optional)
  1. SEO sub-pages: /tools/pdf-processor/editor/ (iframe wrapper, Phase 3)
  2. OS deep link: /app/pdf/editor/ (OS shell + tool window)
All three open the same canonical app. Differences are in the chrome around it: standalone has no shell, sub-pages have unique per-mode SEO <head>, OS deep link has the desktop with dock and IPC.

The ?view= query as the universal mode signal

The canonical tool's index.html learns one new trick. It reads ?view= on init:

var queryView = new URLSearchParams(location.search).get('view');
var targetView = null;
if (queryView && document.querySelector('.tab-btn[data-view="' + queryView + '"]')) {
    targetView = queryView;
}
if (targetView) {
    var btn = document.querySelector('.tab-btn[data-view="' + targetView + '"]');
    if (btn && !btn.disabled) btn.click();
}

That is the entire mode-routing path. TAFNE and Schema editor have similar readers that map ?view= to their existing window.__GINEXYS_INITIAL_MODE__ global, the same global the VSCode extension injects via pendingMode.

One reader, three callers: VSCode extension, OS shell iframe, direct URL with query string. Same contract.

The OS shell forwarding the view to the iframe

The OS shell's openApp accepts an opts argument. When opts.view is set, it appends ?view=<view> to the iframe src:

function _buildToolSrc(src, view) {
    if (!view) return src;
    const sep = src.indexOf('?') >= 0 ? '&' : '?';
    return src + sep + 'view=' + encodeURIComponent(view);
}

function openApp(app, opts) { // ... existing lock checks, focus handling ... if (windowMap.has(app.id)) { const win = windowMap.get(app.id); if (opts && opts.view && app.type === 'iframe' && 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 with _buildToolSrc(app.src, opts.view) ... }

A second openApp call with a different view re-points the iframe. Same window, new mode. The tool sees a load event with new search params and activates the right tab.

Bidirectional URL sync via replaceState

URL writeback happens at three points: window open, window focus, window close.

function _syncDeepLinkUrl(appId, view) {
    const slug = appId ? DEEPLINK_PUBLIC_SLUG[appId] : null;
    let target;
    if (!slug) target = '/';
    else if (view) target = '/app/' + slug + '/' + view + '/';
    else target = '/app/' + slug + '/';
    if (location.pathname + location.search !== target) {
        try { history.replaceState({ appId, view }, '', target); } catch (e) {}
    }
}

replaceState not pushState. The reason matters. Every dock click and every window focus updates the URL. pushState would create a history entry for each, turning browser back into a tour through every focus change of the session. replaceState keeps the URL accurate without polluting history.

When the user types ginexys.com/app/pdf/editor/ or navigates via real history, that becomes a real entry. The shell's popstate listener picks it up:

window.addEventListener('popstate', function () {
    const popLink = _parseDeepLink();
    if (!popLink) return;
    const appId = DEEPLINK_TOOL_ALIAS[popLink.app] || popLink.app;
    const target = APPS.find(a => a.id === appId);
    if (target) openApp(target, { view: popLink.view });
});

Real navigation gets honored. Internal focus changes don't.

The public alias and the inverse map

URLs use public slugs (pdf, tafne, schema). The OS shell uses internal IDs (pdf_processor, tifany, svg_wiring). Two maps bridge them:

const DEEPLINK_TOOL_ALIAS = {
    'pdf':           'pdf_processor',
    'tafne':         'tifany',
    'schema':        'svg_wiring',
};
const DEEPLINK_PUBLIC_SLUG = {
    'pdf_processor': 'pdf',
    'tifany':        'tafne',
    'svg_wiring':    'schema',
};

DEEPLINK_TOOL_ALIAS resolves URL → app on read. DEEPLINK_PUBLIC_SLUG resolves app → URL on write. The alias map also accepts internal IDs so URLs like /app/pdf_processor/editor/ work for forward compatibility.

Pro UI relay over postMessage

Tools used to call window.parent.GxModals.open(...) directly when embedded. Cross-frame property access is fragile (cross-origin throws), couples tool internals to shell internals, and would not survive future CSP frame-src hardening.

Replaced with postMessage. Tool emits:

function openProWaitlist(featureSlug, subtitle) {
    var copy = subtitle || PDF_PRO_COPY[featureSlug] || 'This is a Pro feature.';
    if (window.parent !== window) {
        window.parent.postMessage({
            type: 'gx:pro-gate-click',
            featureSlug: featureSlug,
            subtitle: copy
        }, '*');
    } else if (window.GxModals) {
        window.GxModals.open('pro_waitlist', { featureSlug, subtitle: copy });
    }
}

OS shell receives it in the same generic message listener that already handles gx:open-ipc-panel:

window.addEventListener('message', function (e) {
    if (!e.data) return;
    if (e.data.type === 'gx:pro-gate-click') {
        if (!window.GxModals) return;
        window.GxModals.open('pro_waitlist', {
            featureSlug: e.data.featureSlug,
            subtitle: e.data.subtitle
        });
    }
});

One message contract, one shell-side handler, no cross-frame property access. The modal renders in the OS shell's full viewport, not cramped inside the tool iframe. Future tier-aware variants wire here, not in every tool.

Cloudflare path routing

Path-form deep links need server help. Cloudflare Pages _redirects:

/app/*  /index.html  200

Status 200 not 302. Critical. A redirect would lose the path before JS runs. The 200 rewrite serves the OS shell HTML but preserves window.location.pathname so _parseDeepLink can read it.

Generated from build.sh:

cat > dist/_redirects << 'REDIRECTS_EOF'
/app/*  /index.html  200
REDIRECTS_EOF

What didn't change

Canonical tool URLs work exactly as before. Existing bookmarks to /tools/pdf-processor/ open the standalone app with no shell, no OS chrome, no auth, free tier only. The standalone modal still opens via the local ginexys-modals.js when no parent is detected.

The VSCode extension is completely unaffected. It loads the same canonical index.html into a webview and injects its own __GINEXYS_INITIAL_MODE__. The new ?view= reader checks the global first and falls back to the query, so the extension's path wins when present.

Three surfaces, one contract, no DOM swaps. That's the architecture.

Read this post in the full Engineering Journal →