desktop-app
Build and maintain the Tauri v2 desktop app. Covers scaffolding, Turborepo wiring, CI build matrix, auto-updater with Ed25519 signature verification, and native OS integrations (menu, tray, notifications, window-state).
- License 许可
- MIT
- 所属仓库
- MUDdown/MUDdown
- 最近更新
- 2026-08-06
- Stars
- ★ 2
- Forks
- 2
01 前言 Front Matter
- name
- desktop-app
- description
- Build and maintain the Tauri v2 desktop app. Covers scaffolding, Turborepo wiring, CI build matrix, auto-updater with Ed25519 signature verification, and native OS integrations (menu, tray, notifications, window-state).
03 SKILL.md 正文
Desktop App Skill
You are working on the MUDdown Tauri v2 desktop app (apps/desktop). This skill covers the scaffolding layout, workspace wiring, CI/CD build matrix, auto-updater signature verification, and native OS integrations.
Scaffold Layout
apps/desktop/
├── src/
│ ├── main.ts # Game client (auth, characters, WebSocket, rendering)
│ ├── index.html # Single-page app shell
│ └── styles.css # Dark terminal theme (CSS custom properties)
├── src-tauri/
│ ├── src/lib.rs # Rust backend (menu, tray, Tauri commands)
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # App config, CSP, updater settings
│ ├── build.rs # Tauri build script
│ └── icons/ # App icons (PNG, ICO, ICNS for each platform)
├── vite.config.ts # Vite bundler (port 1420, strictPort)
├── package.json # @muddown/desktop
├── tsconfig.json # TypeScript (noEmit: true)
└── UPDATER_KEYS.md # Ed25519 key rotation procedure
Turborepo Wiring
The desktop app is a workspace package at apps/desktop. Add it to the root package.json workspaces array if not present.
Workspace Dependencies
In apps/desktop/package.json:
{
"dependencies": {
"@muddown/client": "*",
"@muddown/shared": "*",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0"
}
}
Vite Aliases
In vite.config.ts, resolve workspace packages to their source directories:
resolve: {
alias: {
"@muddown/client": path.resolve(__dirname, "../../packages/client/src"),
"@muddown/shared": path.resolve(__dirname, "../../packages/shared/src"),
},
}
Build Order
Build workspace deps before the Tauri app:
npx turbo run build --filter=@muddown/client... --filter=@muddown/shared...
Rust Backend (src-tauri/src/lib.rs)
Tauri Commands
Expose custom commands to the JS frontend with #[tauri::command]:
| Command | Purpose | Error handling |
|---|---|---|
set_window_title |
Update native title bar | if let Err(e) → eprintln! |
send_notification |
Send OS notification | Best-effort let _ (permission denial expected) |
Register commands in the builder:
.invoke_handler(tauri::generate_handler![set_window_title, send_notification])
Tauri Plugins
Register plugins in tauri::Builder::default():
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_updater::Builder::default().build())
Cargo Dependencies
In apps/desktop/src-tauri/Cargo.toml:
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
tauri-plugin-window-state = "2"
tauri-plugin-updater = "2"
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Error Handling Conventions
- Tauri commands: use
if let Err(e) = ... { eprintln!("[context] message: {e}"); }for recoverable errors. - Menu event emitting: check
Errand log witheprintln!. - Tray show/focus: check
Erronw.show()andw.set_focus()and log. - Notifications:
let _is acceptable (permission denial is expected, not an error). - App startup: use
.unwrap_or_else(|e| panic!("...: {}", e))on.run()to preserve the error message.
Native OS Integrations
Menu Bar
Build menus in the .setup() closure using SubmenuBuilder and MenuItemBuilder:
- File: Connect, Disconnect, Quit
- View: Clear Output (Cmd/Ctrl+L), Focus Input (Cmd/Ctrl+K), Toggle Inventory, Toggle Hints
- Help: Game Commands, About MUDdown
Forward menu actions to JS via handle.emit("menu-action", id). In JS, listen with listen("menu-action", ...).
System Tray
Use TrayIconBuilder with a PNG icon and a context menu:
- Show MUDdown —
w.show()+w.set_focus() - Quit —
handle.exit(0)
Update the tray tooltip from JS to reflect connection status (e.g., "MUDdown — Connected" / "MUDdown — Disconnected").
Notifications
Use tauri-plugin-notification via the Rust NotificationExt trait or the JS @tauri-apps/plugin-notification API. Trigger notifications for:
- Player mentions (another player mentions you in chat)
- Combat events (damage, defeats)
- NPC contact (lifecycle
onContacttriggers)
Window State
The tauri-plugin-window-state plugin automatically persists and restores window size/position. No custom code needed — just register the plugin.
Keyboard Shortcuts
Define accelerators on menu items:
CmdOrCtrl+L→ Clear OutputCmdOrCtrl+K→ Focus Input
Handle via the menu-action event flow (Rust emits → JS listens).
Discord Rich Presence (Opt-In)
The desktop app integrates Discord Rich Presence via direct local IPC to the Discord desktop client. It is off by default and gated behind a per-user preference (discord_rich_presence in prefs.json). No OAuth, no MUDdown-server involvement — the integration uses Discord's "Rich Presence Without Authentication" mode (SetApplicationId + SET_ACTIVITY only).
Crate
# Cargo.toml
discord-rich-presence = "0.2" # vionya/discord-rich-presence, MIT
Rust state pattern (src-tauri/src/discord_presence.rs)
pub struct PresenceState {
pub enabled: bool,
pub client: Option<DiscordIpcClient>,
pub session_start: Option<i64>,
}
pub fn initial_state() -> Mutex<PresenceState> { /* enabled: false */ }
Register in lib.rs:
.manage(discord_presence::initial_state())
.invoke_handler(tauri::generate_handler![
discord_presence_set_enabled,
discord_presence_update,
discord_presence_clear,
/* ... */
])
Command contract
discord_presence_update and discord_presence_clear early-return when state.enabled == false. discord_presence_set_enabled is the toggle itself, so it always runs and is idempotent against the cached enabled value. discord_presence_update lazily calls ensure_connected() — connection failures (Discord not running) are silent no-ops, not errors. On set_activity failure, drop the client so the next call retries a fresh connection. Toggling off via discord_presence_set_enabled(false) calls ClearActivity then closes the IPC client.
JS scheduler pattern (src/discord-presence.ts)
The presence updater runs through createPresenceScheduler({ invoke, debounceMs: 15000, ... }) which exposes { schedule, clear, flushForTesting }. Key invariants:
- Leading-edge fire: First call after
clear()(or first call ever) invokes immediately. Critical: initializelastSentAt = Number.NEGATIVE_INFINITY(not0) so the leading-edge check(now - lastSentAt) >= debounceMsis true att=0. Reset toNEGATIVE_INFINITYinsideclear(). - Trailing flush: Calls inside the 15-second window are coalesced into a single trailing invocation with the latest payload.
- Non-room messages ignored:
parseRoomPresencereturnsnullfor envelopes without:::room+ H1 title; the scheduler drops those silently. - 15s floor: Self-imposed throttle. Discord's published RPC docs recommend ≤1 update per 15s.
Wiring into game flow
In main.ts:
- Call
discord_presence_set_enabledat startup with the persisted pref. - In the room-message handler (
appendMessagewhereclassName === "room"), callpresenceScheduler.schedule(muddown). - On
onDisplacedand ononClose(willReconnect=false), callpresenceScheduler.clear(). - The Settings panel toggle persists the pref and invokes
discord_presence_set_enabledand callspresenceScheduler.clear()when disabling.
What is shared / not shared
Shared: region name, room title, session start timestamp, MUDdown logo + tooltip, two profile buttons. Deliberately not shared: character names, inventory, equipment, combat status, room IDs, account identifiers. Document any change to this list in MUDdown.wiki/Desktop-App.md and apps/website/src/pages/privacy.astro.
Multi-account caveat
Discord exposes IPC slots discord-ipc-0 … discord-ipc-9; first-accept-wins. With multiple Discord accounts logged into the desktop client, presence appears on whichever account opened the slot first. Document, don't fight it.
Application ID
APPLICATION_ID in discord_presence.rs must match a Discord developer-portal application registered to MUDdown. Asset keys (large_image: "muddown-logo") must be uploaded under that app's Rich Presence asset list.
Testing
Tests live in apps/desktop/tests/discord-presence.test.ts (vitest) and apps/desktop/src-tauri/src/discord_presence.rs (#[cfg(test)] mod tests). Run with npm test and cargo test --lib respectively.
JS unit coverage:
parseRoomPresence: room envelope yields{ region, title }; non-room envelopes (:::system, etc.) returnnull; rooms missing the H1 title returnnull; missingregionattribute defaults to"Unknown".createPresenceScheduler: leading-edge fire att=0(relies onlastSentAt = Number.NEGATIVE_INFINITY); trailing-flush coalescing of multipleschedule()calls inside the 15 s window into onediscord_presence_update; non-room envelopes ignored;clear()invokesdiscord_presence_clear, cancels any pending timer, and resets state so the nextschedule()fires immediately again;onError("update", err)andonError("clear", err)paths; failure does not advancelastSentAt(next schedule still leading-edges);flushForTesting()drains a queued trailing flush without advancing the clock.
Use a fake clock (makeFakeClock() in the test file) to drive now, setTimeout, and clearTimeout deterministically. Microtask flushing for rejected invoke mocks needs two await Promise.resolve() hops.
Rust unit coverage (cargo test --lib):
update_is_noop_when_disabled:discord_presence_updatereturns immediately whenenabled == false; no client is constructed.disable_clears_session_start: togglingenabledfromtrue→falseclearssession_startandlast_connect_failure.ensure_connected_respects_cooldown: a stampedlast_connect_failurewithinRECONNECT_COOLDOWN_SECScausesensure_connectedto short-circuit so no IPC connect is attempted.
Manual smoke checklist before shipping any presence change:
- Toggle on with Discord not running → no error log, scheduler still fires
schedule()quietly, presence absent on profile. - Toggle on with Discord running → "Playing MUDdown / Exploring <region> / <Room Title>" appears within ~1 s, elapsed timer counts up, MUDdown logo + tooltip render on the profile expanded view.
- Move between rooms rapidly → at most one update per 15 s window; final room title is what appears after the trailing flush.
- Toggle off → activity disappears from profile within a couple of seconds.
- Quit Discord while presence is live → integration silently no-ops, no retry-spam in the log; reconnect attempts cool down for 30 s.
- Verify the Application ID (
1490036207340748960) and asset key (muddown-logo) match the Discord developer portal — a typo in either field renders presence with a blank logo.
ARIA Accessibility
Apply ARIA roles per the MUDdown spec (§8) when rendering messages:
| Block type | ARIA attributes |
|---|---|
room |
role="main" |
system |
role="alert" |
combat |
role="log" aria-live="polite" |
dialogue |
role="group" aria-label="NPC dialogue" |
CSP Configuration
In tauri.conf.json → app.security.csp:
connect-src: Allow WebSocket to game server (ws://localhost:3300,wss://muddown.com) and HTTPS for OAuthstyle-src:'self' 'unsafe-inline'+ Google Fontsfont-src: Google Fonts CDNimg-src:'self' data:
The unsafe-inline for styles is required by the Tauri webview. This is a desktop app, not a web page, so the risk profile is different from a public website.
CI Build Matrix
The GitHub Actions workflow lives at .github/workflows/desktop-build.yml.
Triggers
- Push to
maintouchingapps/desktop/**,packages/client/**,packages/shared/**, or the workflow file - PRs to
mainwith the same path filters - Manual dispatch (
workflow_dispatch)
Build Matrix
| Platform | Target | Artifact |
|---|---|---|
macos-latest |
aarch64-apple-darwin |
.dmg |
macos-latest |
x86_64-apple-darwin |
.dmg |
ubuntu-22.04 |
x86_64-unknown-linux-gnu |
.AppImage, .deb |
windows-latest |
x86_64-pc-windows-msvc |
.msi |
Key Steps
- Checkout, setup Node 22, install Rust stable with target
- Cache Rust deps with
Swatinem/rust-cache@v2(key by target, workspaces:apps/desktop/src-tauri) - Install Linux system deps (webkit2gtk, appindicator, rsvg, patchelf) — Ubuntu only
npm ci→npx turbo run build --filter=@muddown/client... --filter=@muddown/shared...tauri-apps/tauri-action@v0with signing secrets and Apple notarization secrets- Upload artifacts via
actions/upload-artifact@v4
Code Signing Secrets
| Secret | Platform | Purpose |
|---|---|---|
TAURI_SIGNING_PRIVATE_KEY |
All | Ed25519 key for update signing |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
All | Optional password for the signing key |
APPLE_CERTIFICATE |
macOS | Base64-encoded .p12 Developer ID Application certificate |
APPLE_CERTIFICATE_PASSWORD |
macOS | Password for the .p12 file |
APPLE_SIGNING_IDENTITY |
macOS | e.g. Developer ID Application: Your Name (TEAMID) |
APPLE_ID |
macOS | Apple ID email for notarization submission |
APPLE_PASSWORD |
macOS | App-specific password (not Apple ID password) |
APPLE_TEAM_ID |
macOS | 10-character Apple Developer Team ID |
Apple Notarization (macOS)
Apple notarization is required for macOS distribution outside the App Store. Without it, Gatekeeper blocks the app. When the Apple secrets above are configured, tauri-apps/tauri-action automatically handles code signing, notarization submission, and stapling.
Entitlements
The entitlements plist at src-tauri/Entitlements.plist grants hardened-runtime permissions required by the WebView:
com.apple.security.cs.allow-jit— WebKit JITcom.apple.security.cs.allow-unsigned-executable-memory— JavaScriptCore executable memory allocation (required alongside allow-jit under the hardened runtime)
Note:
com.apple.security.network.clientis an App Sandbox entitlement, not a hardened-runtime entitlement. It is unnecessary for Developer ID distribution since the hardened runtime does not restrict outbound networking.
Bundle Configuration
In tauri.conf.json → bundle.macOS:
minimumSystemVersion:"10.13"— macOS High Sierra minimumentitlements:"Entitlements.plist"— path relative tosrc-tauri/dmg— DMG window layout (app icon position, Applications folder shortcut)
CI Verification
The CI pipeline verifies notarization via xcrun stapler validate on the .dmg. This step runs only on macOS runners and only when Apple secrets are configured. Full setup instructions are in apps/desktop/UPDATER_KEYS.md.
Auto-Updater & Signature Verification
Configuration (tauri.conf.json)
{
"plugins": {
"updater": {
"active": true,
"pubkey": "<Ed25519 public key>",
"endpoints": [
"https://github.com/MUDdown/MUDdown/releases/latest/download/latest.json"
]
}
}
}
- The endpoint points to the
latest.jsonmanifest published with each GitHub Release.
Runtime Validation Points
- Rust layer (
tauri-plugin-updater): Automatically validates every downloaded artifact againstpubkeybefore applying. Built into the plugin — no custom Rust code needed. - JS layer (
@tauri-apps/plugin-updater): Thecheck()anddownloadAndInstall()APIs surface the plugin's result. Use them to show update prompts and handle validation failures.
If a signature does not match (tampering, forged release, key mismatch), the update is rejected and the app stays on its current version.
Key Generation
npx @tauri-apps/cli signer generate -w ~/.tauri/muddown.key
Produces:
~/.tauri/muddown.key— private key (store asTAURI_SIGNING_PRIVATE_KEYsecret)~/.tauri/muddown.key.pub— public key (put intauri.conf.json→plugins.updater.pubkey)
Key Rotation Procedure
Full procedure in apps/desktop/UPDATER_KEYS.md:
- Generate a new Ed25519 key pair.
- Update
tauri.conf.json→plugins.updater.pubkey. - Update
TAURI_SIGNING_PRIVATE_KEYGitHub Actions secret. - Update
TAURI_SIGNING_PRIVATE_KEY_PASSWORDif password-protected. - Create a new signed release. Clients running the new version will reject old-key artifacts.
Note: The CI signature verification step reads the public key dynamically from
tauri.conf.json, so no workflow file update is needed during rotation.
Integration Test Plan
The CI workflow includes a signature verification test (apps/desktop/tests/verify-signature.sh) that:
- Locates all
.sigfiles produced bytauri build. - Verifies each signature against the project's public key using
minisign. - Tampers with each artifact and verifies the updater rejects the invalid signature.
The test runs on macOS and Linux CI targets after the build step. It is skipped when TAURI_SIGNING_PRIVATE_KEY is not configured (e.g., on forks).
Icons
- Must be 8-bit/channel RGBA PNG (Tauri crashes on 16-bit/channel).
- Convert with ImageMagick if needed:
magick icon.png -depth 8 icon.png - Required formats:
icon.png(512×512),icon.ico(Windows),icon.icns(macOS) - Store in
apps/desktop/src-tauri/icons/
Acceptance Criteria
Before considering desktop app work complete:
- [ ]
npm run devinapps/desktoplaunches the Tauri dev window - [ ]
npm run tauri buildproduces platform-appropriate artifacts - [ ] TypeScript compiles clean:
cd apps/desktop && npx tsc --noEmit - [ ] All workspace tests pass:
npx turbo run test - [ ] ARIA roles match the spec (room=main, system=alert, combat=log, dialogue=group)
- [ ] Menu bar actions work (File, View, Help submenus)
- [ ] System tray shows/hides the window
- [ ] Keyboard shortcuts (Cmd/Ctrl+L, Cmd/Ctrl+K) work
- [ ] Window position persists across restarts
- [ ] Notifications fire for mentions, combat, NPC contact
- [ ] CSP allows WebSocket to game server and OAuth endpoints
- [ ] CI workflow builds all 4 targets without errors
- [ ]
tauri.conf.jsonupdater haspubkeypopulated (when active) - [ ] CI signature verification test passes (valid accepted, tampered rejected)
- [ ]
UPDATER_KEYS.mddocuments key rotation
Traceability
This skill covers the Phase 5 Tauri desktop app checklist in PROJECT_PLAN.md (lines ~225–243). See that document for the full roadmap context and remaining items.
04 同仓库更多 Skills
testing
Write and maintain tests for MUDdown packages. Covers vitest for the server and parser, fixture helpers, world integrity tests, and testing conventions.
room-creation
Create and modify MUDdown room files. Covers the Markdown+YAML structure, frontmatter fields, container blocks, bidirectional exits, item/NPC references, lighting, and region organization.
privacy
Audit and maintain privacy compliance for MUDdown. Covers the privacy policy, data collection practices, cookie/storage usage, and compliance testing.
persistence
Work with the database abstraction layer and account/character/world persistence. Covers the GameDatabase interface, SQLite adapter, save/load lifecycle, auth sessions, and testing patterns.
osc8-bridge
Add or modify OSC 8 hyperlink capabilities in the telnet bridge — NEW-ENVIRON (RFC 1572) negotiation, Mudlet/Fado/MudForge `send:` URIs, and per-scheme tooltip/menu metadata. Covers the capability pipeline from telnet negotiation through renderer output, the sanitization contract, and the word-wrap invariant.
oauth-provider
Add a new OAuth/OIDC identity provider to MUDdown. Covers the shared type, auth switch statements, server config, login page button, env vars, tests, and local verification.