Building a desktop frontend the NetScript way
Goal: compose one Fresh frontend that runs as an ordinary browser/Aspire app and gains native window chrome, tray menus, dialogs, notifications, typed RPC, and update-ready UX when hosted by Deno Desktop.
The boundary is capability-based. Your route tree stays a normal Fresh 2 application. The native
host owns window creation and policy; @netscript/fresh/desktop binds your existing oRPC router to
that window, @netscript/sdk/desktop creates the typed webview client, and copied
@netscript/fresh-ui components render the controls. None of those layers exits the process, starts
an updater, or invents browser fallbacks.
1. Install the desktop UI collection
From the workspace root, copy the app-owned desktop controls into the dashboard:
netscript ui:add desktop --app dashboard
The collection installs tray/menu, dialog, notification, window-chrome, update-prompt, and
DesktopOnly sources plus their token-driven CSS. They are copied code: import components from your
app barrel, not from the registry package.
2. Bind your existing router in the native host
Create the Deno Desktop window explicitly, then bind the same oRPC router and context you use for
other transports. The Fresh adapter accepts a narrow window shape and returns bound | disabled, so
cleanup is unconditional:
import { bindDesktopRpcWindow } from '@netscript/fresh/desktop';
import { appRouter } from './rpc/router.ts';
const window = new BrowserWindow({ title: 'NetScript Studio' });
const rpc = bindDesktopRpcWindow({
window,
router: appRouter,
context: { windowId: 'main' },
});
addEventListener('unload', () => void rpc.close());
Keep window creation, exit-on-last-window, updater scheduling, and application shutdown in this composition root. Reusable UI must not decide those policies.
3. Call the contract from the webview
Use the existing service contract for inference. No ambient bindings.d.ts, hand-written JSON
protocol, or duplicate RPC contract is needed:
import { createDesktopServiceClient } from '@netscript/sdk/desktop';
import { appContract } from '../contracts/app.ts';
export const desktopApi = createDesktopServiceClient({ contract: appContract });
const session = await desktopApi.sessions.get({ id: 'session-42' });
Outside Deno Desktop, construct this client only behind the same desktop gate. Ordinary browser and Aspire data paths continue to use the normal SDK transport.
4. Wire native chrome without hiding policy
createDesktopChrome owns the mechanical adapter: declarative menu translation, stable action-ID
dispatch, feature-gated dialogs and notifications, documented window operations, replacement, and
cleanup. Your host still decides what an action means:
import { createDesktopChrome } from '@netscript/fresh-ui/desktop';
const chrome = createDesktopChrome({
window,
tray: {
tooltip: 'NetScript Studio',
menu: [
{ type: 'action', id: 'show-window', label: 'Show window' },
{ type: 'separator' },
{ type: 'role', role: 'quit' },
],
},
onAction(event) {
if (event.actionId === 'show-window') chrome.show();
},
});
Only documented operations are exposed: title, show, hide, focus, reload, and close. There are no
fake minimize/maximize controls, and dispose() releases listeners and tray state without exiting
the application.
5. Render desktop-gated, app-owned controls
The copied controls emit intents. Connect those intents to an active lifecycle in a small island; keep ordinary page content outside the gate:
import DesktopOnly from '@app/islands/DesktopOnly.tsx';
import { DesktopWindowChrome } from '@app/components/ui/mod.ts';
export function NativeChrome() {
return (
<DesktopOnly>
<DesktopWindowChrome
title='NetScript Studio'
actions={['focus', 'reload', 'hide', 'close']}
onAction={(action) => chrome.performWindowAction(action)}
/>
</DesktopOnly>
);
}
Use DesktopTrayMenu, DesktopDialog, and DesktopNotification the same way: their callbacks
carry declarations or requests to the host controller, while server render remains side-effect free.
6. Render update-ready events exhaustively
Pass the ready event from @netscript/sdk/auto-update directly to DesktopUpdatePrompt. Automatic
updates say “Update ready — restart to apply”. The manual branch uses the event's verified
manualUpdateUrl for the Windows installer; do not infer the branch from platform strings.
<DesktopUpdatePrompt event={readyEvent} onRestart={requestApplicationRestart} />;
Verify both modes
- Run the Fresh app in a browser or under Aspire and open
/design/components. Desktop controls render as previews;DesktopOnlyemits no native-only content and the console stays clean. - Test action IDs and controller results with injected structural capabilities—never ambient global declarations.
- Run native packaging and smoke through the desktop smoke workflow. Browser gallery proof is not a substitute for native window, tray, dialog, or notification validation.