Files
sure/desktop/README.md
Max Barbare 700ad34eb1 feat: Introduce macOS app v0.1.0 (#2762)
* feat(desktop): scaffold Tauri 2 macOS shell with empty window

* feat(desktop): server store, URL normalization, and health-check helpers

* feat(desktop): IPC commands for server list/add/remove/health + active-server state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): native onboarding server picker with health check and remembered servers

* feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights

* feat(desktop): native menu bar with standard shortcuts and menu events

* feat(desktop): webview→Rust bridge with native notifications

* fix(desktop): gate bridge injection on PageLoadEvent::Finished

Prevents double-injecting the bridge IIFE (once on Started, once on
Finished), which was duplicating every native notification.

* feat(desktop): Dock badge driven by webview attention count

* feat(desktop): launch-at-login autostart commands

* feat(desktop): sure:// deep link scheme with parse tests and navigation

* feat(desktop): preferences window with server switcher and launch-at-login

* docs(desktop): README for dev, release, signing/notarization, and deferred widget

* fix(desktop): remove dead New Window menu item

* fix(desktop): correct login route to /sessions/new

Rails uses `resources :sessions` (plural), so the login page is
/sessions/new, not the /session/new the plan assumed. Fixes an
immediate 404 when connecting to a server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset

- Restyle onboarding + prefs to match Sure's auth page: solid surface
  background, centered logomark, .form-field-style inputs, inverse primary
  button; theme-aware via prefers-color-scheme (design-system tokens).
- Add a draggable titlebar strip on bundled pages and inject one into the
  remote page so the window drags from the top everywhere.
- Inject a top offset on the logged-in app-layout root so the sidebar logo
  clears the macOS traffic lights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): de-dupe login navigation to prevent CSRF token/session race

connect() navigated to /sessions/new directly AND via the
active-server-changed event, which is also handled by a second listener
injected into the page by bridge.js. One connect fired multiple concurrent
GET /sessions/new requests, each minting a fresh session + CSRF token; the
form shown and the _sure_session finally stored could come from different
GETs, so the login POST failed 'Can't verify CSRF token authenticity'
intermittently. Route all navigation through a single window-level guard so
only the first request per server wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): enable window drag permission; offset only the icon rail

- Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to
  capabilities so data-tauri-drag-region actually drags the window on macOS.
- Offset only the 84px left icon rail (logomark) to clear the traffic lights
  instead of pushing the entire app-layout down; keep main content full-height.
- Drag strip z-index lowered below Sure's sticky headers so its controls stay
  clickable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): persist active server and resume session on launch

- Persist the active server to the Keychain in set_active_server; active_server
  falls back to it so a relaunch knows where to go.
- On launch, auto-resume straight to the last server instead of showing the
  picker every time.
- Navigate to the server root (not /sessions/new): Rails serves the dashboard
  when the session cookie is still valid, or redirects to login when not — so a
  persisted session no longer forces a re-login.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat: desktop SSO via system browser with PKCE code exchange

Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in
the system browser and hands a session back to the app securely.

Server (Rails):
- GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to
  OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser).
- openid_connect — for a linked identity in a desktop flow, mints a single-use,
  2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=...
  (unlinked identities are sent back with an error).
- GET /sessions/desktop_exchange — verifies the code + PKCE verifier
  (secure_compare), single-use (cache delete), then create_session_for; MFA is
  enforced at exchange time. Sets the normal web session cookie in the webview.
- Tests: happy path + single-use, wrong-verifier rejection, missing challenge.

Desktop (Tauri):
- start_sso command: generates PKCE, opens the browser, stores the verifier.
- sure://sso/callback deep link -> webview navigates to desktop_exchange with
  the verifier (never sent through the deep link, so an intercepted code is
  useless).
- bridge.ts intercepts SSO provider form submits and routes them to start_sso;
  password login stays in the webview.
- remote.json capability: minimal IPC (drag, event bridge, prefs window,
  start_sso) for the remote Sure origin — no fs/shell/http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback

Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in
page: the remote-bridge capability's remote.urls ('https://*') did not match
the server origin, so all IPC (event listen, invoke, drag command) was denied.
Per Tauri v2, window.__TAURI__ is injected on remote pages only with
withGlobalTauri (set) AND a matching remote.urls; patterns need a path
wildcard.

- remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server
  origin matches.
- menu.rs: Preferences and Switch Server now show the prefs window directly in
  Rust (no dependency on remote-page IPC); Switch Server moved from Window to
  the App menu.
- bridge.ts: drops the menu-event listeners (Rust owns them), adds a
  startDragging mousedown fallback for the drag strip, logs diagnostics, and
  reports start_sso success/failure to the console.
- main.ts: drops the now-unused menu listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store

Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a
remote origin cannot invoke custom commands ('start_sso not allowed. Plugin
not found'). Events are permitted, so SSO now goes through an event.

- SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso
  (opens the system browser). start_sso command kept for local use.
- servers: mirror the server list + active server to a JSON file in
  Application Support as a fallback — Keychain items don't persist for
  unsigned builds, which was wiping the saved server on relaunch.
- remote.json: add notification:default (Sure's PWA was requesting it and
  erroring).
- menu: log whether the prefs window is present when Preferences/Switch
  Server fire, to diagnose the no-op.
- main: log the persisted active server on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip

The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only
on pages without a top header. Replace it with a document-level mousedown in the
top ~34px that starts a window drag unless the target is an interactive element
— so dragging works on all pages, Sure's titlebar controls stay clickable, and
main content isn't pushed down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg

- .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the
  universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and
  publish it to a GitHub Release with unsigned-install instructions.
- README: universal build command, the tag-based release process, and the
  Gatekeeper 'Open Anyway' / xattr steps for end users.
- Drop the unused iOS/Android icon sets (macOS build only needs icon.icns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags

Replaces the manual desktop-v* tag release with a path-filtered workflow that
builds only when desktop/ changes on main and publishes to a single rolling
'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent
download URL, and the file changes only when the desktop code does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-driven versioned releases; tag is the single source of version

Revert to manual version tags (desktop-v*) for explicit version control, but
derive the app/.dmg version from the tag so package.json + tauri.conf.json are
synced automatically in CI — no manual version-file edits. Each tag produces its
own versioned GitHub Release with the universal unsigned .dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): release entirely from GitHub via workflow_dispatch version input

Make the GitHub Action the single tool to version + deploy the desktop app:
Run workflow -> enter a version -> it syncs the version, builds the universal
unsigned .dmg, and creates the desktop-v<version> tag + Release. Refuses to
re-release an existing version; marks pre-release versions accordingly. Tag
push (desktop-v*) still works as a secondary trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge

Desktop is a secondary artifact, not the main product. Build with tauri-action,
then publish via action-gh-release with make_latest:false so the repo's 'Latest
release' badge stays on the main app's v* release. Separate desktop-v* tag
namespace already keeps it out of the v* publish workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): address PR review feedback (security + correctness)

Security:
- workflow: pass workflow_dispatch version via env (no shell injection); pin all
  third-party actions to commit SHAs.
- SSO: gate deep-link navigation and begin_sso to servers the user has saved
  (is_known_server), so a rogue page/deep link can't drive them.
- desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped
  since the single-use PKCE code is the protection.
- desktop_sso_start validates the code_challenge is a 43-char base64url digest.
- desktop_exchange claims the one-time code atomically (delete-and-check) to
  close the read/delete TOCTOU.
- failure: return desktop SSO errors to the app via sure://sso/callback?error.

Correctness / stability:
- prefs window hides on close instead of being destroyed, so the menu can
  reopen it.
- servers.rs: on-disk store is authoritative (file-first read), atomic writes
  (temp + rename).
- main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add
  a shared serverErrorMessage helper (no duplicated substring checks).
- vite.config.ts: derive dir from import.meta.url (ESM has no __dirname).
- bridge.ts: coalesce MutationObserver scans to one per frame.
- README: notarization example uses the universal .dmg name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability

Resolves the remaining security finding: the static remote.json granted Tauri
IPC to any http(s) origin (https://*). Remove it and instead add a capability
scoped to each server's exact origin at runtime (CapabilityBuilder +
add_capability), granting only the minimal permissions the bridge needs, for
saved/active servers on startup and for the target in set_active_server. No
origin outside the user's configured servers can access IPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): add runtime per-origin IPC capability (grant_server_capability)

Implements the runtime-scoped capability that replaces the removed wildcard
remote.json: CapabilityBuilder scoped to each server's exact origin, added via
add_capability for saved/active servers at startup and in set_active_server.
(Split from the previous commit, which only recorded the remote.json removal.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): harden release workflow (no shared caches, environment gate)

Address two release-workflow security findings:
- Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions
  cache written by another workflow can't flow into a published .dmg (P0).
- Add 'environment: release' to the build job so publishing can require manual
  approval and scope secrets to release runs (P1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): remove transparency code and fix relaunch behavior

* fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values

* ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release

* fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:44:06 +02:00

86 lines
3.5 KiB
Markdown

# Sure Desktop (macOS)
Native macOS shell (Tauri 2 + WKWebView) that renders the full Sure web app and
wraps it in real Mac chrome. It always talks to a Sure server you already run
(self-hosted or managed) — same trust model as a browser.
## Requirements
- Rust (stable), Node 18+, Xcode command line tools, macOS 12+.
## Run in development
```bash
cd desktop
npm install
npm run build # builds the injected bridge.js + onboarding assets
npm run tauri dev
```
On first launch, enter your Sure server URL (e.g. `http://localhost:3000` when
running `bin/dev`). The app health-checks `{server}/up`, then loads the real
`/sessions/new` where you sign in with password or SSO (MFA supported).
## Build a release .dmg (unsigned)
```bash
cd desktop
# Single-arch (host only):
npm run tauri build
# Universal (Apple Silicon + Intel) — what releases ship:
rustup target add aarch64-apple-darwin x86_64-apple-darwin
npm run tauri build -- --target universal-apple-darwin
# Output: src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure_<ver>_universal.dmg
```
## Publishing a release
The desktop build runs automatically as part of the normal Sure `v*` release.
The version comes from `.sure-version` and must match the release tag; it is
stamped into `desktop/package.json` and `desktop/src-tauri/tauri.conf.json`
only while building. The universal `.dmg` is attached to that same GitHub
Release—there is no separate desktop action, tag, or version.
## Installing an unsigned build (end users)
The published `.dmg` is **not code-signed**, so macOS Gatekeeper blocks the first
launch. To open it:
1. Drag Sure to Applications and try to open it; dismiss the warning.
2. **System Settings → Privacy & Security**, scroll down, click **Open Anyway**,
and confirm. (On macOS 15 Sequoia the old right-click→Open shortcut is gone;
this Settings path is the way.)
If macOS instead says the app is "damaged", the download was quarantined — strip
it once in Terminal:
```bash
xattr -cr /Applications/Sure.app
```
Signing + notarization (below) removes this friction entirely.
## Rust tests
```bash
cd desktop/src-tauri
cargo test
```
## Deep links
Registered scheme: `sure://{host}[:port]/{path}` → opens the app to that
server/page. Example: `open "sure://localhost:3000/accounts"`. (Works from the
bundled `.app`, not `tauri dev`.)
## Code signing & notarization (required for distribution — NOT wired up)
No Apple Developer credentials are needed to build/run locally. To ship a
distributable, signed, notarized `.dmg`, add:
1. An **Apple Developer ID Application** certificate in your login keychain.
2. Tauri signing config in `src-tauri/tauri.conf.json` under `bundle.macOS`:
`"signingIdentity": "Developer ID Application: <NAME> (<TEAMID>)"`,
`"hardenedRuntime": true`, and an `entitlements` plist if needed.
3. Notarization after build:
```bash
VERSION="<release-version>"
xcrun notarytool submit "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure_${VERSION}_universal.dmg" \
--apple-id "<APPLE_ID>" --team-id "<TEAMID>" --password "<APP_SPECIFIC_PW>" --wait
xcrun stapler staple "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure_${VERSION}_universal.dmg"
```
These steps require an Apple Developer account and are intentionally left as a
documented follow-up.
## Not built yet (see spec §9)
- Balance-with-sparkline glance widget (Tauri floating panel and/or a WidgetKit
Notification Center widget with App Group data sharing), fed by an
auto-provisioned read-only API key polling `/api/v1`. Deferred by design.