Status: Proof-of-concept / proposal — not a merged plan. Prepared for discussion.
Scope: crates/client-web (the web UI). No backend changes are required or proposed.
Worktree: poc/svelte-rewrite (companion PoC in crates/client-web-svelte/).
- Rewrite
client-webfrom vanilla TypeScript into Svelte 5, shipped as a static SPA (SvelteKit withadapter-static, SSR disabled). The rewrite is well-supported, modest effort, and the single biggest win is deleting the hand-rolleddomPatcher.ts(~290 lines) and most ofeventBindings.ts(~1,395 lines) — Svelte's reactivity replaces them natively. - A Tauri desktop shell is viable and lower-risk than typical because Koko's server already builds on
tao+tray-icon+keyring— the exact crates Tauri is made of. Tauri is an optional follow-on, not a prerequisite. The recommended architecture keeps the HTTP-served SPA for remote clients (phones, tablets, LAN TVs) and adds a Tauri shell that loads the same frontend for desktop. - The real risk is
playbackController.ts(video/audio/YouTube trailers), not the framework swap. That file needs careful, incremental porting. The logs-view PoC in this worktree de-risks everything except playback. - Recommendation: pursue the Svelte rewrite incrementally (view-by-view, keeping the vanilla client buildable in parallel), and treat the Tauri shell as a separate, later decision once the rewrite has shipped.
The current client is a vanilla TypeScript SPA (~11,750 lines across src/app/) bundled with Vite. It has exactly one runtime dependency (lucide). It is genuinely lean, and that's worth preserving.
However, it implements its own UI framework by hand:
domPatcher.ts— a custom DOM reconciler with keyed reordering, focus/scroll/selection snapshot+restore, form-control syncing, and a special-case guard so<video>/<audio>elements don't reload mid-render.eventBindings.ts— 1,395 lines that wires up every DOM event by hand, including a temporary monkeypatch ofEventTarget.prototype.addEventListenerto auto-scope listeners to a per-renderAbortController.app.ts— an 865-line orchestrator that does full-app string-template re-renders on essentially every state change, with hand-rolled snapshot-diffing to suppress redundant renders.
This is a lot of bespoke machinery solving problems that a reactive framework handles natively. It's not broken — but it's a long-term maintenance liability: every new feature has to thread through the render/event-binding lifecycle correctly, and the patcher's quirks (focus preservation, media-element src stability) are the kind of thing that breaks silently.
A rewrite to Svelte 5 replaces ~1,700 lines of that glue (domPatcher + most of eventBindings) with idiomatic, framework-maintained equivalents, while keeping the bundle small (Svelte compiles away; the PoC's whole app is 132 KB of static output).
A minimal Svelte 5 port of the Settings → Logs view lives in crates/client-web-svelte/. It was chosen because it's small, self-contained, and exercises the three architectural seams that matter most: state, rendering, and events — without touching playback.
Validated by the PoC:
| Claim | Evidence |
|---|---|
| Static SPA build the Rust server can serve | npm run build → dist/index.html + dist/_app/ (132 KB total), using adapter-static with SPA fallback |
Routing parity for /settings/logs |
File-based route src/routes/settings/logs/+page.svelte; / also resolves |
| Data layer ports verbatim | getLogs + LogEntriesResponse/LogEntry types + VITE_USE_MOCK_API toggle copied from the vanilla client unchanged; mock mode active |
| Dev workflow carries over | npm run dev:mock (= vite dev --mode mock) serves at http://127.0.0.1:4173/ with mock data, identical to the vanilla client's fixed workflow |
domPatcher/eventBindings disappear |
The logs view's filter form + table + refresh/clear handlers are one ~150-line .svelte file with local $state runes — no patcher, no AbortController, no FormData-rehydration |
| The Message-column fix ports for free | .log-message-col { min-width: 70ch } is carried into the PoC's CSS unchanged |
How to run it:
cd crates/client-web-svelte
npm install
npm run dev:mock # http://127.0.0.1:4173/ → Settings → LogsWhat the PoC deliberately does NOT cover: playbackController.ts, youtube.ts, the home shelves' lazy-loading/virtualization, itemPersonView, and the auto-refresh polling in app.ts. Those are the real work of a full migration (see §4).
The most important discovery from the research: Koko's server already uses Tauri's own lower-level crates.
Koko today (crates/server) |
What it is | Tauri equivalent |
|---|---|---|
tao (event loop / windowing) |
The Tauri team's windowing library | Tauri's windowing layer |
tray-icon |
The Tauri team's standalone tray library | tauri::tray::TrayIconBuilder |
keyring / keyring-core (in secrets.rs) |
OS keychain access | What Tauri's recommended keychain plugins wrap |
crates/server/src/tray.rs builds the tray menu (Open / Donate / Quick Options / API Docs / About / Quit) directly on tao's EventLoop. Tauri = tao + wry (webview) + a command/IPC/plugin layer on top. Koko is already ~80% of the way to Tauri at the crate level — it's just missing wry and Tauri's command framework.
- Tray migration is mechanical.
tray.rsports ~1:1 ontoTrayIconBuilderbecause it's the same upstream crate. Lowest-risk part. - Don't adopt a Tauri secret plugin. Koko's
secrets.rsalready does OS-keychain access viakeyring, which is strictly better thantauri-plugin-stronghold(no master password, OS-managed) and avoids a component the Tauri team has flagged for deprecation. Keepsecrets.rsas-is. - Tauri does NOT bundle Chromium. It uses the OS native WebView: WKWebView (macOS), WebView2 (Windows), WebKitGTK (Linux). So "rendering on Tauri" just means "the web client in a native window" — your browser baseline carries over.
- In-process (embed the server as a crate). Koko's server already exposes a
lib.rs, so Tauri's.setup()hook can spawn it (axum/tokio task) in the same process. Zero IPC for server logic; frontend still hitslocalhostover HTTP, or migrates gradually to Tauriinvoke. Cleanest end-state; the footgun is co-hosting the HTTP server and Tauri on one tokio runtime (documented, solvable). - Sidecar (bundle the standalone
kokobinary). Tauri spawns it as an external process. Preserves "server runs standalone for headless/remote use." More moving parts (lifecycle, port discovery, two binaries to sign). - Hybrid (HTTP only). Tauri is purely a window shell that loads the remote/local server URL. Smallest change; loses the "single binary" story.
Recommended: treat the process-model choice as a separate decision after the Svelte rewrite has shipped. Options 1 and 2 are both viable; the right answer depends on packaging preferences, not on the frontend framework.
On Windows and macOS, the native-WebView floor is effectively "recent Chromium / recent Safari" — nearly free. On Linux, Tauri uses the distro's webkit2gtk-4.1, which on stable/enterprise distros can be years behind and has known rendering bugs (glitchy maximize, font-weight, NVIDIA/Arch blank windows). Mitigation: target Linux via Flatpak, which bundles a known webkit2gtk — Koko already ships a Flatpak, so this fits.
From a full read of every module in src/app/. Tiers: T trivial, M moderate, H hard.
api.ts(1,285) +mockApi.ts(1,590) — copy verbatim. Framework-free data layer. The PoC proves this.format.ts,constants.ts,providers.ts,mediaExtras.ts,playbackProgress.ts,mediaTargets.ts,activities.ts,selectors.ts(357) — pure functions overstate; become$derived/ helpers.types.ts— keep as-is.
routes.ts(63) → SvelteKit file routes. URL shapes must be preserved exactly.state.ts+ everyrender()call site →$staterunes / stores. Conceptually easy; the work is finding every call site.settingsView.ts(831),auth.ts(168),ui.ts(162),dashboardView.ts(332 — logs lives here),homeView.ts(1,014),input.ts(80),formUtils.ts.
domPatcher.ts(290) — good-news-H. The most novel code in the codebase. Deleted entirely in Svelte; Svelte's reactivity +{#each key}replace it. Risk is verifying nothing depends on its quirks beyond what Svelte covers — specifically the<video>/<audio>src-stability guard and the lazy-shelfbeforePatchhook.eventBindings.ts(1,395) — H for effort, not novelty. ~90% becomes inline Svelte handlers; theaddEventListenermonkeypatch and theAppEventBindingContextseam disappear. Risk: subtle behaviors (trailer long-press chooser, escalating seek, deferred renders) live here.playbackController.ts(1,385) — genuinely hard. THE headline risk. Owns the<video>/<audio>player + two YouTube iframe players, imperatively: module-level mutable refs, escalating-seek handlers, 500ms progress polling, fullscreen/PiP, audio-track switching that triggers remux viarender(false), autoplay-block handling. Porting means a<VideoPlayer>/<TrailerPlayer>component withonMount/onDestroylifecycle and$statefor play state. This is the single biggest chunk of effort and the most likely to harbor bugs.youtube.ts(115) — H-ish, small but exotic. The URL→videoId parser ports as-is; the YouTube IFrame API loader (onYouTubeIframeAPIReadyglobal + script injection) needs a Svelte-friendly singleton wrapper.
app.ts(865) — the migration spine.startApp→+layout.svelte;render()→ component reactivity;refreshData/refreshPending*→loadfunctions + polling stores;navigateTo→goto(). Rewritten last.
Svelte 5 requires ES Proxies and modern browser APIs. Per the official Svelte browser-support table, minimums are roughly Chrome/Edge 91+, Firefox 90+, Safari 14.1+. Vite/esbuild transpiles output to your browserslist target, so the practical floor is the Proxy requirement (Safari 12-ish if you really stretch it), not syntax.
For a media-server web UI in 2026, this is a non-issue for browser users. The only genuine risk is very old Smart TV / embedded WebView engines — worth checking actual user-agent stats before committing. (And if Koko ever wraps in Tauri, the WebKitGTK-on-stable-Linux concern in §3 applies.)
Net: no realistic browser-users lost.
┌─────────────────────────────────┐
│ Rust server (crates/server) │
│ serves static SPA over HTTP │
└──────────────┬──────────────────┘
│
┌────────────────────┼─────────────────────┐
│ │ │
Remote browsers Tauri desktop shell (headless /
(phones, tablets, loads same dist/ remote API clients
LAN TVs) + tray/window/IPC)
- One frontend codebase, built once to static
dist/. - The Rust server serves it over HTTP (status quo) for all remote clients — the load-bearing property for a media server.
- Optionally, a Tauri app embeds the same
dist/for a native desktop experience (tray, single window, no port UX). This is an additive, later decision. - Svelte 5 with SSR disabled (the PoC's
+layout.ts). SvelteKit's SSR/load-functions/endpoints add no value when there's no Node runtime — only inside Tauri or served statically.
A real fork in the proposal:
- SvelteKit +
adapter-static— file-based routing,loadfunctions, first-class Tauri template. Imposes its conventions (+page.svelte,+layout.ts). The PoC uses this. - Plain Svelte 5 + a tiny router — closer 1:1 port of Koko's existing
routes.ts; less to learn; but you hand-roll routing and miss SvelteKit's dev ergonomics.
Recommendation: SvelteKit in SPA-only mode. The conventions are mild, the Tauri story is first-class, and goto()/$page replace Koko's hand-rolled navigateTo/parseRoute. Disable SSR globally (one line, already in the PoC).
Rough sizing: weeks-to-a-few-months for one focused dev, dominated by the view modules and playbackController.ts — not a multi-quarter effort. The data layer, types, and pure helpers are essentially free (copy + $derived).
Recommended sequence (incremental, vanilla client stays buildable in parallel):
- Scaffold — SvelteKit SPA alongside
client-web(this PoC). Wire CI to build both. - Data + types — copy
api.ts,mockApi.ts,types.ts, pure helpers. (PoC-level: done for logs.) - Auth shell + routing —
+layout.svelte, login/welcome, route parity for allroutes.tsshapes. - Settings views (logs ✓, dashboard, providers, libraries, scheduled) — lowest-risk real views.
- Home + item/person views — shelves, lazy-loading, metadata search. Re-implement lazy-shelf virtualization.
- Playback —
playbackController.ts+youtube.tslast, as dedicated<VideoPlayer>/<TrailerPlayer>components. Highest risk; budget the most time and keep the vanilla player available as a fallback during this phase. - Delete the vanilla client once parity is verified.
Tauri is a separate workstream after step 6, gated on its own decision.
- Agree to pursue the Svelte 5 rewrite incrementally (vanilla client kept buildable in parallel)?
- SvelteKit SPA vs plain Svelte + router — preference? (Recommendation: SvelteKit SPA.)
- Should a
playbackController.tsspike happen early to de-risk the headline item before committing to the full sequence? - Tauri — defer to a post-rewrite decision, or explore in parallel? (Recommendation: defer; the rewrite unblocks it but doesn't require it.)
- Linux WebKitGTK strategy — confirm Flatpak is the supported Linux desktop distribution (it already exists), so a future Tauri shell has a controlled WebView version.
Svelte / browser support:
Tauri (official):
- Webview Versions — Tauri v2
- Tauri 2.0 release blog
- SvelteKit frontend guide — Tauri v2
- Calling Rust from the Frontend — Tauri v2
- System Tray — Tauri v2
- Embedding External Binaries (sidecar) — Tauri v2
- Stronghold plugin — Tauri v2
Tauri (issues / community):
- Stronghold deprecation discussion #7846
- Tauri v2 constrained Linux compatibility #9039
- Glitchy rendering on Linux #13157
- Bundle chromium renderer request #14963
- SvelteKit scaling discussion sveltejs/kit #13455
- Datawrapper: migrating to SvelteKit
- Firezone: using Tauri (AppImage bundles webkit)
Repo evidence (read directly):
crates/server/Cargo.toml—tray/native-secret-storefeatures;tao,tray-icon,keyring,keyring-coredepscrates/server/src/tray.rs— tao + tray-icon event loopcrates/server/src/secrets.rs— keyring-based OS keychaincrates/client-web/src/app/domPatcher.ts— custom reconciler (290 lines)crates/client-web/src/app/eventBindings.ts— manual event wiring (1,395 lines)crates/client-web/src/app.ts— render orchestrator (865 lines)crates/client-web/src/app/dashboardView.ts:260-332—renderLogViewer()(PoC source)crates/client-web/src/api.ts:1193-1223—getLogs();:581-593—LogEntry/LogEntriesResponsecrates/client-web/src/mockApi.ts:1046-1097—getMockLogs()(PoC mock data)