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
| Profile | desktop.json | What the shell does |
|---|---|---|
| Local backend | backend 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 client | no backend, url of a remote server | Opens 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
-
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"}
} -
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
eventsthe installer is packaged after the build of the solution named ininstaller.solutionofdesktop.json(desktopby default). A thin client leaveseventsout and packages withoidis desktop-shell. -
resource/desktop/desktop.jsonandresource/graphics/desktop/icon.ico(graphics folders are outside the copyright check a binary cannot pass). Keys and placeholders are documented in the packageREADME.mdand insource/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:
| Method | Purpose |
|---|---|
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
SelfUpdater.ScheduleChecks()asks the Hub (SelfupdatesManager.UpdateExists) everyupdate.checkIntervalHours;SelfUpdater.getInfo()andgetState()feed the application's UI,Start()begins the update.- The package
<hub>/Update/<project>/<release>/<platform>is unpacked intoreleases\<id>, the files ofupdate.configFilesare copied fromshared\and the release runs with--update-verify. OverrideLoader.verifyRelease()to check native modules. - Service installation: a detached
--update-activateprocess stops the service, switchescurrent, starts it and rolls back when it does not come up. Under the shell (OIDIS_SHELL=1) the backend writesshared\activate.jsonand 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.