cj-tauri: A 10MB AI Desktop Client Built on Cangjie + System WebViews Instead of Electron
This article is organized based on the repository
https://atomgit.com/qq8864/cj-tauri(framework version2.0.0, IPC protocol v1) and the source code, README, capabilities, and UI implementation of its exampleexamples/ai-chat. One-sentence summary: The frontend uses any web technology, the backend is an auditable Cangjie static-compiled binary, the output is a few MB, no Chromium bundled, and the same business code can run on Windows / Linux / HarmonyOS PC.
You can develop on Windows, and build once to run on Windows, Linux, and HarmonyOS PC with different builds. No more hunting around on web pages. The package size is 10M, fully replacing the bloated Cherry Studio.
1. What is cj-tauri
1.1 Positioning
cj-tauri is the "Cangjie version of Tauri": it rewrites Tauri's architecture (system WebView + native backend + IPC + capability whitelist) in Huawei Cangjie, and treats HarmonyOS PC (2-in-1 desktop form) as a first-class citizen.
| Dimension | Tauri 2 | cj-tauri |
|---|---|---|
| Backend language | Rust | Cangjie (static compilation, no VM, no GC jitter concerns) |
| WebView | System WebView | Same policy: WebView2 / WebKitGTK 4.1 / HarmonyOS ArkWeb |
| Platforms | Win / macOS / Linux / iOS / Android | Windows / Linux / HarmonyOS PC (macOS not yet) |
| Frontend | Any web stack | Same; create defaults to zero-Node inline HTML, also has Vue/React templates |
| Permission model | capabilities (ACL) | capabilities/*.json: command / event / named permission sets / path scope / outbound host whitelist |
| Output | Single executable + resources | Single executable + resources; Windows also has a portable single-file package (4.69–10.3 MB) |
Project URL: https://atomgit.com/qq8864/cj-tauri
1.2 Layered architecture
Frontend (HTML/CSS/JS) <- window.__CJ_TAURI__.invoke / listen / emit (bridge injected at document-start)
| JSON over postMessage (Linux: WebKitUserContentManager; Windows: chrome.webview.postMessage)
v
IPC message bridge (src/ipc_hub.cj) command registration / dispatch / validation, commands execute asynchronously in worker threads
v
Capability security model (src/capability.cj + src/scope.cj) gates for four entry points: commands, events, paths, outbound network
v
Built-in commands (system:version / ping / echo / devtools) + 14 official plugins
v
WebView host (src/host.cj abstraction + three platform implementations)
|- Linux: webkit2gtk-4.1 + native/platforms/linux/bridge_linux.c
|- Windows: WebView2 + native/platforms/windows/bridge_win.c
|- HarmonyOS PC: ArkWeb (ArkUI component host + arkweb_proxy.cj, no C bridge)
Key design: decision logic lives only in the shared layer (capability.cj / scope.cj / urlutil.cj). The C bridge and platform code are treated as "system boundaries" and do not maintain their own judgments - so the only behavioral differences between Windows and Linux are "conditional compilation + C bridge" in two places.
1.3 Building an app takes three steps
// 1. Implement a command
public class GreetCommand <: CommandHandler {
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
return JsonString("Hello, ${argString(args, "name", "world")}!")
}
}
main(): Int64 {
// 2. Assemble: window + register command; 3. Start (capability list auto-scanned from capabilities/ by run())
let app = TauriApp()
.window(WindowConfig("hello", 1000, 700))
.register("greet", GreetCommand())
app.run(html)
return 0
}
// Frontend: bridge injected by host before page scripts
window.__CJ_TAURI__.invoke('greet', { name: 'Cangjie' }).then(d => console.log(d));
window.__CJ_TAURI__.listen('tick', p => console.log(p));
This corresponds to Tauri's #[tauri::command] / Builder / capabilities; the difference is that three linked steps are all required: implement + register() + whitelist in the commands list - missing registration yields command not registered, missing whitelist yields command not allowed, both printed to stderr.
2. Advantages of cj-tauri (with verifiable evidence)
2.1 Statically compiled backend, no runtime baggage
Cangjie has no VM; the backend is a native binary. The protocol layer is only about two hundred lines and can be audited line by line.
Benefits: fast cold start, flat memory curve (no GC jitter causing frame drops), and - extremely small dependency surface: the PE import table of the single-file executable only contains system DLLs like KERNEL32 / USER32 / WS2_32 / SHELL32 / ole32 / CRYPT32 …. The third-party runtime column is all zeros, and Subsystem = 00000002 (GUI subsystem, no console window on double-click).
2.2 Zero Chromium bundling: a few MB "Electron replacement"
Electron apps typically start at 100 MB; the core cost is bundling the entire Chromium. cj-tauri uses the system WebView:
| Output | Size | Description |
|---|---|---|
serial-waveform.exe |
4.69 MB | Serial port real-time waveform oscilloscope |
cj-link.exe |
5.48 MB | Serial + TCP/UDP all-in-one debugger |
modbus-poll.exe |
5.59 MB | Modbus TCP/RTU debug tool |
markdown-notes.exe |
4.78 MB | Markdown notes (includes marked + highlight.js) |
leetcode-gui.exe |
9.79 MB | Cangjie LeetCode: problem bank + editor + judging terminal |
ai-chat.exe |
10.3 MB | AI chat client (the subject of this article) |
The trade-off is explicit: it depends on the host's WebView version (Windows needs WebView2 Runtime, Win11 has it; Linux needs libwebkit2gtk-4.1), and the shell experience is constrained by system WebView capabilities.
2.3 Capability security model: default deny, four entry points each gated
The threat model is one sentence: the page is untrusted. The entries a page can touch are narrowed to four categories, each with an independent defense line:
| Entry | What a page can construct | Defense |
|---|---|---|
invoke command |
Any command name + any arguments | Command whitelist + command must be registered |
emit event |
Any event name + payload | Event whitelist (Tauri 2 does no ACL for events, here it does) |
| Command path argument | Any path (including ../ traversal) |
Path scope (allow/deny + normalization) |
| Command URL argument | Any URL (SSRF) | Host whitelist scopes.http / scopes.ws, not specified = all denied |
Deliberately stricter points:
- Empty list = all denied, no "allow by default" form;
- Declared set ≠ granted: even if a plugin declares a permission set like
fs:readonly, it only takes effect when the manifest references it; otherwise it stays denied, and it only warns, not silently grants; - Path scope is deny-before-allow:
*does not cross directory separators,**does,?matches a single character; itcanonicalizes before matching (also resolves symlinks); if the path doesn't exist, it lexically folds./..; - Runtime allowlisting is centralized at the registry:
allowScopePatternonly accepts concrete values; wildcards (*,?) or tabs/newlines are rejected on the spot - "a single wildcard pass-through equals full access" is an invariant the app cannot bypass, and allowlisting cannot override a deny; - Outbound network is centrally gated:
httpandwsshare the same host extraction (urlutil.cj), blocking userinfo trickery, non-http(s)/ws(s) protocols, separate IPv6 literal recognition, automatic redirects are disabled, and the response body is capped at 8 MiB.
These are not paper promises - every README claim corresponds to a real test case in src/tests/ (re-runnable with bash scripts/test.sh, currently 300+ all green), e.g. testRegistryEmptyDeniesEverything, testRegisteredPermissionSetNotReferencedStaysDenied, testRuntimeAllowExtendsManifestScope.
2.4 Plugin system: one-line integration, permissions still belong to the manifest
14 official plugins: fs / dialog / shell / serial / store / clipboard / notification / menu / mqtt / redis / log / http / ws / sqlite. Commands auto-register as <plugin>:<short-name>, permissions are triple-gated by the manifest (command-level whitelist + named permission set + path scope), and plugins themselves do not hold grant authority.
Several are "pure Cangjie implementations": mqtt (MQTT protocol stack), redis (RESP2 dual socket), ws (handshake reuses stdx's WebSocket.upgradeFromClient, no hand-written protocol), sqlite (Cangjie FFI directly connects to system libsqlite3, no C bridge). Plugin boundaries are also enforced by static gates: plugin packages cannot reference each other, and the link granularity is plugin package = static library.
2.5 Slow commands don't freeze the window
Commands that pass validation execute asynchronously on worker threads, so slow commands don't block the UI (testDispatchReturnsBeforeSlowCommandFinishes); both rejections and exceptions are reported back to the frontend and logged to stderr, with no "silent failure" channel. Correspondingly, concurrent calls of the same command have no ordering guarantee; the frontend matches by promise id - something to note for people coming from Tauri.
2.6 One business code, multi-platform output
Business code (src/main.cj + assets/) → cj-tauri.toml manifest
→ CLI (create / sync / check / build / dev)
→ targets/desktop (WebKitGTK / WebView2)
→ targets/ohos (ArkWeb, builds hap to install on HarmonyOS PC)
cj-tauri sync ohos derives a skeleton from the manifest; the business package is included as a path dependency, never copied. So examples/ohos/ohos-music and the desktop music app share the same page code; on HarmonyOS PC (2-in-1 desktop window), window sizing is left to the system, and the title bar uses the native system decoration.
2.7 Host capability bits are real capabilities, not placeholders
Menu, tray (Shell_NotifyIconW / libayatana-appindicator3 / X11 grab), global shortcuts (RegisterHotKey / X11 grab), file drag-in, system notifications, in-window embedded views (multiple WebViews in one window), owned windows (not in taskbar or Alt-Tab), minimize to tray - all double-implemented on Windows and Linux. system:host truthfully reports capability bits; capabilities the platform doesn't have (mostly zero on HarmonyOS) return business errors truthfully, not fake success.
2.8 Full-chain verification: every example can self-check unattended
This is the most distinctive engineering habit of cj-tauri: bridge stderr instant evidence + page self-check assertions fed back. Every example has a probe path (CJ_XXX_PROBE=1), and assertion results are fed back line by line to the log.
ai-chat has 73 assertions, locally run 65 detailed PASS, plus four independent probes CJH-GUIDE / CJH-DEPLOY / CJH-PANELS / CJH-CHAT - the deliverable ships with its own acceptance script.
2.9 Scaffolding handles platform pits
create / dev / build / run / info / doctor / sync command set; dev takes over Vite dev server (HMR works inside the window, Ctrl-C kills the process tree), doctor gives line-by-line "what's missing, how to fix" (SDK / stdx / mingw gcc / WebView2 / C bridge). npm distribution cj-tauri (no-install npx cj-tauri create myapp), plus ten statically linked single-file Windows tools distributed per version (no SDK needed, double-click to run).
2.10 Ecosystem and scenarios already running
More than twenty runnable examples, covering business tools (Modbus debugger, MQTT console, Redis console, serial assistant, serial oscilloscope, SQLite browser, HTTP client, Markdown notes, LeetCode GUI), capability demos (IPC performance probe, event coalescing probe, multi-window, plugin four-piece set) and real applications (music player, movie viewer, typing game, cjnote, ai-chat).
3. ai-chat: one window for "AI client + Agent desktop + nine web versions"
3.1 What problem it solves
Daily AI use has three fragmented entry points: the agent in the command line, each vendor's web page, and scripts you write with API keys. ai-chat collects them into the same window, with a switch to decide "who does the work":
- Chat (direct): the page directly calls an OpenAI-compatible
/chat/completions, streaming incremental chunks rendered as Markdown. Model selection really takes effect (whatever you pick is what runs). - Agent (cjh): connects to the local cjh (an AI agent also written in Cangjie). The chat stream goes over a WebSocket long connection; the four panels for sessions / skills / MCP / files go over its REST.
The form factor is restrained: no tray residency, the window is just a normal window, closing it exits. The previous version opened each web vendor in its own independent window, which was rejected by real usage feedback - "switching sites should change content in place, not add another window on the desktop" - so it was changed to same-window tabs (see §3.5).
3.2 Feature list
Shared by both pipelines
- Multi-session + session search (searches both title and body) + inline rename;
- Multiple providers: custom Base URL / API Key / model / system prompt;
- Streaming Markdown rendering (headings / lists / tables / quotes / code block highlighting) + one-click code block copy;
- Light / dark theme, compact mode, collapsible sidebar (
Ctrl+B),Ctrl+Ksearch sessions,Ctrl+Shift+Nnew session; - Auto-height input box, streaming "stick to bottom" (only follows when the user is already at bottom; doesn't drag when scrolling up);
- Log to disk (
setLogToDefaultFile("ai-chat"): C bridge + Cangjie side + pagelog:writeall go to the same file).
Agent (cjh) mode only
- Four panels: Sessions (cjh's own history with titles and timestamps) / Skills / MCP (read-only) / Files;
- Workspace switching ("Browse..." uses the native directory dialog
dialog:pickFolder, can also type an absolute path) + breadcrumbs + file preview; - Tool calls show running / done / error three-state chips;
- Dangerous operation approval: when cjh wants to run a dangerous tool, a pending-approval card pops up (tool name / input / matched rule + countdown synced with
expires_in_ms); "approve / reject" goes back over the same WS asapproval_response; timeout or disconnect always treats as "reject", the turn continues, and the page reports truthfully - it never pretends it can renew; - Model pass-through: each turn's chat frame carries the currently selected
model/base_url/api_key, effective only for that turn, not persisted, not sticky; - Structured termination:
ack/cancelled/error{code,retryable}each have their own landing, not conflated into one "error" message; - When cjh is missing, a walkable onboarding is shown (§3.4).
Web version launcher (nine vendors)
DeepSeek / Kimi / Doubao / Tongyi Qianwen / Zhipu Qingyan / Tencent Yuanbao / Wenxin Yiyan / MiniMax / ChatGPT - clicking a card opens a tab in the current window that loads the site's real address. Log in once and it's remembered (same WebView user data directory); billing uses each account's own quota.
3.3 Project structure
examples/ai-chat/
├── cjpm.toml # depends on framework (path ../..) + link-options for two platforms (GUI subsystem / GTK)
├── capabilities/app.json # capability manifest: 29 commands, 3 events, 4 permission sets, three scopes
├── src/
│ ├── main.cj # 214 lines: assemble window + 7 plugins + register commands + injection endpoint + start/finish
│ ├── cjh_host.cj # 33 KB: cjh lifecycle (health check / reuse / spawn / one-click deploy / exit cleanup)
│ ├── webtab.cj # 381 lines: in-window embedded views for nine web versions (LRU cap 4)
│ ├── allow_host.cj # 10 KB: runtime allowlisting (native confirmation dialog + allowed-hosts.txt persistence and replay)
│ ├── packed_assets.cj # empty stub: packager overwrites with base64 of page / manifest
│ └── packed_resources.cj # decodeBase64 / splitBy / textOrFile / vendorSegments
├── ui/index.html # 3963 lines / 235 KB: page + minimal Markdown renderer + all business logic
├── ui/vendor/ # highlight.js + theme (inlined at build time, not runtime external links)
└── vendor/cjh/ # precompiled cjh v1.5.3 (Linux tgz + Windows zip + SHA256SUMS)
The assembly section of main.cj is almost a self-documenting manifest:
let app = TauriApp()
.window(WindowConfig("ai-chat, made by cj-tauri", 1320, 860))
.plugin(HttpPlugin()) // outbound for direct pipeline (including http:postStream streaming)
.plugin(LogPlugin()) // logs to disk
.plugin(StorePlugin()) // session / provider config persistence
.plugin(ClipboardPlugin()) // code block copy
.plugin(WsPlugin()) // cjh channel: ws:frame returns chat stream
.plugin(DialogPlugin()) // native folder picker for workspace "Browse..."
.plugin(FsPlugin()) // file preview (scope determines what can be read)
.setLogToDefaultFile("ai-chat")
app.register("quit", QuitCommand(app))
app.register("cjh:status", cjhCmds) // three states: already running / has a path / neither
app.register("cjh:start", cjhCmds)
app.register("cjh:deploy", cjhCmds) // one-click deploy (local package verify → unpack → launch)
app.register("app:allowHost", AllowHostCommand(app))
app.register("webtab:list", webTabs) // five commands for web versions
…
3.4 Implementation anatomy #1: cjh lifecycle and "one-click deploy"
src/cjh_host.cj only manages the lifecycle, not the data plane:
GET /api/healthprobe - if already running, reuse it (gCjhOwned=false, don't touch its process on exit);- If not running and
CJ_AICHAT_CJHis set,spawn cjh web --port 8765 --bind 127.0.0.1, then poll health check every 100ms, up to 10 seconds; - On exit, only kill the one we spawned.
--bind 127.0.0.1 is a deliberate tightening: cjh web defaults to 0.0.0.0 with no auth token, so following the default would expose an agent backend that can read/write the workspace, run shell, and send messages to an LLM to the entire LAN.
Onboarding card three states (switch to Agent mode first asks cjh:status):
- Already running → connect directly, don't spawn a new process;
- Has a path (last picked > environment variable) →
cjh:start {path}launches on the spot; - Neither → onboarding card: what cjh is, why install locally, and per current platform the exact artifact name and download page.
Why the "runtime-specified path" route is mandatory: __CJH_API__ is injected only once at document-start; if cjh isn't there at startup, the endpoint is never available (a GUI launched by double-click on Windows has no place to set environment variables). So the install result must be caught by the app itself, not just a "not connected" hint.
One-click deploy (cjh:deploy) carries this to completion:
vendor/cjh/*.zip|.tar.gz
→ SHA256SUMS local verification (if mismatch, don't deploy; no network at all)
→ unpack (Linux: tar -xzf + chmod; Windows: %SystemRoot%\System32\tar.exe absolute path bsdtar)
→ Windows additionally names 4 runtime DLLs to check (libcangjie-runtime / libboundscheck / libssl-3 / libcrypto-3,
they are LoadLibrary'd by name, not in PE import table; missing any one fails immediately)
→ launch (Linux: std.process.launch can capture exit code; Windows: framework launchHidden, DETACHED_PROCESS)
→ /api/health 200 (within 10 seconds)
On failure it says which step it got stuck on: package not found / verification failed / unpack failed / not ready 10 seconds after deploy - the last one also hints "the most common cause is this binary is not compatible with this machine", and names SmartScreen / antivirus / missing VC runtime (Windows) or glibc (Linux), then falls back to manual file selection.
3.5 Implementation anatomy #2: nine web versions - in-window embedded views, not iframes
src/webtab.cj exposes five commands: webtab:list / show / bounds / hide / close, backed by the host's embedded view capability (WebViewHost.createEmbeddedView):
handle = host.createEmbeddedView(site.url, x, y, w, h) // real WebView, not iframe
host.setEmbeddedViewBounds(handle, ...) // re-report rectangle on layout changes (client coords)
host.setEmbeddedViewVisible(handle, true/false) // switching tabs = swapping which one is visible, no reload
host.destroyEmbeddedView(handle) // closing tab = killing the renderer process behind it
Four key decisions:
- Why not iframe: inside an iframe is a third-party context; WebView2 blocks third-party cookies by default, so login state won't persist; many sites send
X-Frame-Optionsfor OAuth redirects, causing a blank screen, and popups are also blocked. The symptom is "page visible, login doesn't pass" - harder to debug than not opening at all. - Why not a new window: separate windows clutter the taskbar and users treat it as a second app. Switching should change content in place.
- Cap of 4 views + LRU eviction: each view has a renderer process; all nine open is ~300–600 MB; when full, evict the least-recently-used one (never evict the one being displayed). The tab bar can have more than 4 items; clicking back rebuilds the view, login state persists.
- Security boundary: external pages get no bridge. Capability checks are global (
CapabilityRegistry.canInvokedoesn't partition by window), so when the platform layer creates the view, it doesn't inject the bridge or plugin shims - external pages gettingwindow.__CJ_TAURI__seeundefined, leaving no path to invoke host commands. Commands only recognize the site id; the page cannot pass a URL, otherwise loading an arbitrary page would become a host capability usable by the page (phishing / intranet scanning).
The site table exists in one place:
let WEB_TAB_SITES: Array<WebTabSite> = [
WebTabSite("deepseek", "DeepSeek", "https://chat.deepseek.com/", "Deep reasoning R1 / V3", "#4d6bfe"),
WebTabSite("chatglm", "Zhipu Qingyan", "https://chatglm.cn/", …),
… // nine total; adding one only requires editing this table; frontend cards are driven by webtab:list, page doesn't change
]
The UI side has a hard constraint: the view is a native host control, it doesn't participate in page layout, so the app must report its rectangle according to its own layout (main window resize / sidebar collapse / DPI changes all require re-reporting; page side throttles to 80ms), otherwise it will cover the sidebar or show white gaps.
3.6 Implementation anatomy #3: streaming and termination for the two chat pipelines
Pipeline A: direct (http:postStream + http:chunk)
// Event listener mounted before invoke (chunks can arrive immediately after the command is sent)
var off = window.__CJ_TAURI__.listen('http:chunk', function (p) {
if (p.done) { return; } // termination uses command resolve to reconcile, avoiding event/resolve race
acc += p.delta || '';
setBody(body, sseToText(acc), true);
});
window.__CJ_TAURI__.invoke('http:postStream', {
url: cfg.base, body: payload,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }
});
Three design points:
- Host only returns bytes; SSE frame parsing happens in the frontend (
data:lines take JSON, concatenatedelta, stop on[DONE]). The host doesn't parse the protocol, so switching to another compatible format only requires changing one function; - Render parsed text, not the raw byte stream:
accis the SSE raw text; rendering it directly flashes protocol messages in the bubble (tested and hit).sseToTextre-parses tens of KB each time; the cost is far lower than streaming latency; - Streaming events use
emitLatest(same-frame slot + rAF batch flush, last value guaranteed), so high-frequency increments don't crush the page; comparison experiment inexamples/ipc-coalesce:emitLatest × 50coalesces into single-digit callbacks.
Three termination items must not be "silent":
- Empty reply ≠ empty: when there's no
data:line at all, the error includes HTTP status and bytes received, and points out "the most common cause: Base URL is not the complete chat/completions endpoint (DeepSeek should behttps://api.deepseek.com/chat/completions, without/v1)"; adds another note if the server returns HTML; - Truncation marker goes into the message: when
stat.truncatedtruncates, write a marker into the message content (still visible after refresh), not just a single DOM line; - Stop generation wording is truthful: since the server has no cancel channel, the text is "this end has stopped receiving, the server may still be generating", and the text received so far is committed as a message with a annotation.
Pipeline B: Agent (ws plugin + cjh)
function agentChatFrame(text, cjhId) { // frame carries the currently selected provider
var f = { type: 'chat', message: text, session_id: cjhId || '' };
var ap = activeProvider();
if (ap) {
if (ap.model) { f.model = ap.model; }
if (ap.baseUrl) { f.base_url = ap.baseUrl; }
if (ap.apiKey) { f.api_key = ap.apiKey; } // different vendors' keys aren't interchangeable; only model+base_url would send the wrong key to another endpoint
}
return f;
}
Why api_key must be included, the comment states clearly: with only model + base_url, cjh would use its own key to hit another endpoint, and the failure shape is a provider error - "the provider selected in the desktop app" effectively doesn't take effect. cjh promises the key is only for this request, not persisted, not printed in logs; the desktop side promises it never enters any log line, guarded by self-check assertion cjh-key-not-logged.
The receive side (handleCjhFrame) dispatches by type: delta incrementally continues the bubble, tool_start / tool_result generate tool chips, approval_request pops the approval card, ack / cancelled / error{code,retryable} each land truthfully.
Both pipelines share the same bubble, Markdown rendering, termination, and archive logic - so switching modes looks like a sidebar change, not a different app.
3.7 Implementation anatomy #4: how security lands at the application layer
capabilities/app.json is this app's "external permission declaration" (excerpt):
{
"identifier": "app",
"windows": ["main"],
"commands": ["http:get", "http:post", "http:postStream", "ws:connect", "ws:send", "ws:close",
"store:get", "store:set", "fs:readText", "dialog:pickFolder", "clipboard:write",
"cjh:status", "cjh:start", "cjh:deploy", "app:allowHost",
"webtab:list", "webtab:show", "webtab:bounds", "webtab:hide", "webtab:close", …],
"events": ["http:chunk", "ws:frame", "ws:state"],
"permissions": ["http:default", "store:default", "log:default", "ws:default"],
"scopes": {
"http": { "allow": ["api.deepseek.com", "api.openai.com", "open.bigmodel.cn",
"dashscope.aliyuncs.com", "api.moonshot.cn", "*.openai.azure.com",
"127.0.0.1", "localhost"] },
"ws": { "allow": ["127.0.0.1", "localhost"] },
"fs": { "allow": ["$HOME/**"],
"deny": ["**/.ssh/**", "**/.aws/**", "**/.git-credentials", "**/id_rsa*", "**/*.pem"] }
}
}
Note that scopes.fs uses "deny before allow": even under $HOME, .ssh, .aws, id_rsa, and *.pem are unreadable - this is ai-chat's own choice (the file panel is only for preview, should not touch keys).
How endpoints outside the manifest get allowed (the most product-hardened part): the manifest is a static file read at startup; the page cannot modify it, so allowing requires a human click. When saving a provider, the app asks once for the hostname of the Base URL (app:allowHost); if it's not on the list, a native confirmation dialog appears:
- Confirm →
TauriApp.allowScopePattern("http", host)adds it to the registry immediately (usable this round), and appends the hostname to~/.local/share/cj-ai-chat/allowed-hosts.txt, replayed beforerun()next startup; - Cancel → nothing changes, request still blocked by scope (log line
scope denied), the page reports truthfully; - When a request is blocked (business error includes
outside the authorized scope), the page pushes the confirmation dialog directly in front of the user.
And the injected page cannot self-allow: the confirmation dialog is native, the page can't click it; the registry only accepts concrete values and rejects wildcards; the deny in the manifest cannot be overridden.
3.8 Implementation anatomy #5: rendering safety and persistence
Model output is untrusted content, and renderMd's order is fixed:
First strip out code blocks and hand them to highlight.js → then escHtml the entire thing → after that, only recognize controlled markers
(inline code / bold / italic / headings / tables / quotes / lists; link href only allows http(s), otherwise only text remains)
The code block copy button puts the source code into data-raw (escaped quotes to prevent attribute escaping); what's copied is not the highlighted HTML. bindCopyButtons must re-run with every innerHTML redraw - otherwise streaming rendering redraws per frame and wipes already-bound onclick, showing as "clicking copy after streaming ends does nothing".
Persistence uses the store plugin (arbitrary JSON, atomic write tmp + rename):
// one entry in store.json: provider config
{ "providers": [{ "name": "DeepSeek", "baseUrl": "https://api.deepseek.com/chat/completions",
"apiKey": "sk-…", "model": "deepseek-chat" }] }
API Key is stored locally in store.json, not committed (.gitignore has it), not in logs. Provider config also has a path to "push to cjh baseline" (syncProviderToCjh → POST /api/settings), triggered by switching to Agent mode / saving provider / switching provider / every time cjh connects; the payload only carries non-empty fields (cjh's baseUrl / model are direct assignments; sending an empty string clears the config), and apiKey is never sent when empty or masked (GET /api/settings returns sk-e****493a style) - writing a masked key back would replace the real key with stars.
3.9 Distribution: one exe, zero accompanying files
bash scripts/pack-win-single.sh examples/ai-chat # → examples/ai-chat/dist-single/ai-chat.exe
The artifact is a single file (measured 10 789 376 bytes, Subsystem = 0x2 GUI, import table only system DLLs). The page, highlight.js with theme, and capabilities/app.json are all base64-encoded into the binary; at runtime it no longer reads ui/ or capabilities/. The three-piece set (packed_assets.cj / packed_resources.cj / ui/vendor/pack-files.txt) is already wired in this example, with the criterion fixed as "base64 non-empty means embedded, otherwise read from disk", so normal builds in the repo behave exactly the same.
Two things you should know beforehand: the single-file package does not include the cjh installer (the 13 MB zip is not embedded), so step 1 of the onboarding card in the single-file artifact truthfully reports "cjh installer not found..." and gives the CJ_AICHAT_CJH_BUNDLE usage; on double-click launch, the C bridge's dup2 log-to-disk doesn't work (stderr handle is invalid in the GUI subsystem, the file is created but zero bytes); for live logs, start with ai-chat.exe 2> ai-chat.log.
3.10 Self-check and acceptance
CJ_AICHAT_PROBE=1 ./target/release/bin/main # in-page self-check, assertions fed back via report to stderr
CJ_AICHAT_PROBE_DEPLOY=1 … # separate round runs the full "one-click deploy" chain (needs to actually install cjh)
- Source has 73 assertions, on a local isolated copy 65 detailed PASS / 0 FAIL - the gap is conditional branches (those requiring "cjh configured / skill configured") not reached, not skipped;
- Four independent probes (not in self-check count):
CJH-GUIDE(onboarding three states, and must not pretend the endpoint is ready),CJH-DEPLOY(deploy ok=true reused=false … api=yes+state=running),CJH-PANELS 4/4,CJH-CHAT deltas=1 done=1 err=0; - The reason probes run in separate rounds is also documented:
CJH-DEPLOYneeds to actually install cjh and start processes, mutually exclusive with the self-check's precondition of "no cjh yet".
4. Getting started
# Method 1: npm scaffolding (no install)
npx cj-tauri create myapp
cd myapp && npx cj-tauri dev # takes over Vite dev server, HMR inside window
# Method 2: run ai-chat directly from this repo (Agent mode needs Cangjie SDK 1.0.5+ and stdx)
cd examples/ai-chat
cjpm build
./target/release/bin/main # working directory must be the example root (capabilities/ and ui/ read as relative paths)
# Agent mode has three ways to connect
export CJ_AICHAT_CJH=/path/to/cjh # specify yourself
# or don't: first switch to Agent shows onboarding card, "one-click deploy and start" unpacks from vendor/ package (local SHA256 check, no network)
# or: if cjh is already running on local 8765 → reuse directly (won't touch it on exit)
# Method 3: Linux directory-style portable package (cjh included inside)
bash ../../scripts/pack-linux.sh && cd dist-linux/ai-chat-linux-x64 && ./run.sh
# Method 4: Windows single-file
bash scripts/pack-win-single.sh examples/ai-chat # → dist-single/ai-chat.exe (10.3 MB)
First thing on a new machine: npx cj-tauri doctor (line-by-line "what's missing, how to fix").
5. Why this combination is worth a look
- "Light" is truly light: 4.69–10.3 MB desktop apps, system-DLL-level dependencies, no console on double-click - a delivery form Electron can never give. Tauri with Rust achieved it; cj-tauri does it again with Cangjie + HarmonyOS as first-class.
- Security is structural, not patched: default deny, events also whitelisted, path scope deny-before-allow, outbound host whitelist "not specified = all denied", runtime allowlisting rejects all wildcards - four entry points, four defense lines, each with re-runnable assertions.
- "Single business code, multiple platforms" lands in engineering:
sync desktop/sync ohosderives skeletons, business package via path dependency, page code identical to desktop version, HarmonyOS PC real-device loop closed (music / movie two examples as evidence). - Deliverable ships with acceptance: every example has an unattended self-check, assertions fed back to stderr, failure shape states "doesn't fake success".
- ai-chat is a credible "template-level" application: it consumes both
httpandwsoutbound plugins, and weaves SSE streaming, long connections, dangerous operation approval, model pass-through, native directory dialog, embedded multi-view, single-file distribution, runtime allowlisting - these "real needs" - into a runnable, self-checkable, mass-producible implementation. It explains the framework's boundaries far better than any Hello World.
If you want to dig deeper, recommended reading order in the repo docs:
docs/使用文档.md (getting started) → docs/安全模型.md + docs/与Tauri的差异.md (mental models) → docs/设计决策记录.md (why it looks like this) → docs/IPC-通信机制.md (protocol and measured performance) → examples/ai-chat/README.md (source of details in this article).