Skip to main content

Desktop Shell

io-oidis-desktop turns an Oidis application into a Windows desktop application: its own window (WebView2), a tray icon with a menu and an optional flyout widget, start at sign-in, a single running instance and a per-user installer that needs no administrator rights.

The shell is one prebuilt binary for every application. It is compiled once by the maintainer of the package and deployed to the Hub; applications download it as a dependency, so packaging an installer needs neither Rust nor NSIS on the build machine.

Two profiles​

Profiledesktop.jsonWhat the shell does
Local backendbackend block, url: "http://localhost:{port}/"Starts the Oidis backend (node.exe from build/target) as a child process tied to the shell, restarts it after a crash, switches its A/B releases for the self-update
Thin clientno backend, url of a remote serverOpens the remote server; its pages may use the window API like local ones

Everything application specific lives in resource/desktop/desktop.json and is read at run time: name, identifier (keys the WebView data, the single-instance lock and autostart, so applications sharing the binary never meet), window size and frame, icon, tray widget, labels and backend. The executable's icon is replaced at packaging by the shell itself (--brand).

Using it in an application​

  1. Dependency from the Hub in package.conf.jsonp:

    "io-oidis-desktop": {
    location: {path: "file+https://hub.oidis.io/Update/io-oidis-desktop/null/app-win-none/2026.0.1"}
    }
  2. The packaging module in resource/configs/modules.register.json. The Builder does not read the registers of dependencies, so the application registers the script by its path:

    {
    "desktop-shell": {
    "script": "dependencies/io-oidis-desktop/resource/scripts/desktop-shell.js",
    "events": {"after": ["build-check:dev", "build-check:eap", "build-check:prod"]}
    }
    }

    With events the installer is packaged after the build of the solution named in installer.solution of desktop.json (desktop by default). A thin client leaves events out and packages with oidis desktop-shell.

  3. resource/desktop/desktop.json and resource/graphics/desktop/icon.ico (graphics folders are outside the copyright check a binary cannot pass). Keys and placeholders are documented in the package README.md and in source/shell/src/config.rs.

The result is build/<Name>_<version>_x64-setup.exe. It installs into %LOCALAPPDATA%\Programs\<Name>, adds Start menu and desktop shortcuts and an uninstaller; the user's data in dataDir survives an uninstall.

Window API in the pages​

The shell injects window.__TAURI__ into its own pages: the local backend's, the thin client's url origin and the origins in remoteUrls. DesktopShellConnector in io-oidis-services (Io/Oidis/Services/Connectors/DesktopShellConnector.js, the Tauri counterpart of WindowHandlerConnector for CEF) wraps it and does nothing in a plain browser, so one page serves both:

MethodPurpose
IsActive()True inside a shell window; show desktop-only controls only then
IsTrayWindow()True inside the tray widget window
Minimize(), ToggleMaximize(), Close()Window buttons of a frameless window; Close() hides to the tray when closeToTray
Hide()Hides the current window, e.g. the tray widget
ShowMain()Brings the main window to the front, e.g. from the tray widget

A frameless window (window.decorations: false) needs the page to draw the title bar: mark the header with data-tauri-drag-region="deep" (the whole header drags the window, its buttons and links still work) and bind the three window buttons. Labs (HeaderPanel, MainPageController.bindWindowControls) and Igor do it this way.

The tray widget (trayWindow.url) stays loaded while hidden; reload its data when it shows again (document.hidden turns false, or WindowManager.getEvents().setOnFocus). It shares the WebView profile with the main window, so a sign-in in the main window applies to it too.

Secrets and the hook bridge​

A thin client has no node.exe, so the shell itself keeps secrets and talks to the server for local tools.

Secrets. DesktopShellConnector.SetSecret(name, value), HasSecret(name) and DeleteSecret(name) store a value in the keychain of the operating system (Windows Credential Manager) through the Rust keyring crate, under the service OidisAgent that KeyringManager of io-oidis-localhost uses, account desktopCredentials:<identifier>.<name>. A page can store, test and delete a secret, never read it back; only the shell reads it.

Hook bridge. With a hook block in desktop.json the executable runs as a bridge for Claude Code hooks:

"hook": {"path": "api/time/hook", "loginPath": "api/login", "events": ["SessionStart", "SessionEnd"]}

<App>.exe --hook [--config <desktop.json>] reads the hook event from stdin, adds the git branch and the subject of the last commit of the session's folder, reads the secret session ({user, token} with a user AuthToken), exchanges it at loginPath for a JWT session and posts the event to path with that JWT as the Bearer. It never fails the Claude Code session: the exit code is always 0 and problems go to {dataDir}/log/hook.log. It runs before the single-instance check, so it works while the application is open. Only the thin-client profile is supported (url must not depend on {port}). Why a user token exchanged for a JWT and not an mcp: token: see Model Context Protocol.

DesktopShellConnector.getClaudeHooksState() and InstallClaudeHooks() register the bridge in ~/.claude/settings.json for the configured events. Other entries stay; the shell's own entries (its executable with --hook) are replaced and the previous file is copied to settings.json.oidis-backup-<timestamp>.

Labs uses it for Labs Time: the desktop app issues a user token "Labs desktop · date" after sign-in (DesktopSession, revocable among the profile's tokens), "Můj den" offers Napojit Claude Code, and TimeManager.TimeHook (POST /api/time/hook) starts the timer of the issue named by the branch or the last commit on SessionStart and stops it when the last session on it ends.

Self-update of a local backend​

A local backend updates itself through SelfUpdater of io-oidis-localhost (Utils/SelfUpdate), the same A/B scheme for a Windows service (WinSW) and for the desktop shell:

<root>\current      junction to the active release
<root>\releases\<id> unpacked packages: the active one and the rollback target
<root>\shared\ configuration, private.conf.json, appData and log
  1. SelfUpdater.ScheduleChecks() asks the Hub (SelfupdatesManager.UpdateExists) every update.checkIntervalHours; SelfUpdater.getInfo() and getState() feed the application's UI, Start() begins the update.
  2. The package <hub>/Update/<project>/<release>/<platform> is unpacked into releases\<id>, the files of update.configFiles are copied from shared\ and the release runs with --update-verify. Override Loader.verifyRelease() to check native modules.
  3. Service installation: a detached --update-activate process stops the service, switches current, starts it and rolls back when it does not come up. Under the shell (OIDIS_SHELL=1) the backend writes shared\activate.json and exits with code 75; the shell switches, starts and rolls back.

Configuration (update block of the application config): enabled, baseUrl (https://hub.oidis.io by default), serviceName (application name by default), checkIntervalHours (6), configFiles (<application>.config.jsonp and private.conf.json).

Building and deploying the shell​

Only for changes of the shell itself, in a clone of io-oidis-desktop, with Rust (rustup) and NSIS 3 (OIDIS_NSIS_HOME, or the copy the Tauri CLI downloads):

oidis build prod
oidis deploy:prod

The module shell-build runs cargo build --release before the build and stages the exe with NSIS in resource/prebuilt; the target resources do not read build_cache. The package is build/target zipped as it is (platform app-win-none, toolchain none).

Stack exception​

Rust/Tauri is an approved exception to the TypeScript stack (see Vanilla vs Framework): a native window, tray and per-user installer cannot be done in Node.js, and a prebuilt binary keeps the cost to the package maintainer. Rust stays in source/shell; packaging is a thin JavaScript Builder module.

Limits​

  • Windows x64 only. macOS needs notarization on a Mac, Linux an AppImage and a tray on GNOME; both are open.
  • The thin client does not update the shell itself yet; a new installer replaces it.