11 Commits
Author SHA1 Message Date
samiandClaude Opus 5.5 608d85c297 Linux: browser helper for snap Firefox; packages register it
ci / build (ubuntu-22.04) (push) Waiting to run
ci / build (windows-2025) (push) Waiting to run
ci / deny (push) Waiting to run
Snap Firefox starts the helper through the desktop portal, which reads the
manifest from ~/.mozilla or /usr/lib/mozilla. The .deb and .rpm now install the
manifests system-wide, and install.sh detects snap Firefox.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:12:45 +04:00
samiandClaude Opus 5.5 32eadc4d03 Windows updates come from the Kestrove website; version 1.2.1
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:57:46 +04:00
samiandClaude Opus 5.5 57b728c7e3 gitea-release.sh: work with any subset of bundles
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
A release with only the Windows installer aborted: shasum failed on the
unmatched *.deb and *.rpm globs under set -e. Checksum the files that exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:29:32 +04:00
samiandClaude Opus 5.5 d70dcc04b6 In-app updates on Windows
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
The Microsoft Store doesn't update EXE installs, so the app updates itself
with Tauri's updater. It reads updater/latest.json from the Kestrove Gitea a
minute after startup and once a day. A new version shows as a banner and in
Settings > Updates, and installs only on "Update and restart". The vault locks
before the installer takes over.

The installer must be signed with the Kestrove updater key, with the signature
bound to its version (requireSignedVersion), so a compromised download server
can hold updates back but can't ship one or replay an old installer. Signing
is a release step, after Authenticode: gitea-release.sh signs, uploads the
.sig, and writes latest.json last. Plain builds need no key.

Checks reveal only the IP address and app version, and can be turned off in
Settings. endpoints is a list, so a mirror can be added. Linux packages update
through their package manager.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:24 +04:00
samiandClaude Opus 5.5 70ccff34f2 Installer: stop the browser helper before updating or uninstalling
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
A browser keeps kestrove-nmh.exe running while the extension is connected,
so an update couldn't replace it and stopped half way: the old version's
uninstall had already removed the browser registration, and the new one was
never written. The installer only closes the app itself, so the hooks now
stop the helper too. It holds no state; the browser starts it again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:21:12 +04:00
samiandClaude Opus 5.5 c2273e202e Release 1.2.0: colour themes, Windows Hello, official icon
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Windows and Linux app 1.2.0, from the beta work: colour themes synced across
devices, unlock with Windows Hello, the official Kestrove icon, and the Chrome
Web Store extension id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:09:04 +04:00
samiandClaude Opus 5.5 95ce03ba06 Trust the Chrome Web Store extension (pmhfoafjoogobhfadaicklhkldgnafmk)
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
The store build has its own id, so the native-messaging manifest refused it
and the extension reported the app as not running. Chrome now allows the
store id and the unpacked development id, in the NSIS hook and in both
install scripts; --chrome-id / -ChromeId take a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:21:09 +04:00
samiandClaude Opus 5.5 7b61a4851b Rebuild the exe icon when icons/ changes
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
tauri-build only reruns for tauri.conf.json and capabilities/, so an
incremental build after the new icons kept the old keyhole in the exe
resource and the window icon. Clean builds were unaffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:45:24 +04:00
samiandClaude Opus 5.5 27f13b4743 Official app icon and Windows Hello unlock (beta)
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Icons: the taskbar, title bar, tray and installer icons are now the Kestrove
mark (kestrel wing and keyhole on the Spruce tile) instead of the placeholder
keyhole. Generated with `tauri icon` from icons/kestrove-mark.svg, the brand
mark with the eye in the `accent` token (#b4481f), as the brand book asks.

Windows Hello (docs/02 "Local unlock"): Settings > General > "Unlock with
Windows Hello". Turning it on creates a TPM-backed KeyCredentialManager key
for this vault, which signs a fixed label plus a random salt; HKDF of the
deterministic RSA signature seals the core's Local Unlock Key with
XChaCha20-Poly1305 in windows-hello.json. The unlock screen then offers
"Unlock with Windows Hello" above the master password.

- The master password is still required every 14 days; Hello is not offered
  while it is due, and the LUK is kept.
- A cancelled prompt falls back to the password. The LUK is deleted only when
  Hello's key is gone or doesn't open it.
- Hello calls block, so they run on a blocking thread; the prompt is brought
  in front of the window.
- Not offered on Linux or where Windows Hello isn't set up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:14:32 +04:00
samiandClaude Opus 5.5 10a9507aa2 Colour themes, synced across devices (beta)
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Seven themes (Spruce, Ocean, Violet, Rose, Crimson, Amber, Graphite) that
change only the brand colour, all AA in light and dark. "Same theme on all
my devices" saves the choice in the encrypted vault and adopts it after
unlocking and syncing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:23:42 +07:00
samiandClaude Opus 5.5 fb53b7d907 README: plain-language guide for users, hosting and building
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:22:04 +07:00
39 changed files with 1617 additions and 120 deletions
+112 -70
View File
@@ -1,87 +1,129 @@
# kestrove-desktop
# Kestrove for Windows and Linux
**Windows and Linux** app built on Tauri 2. The Rust side links `kestrove-core`'s `kv-ffi`
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
The Kestrove desktop app for **Windows 10/11 and Linux**: your password manager, with a vault encrypted on
the computer and synced through your Kestrove server. On a Mac, use the Mac app from `kestrove-apple`
instead: it does everything this app does.
Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app.
On a Mac, the Kestrove Mac app serves the browser extension itself (Chrome, Edge, Brave, Vivaldi, Arc,
Firefox), so this app isn't needed there; it still builds on macOS for development.
**What it does:**
- Your vault: logins, one-time codes, tags and "Sign in with Google/Apple", with search and a password
generator.
- **Connects the Kestrove browser extension** (Chrome, Edge, Brave, Firefox) to your vault: filling, saving
new logins, and **passkeys**. You approve each passkey in the app with your master password.
- Adding this computer to your account: scan a QR code with your phone, use your Emergency Kit, or paste a
setup code.
- Locks itself after 10 minutes without use (you can change this), when you lock the screen, and when the
computer sleeps.
- Closing the window keeps Kestrove running in the tray, so the browser extension keeps working. **Quit** is in
the tray menu.
## Install
Download from the [releases page](https://gitea.samialabri.com/kestrove/kestrove-desktop/releases):
| System | File |
|---|---|
| Windows (x64) | `Kestrove_<version>_x64-setup.exe` |
| Debian, Ubuntu | `Kestrove_<version>_amd64.deb`, then `sudo apt install ./Kestrove_*.deb` |
| Fedora, openSUSE | `Kestrove-<version>-1.x86_64.rpm`, then `sudo dnf install ./Kestrove-*.rpm` |
Then open Kestrove and add this computer to your account. The easiest way is **Use my phone**: type your
server's address and scan the QR code on screen with the Kestrove app on your phone.
**Connect your browser:** install the Kestrove extension, open it, and choose **Pair with Kestrove**. The
extension and the app show the same six words; confirm in both. After that, the browser fills from your vault
whenever the app is unlocked.
## Build it
You need [Rust](https://rustup.rs), Node.js 22+, and
[Tauri's system packages](https://tauri.app/start/prerequisites/) for your OS, with `kestrove-core` next to
this folder.
```bash
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
npm install
npm run tauri dev
npm run tauri build # MSI/NSIS on Windows, AppImage/deb on Linux
npm run tauri build -- --debug --bundles app # macOS dev build (Kestrove.app)
npm run tauri icon assets/logo.png # regenerate src-tauri/icons (placeholder logo)
npm ci
npm run tauri dev # run it from source
npm run tauri build # an installer for the system you're on (Windows: .exe; Linux: .deb/.rpm)
(cd src-tauri && cargo clippy --all-targets -- -D warnings && cargo test)
```
The vault lives in the OS app-data dir (`vault.sqlite`, plus `pairings.json`, `settings.json` and
`secret-key`, all owner-only). Closing the window keeps Kestrove in the tray so the browser extension
keeps working; Quit is in the tray menu.
**Release builds from a Mac** (both write to `../release/desktop/`):
## Browser integration (docs/05)
```bash
# Linux .deb and .rpm, built in a clean Debian 12 container (run from the folder holding the repositories)
docker buildx build --platform linux/amd64 -f kestrove-desktop/scripts/linux.Dockerfile \
--target out -o type=local,dest=release/desktop .
```
extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (broker)
└──────────── pairing channel: X25519 → 6 words → AES-256-GCM, end to end ───────────┘
# Windows installer, cross-compiled. Setting XWIN_ACCEPT_LICENSE=1 accepts Microsoft's
# license for the Windows SDK files it downloads; the script header lists what to install first.
XWIN_ACCEPT_LICENSE=1 scripts/windows.sh
```
The bridge is shared with the macOS app and lives in kestrove-core: `crates/kv-ipc` (framing, IPC),
`crates/kv-nmh` (the host) and `kv-ffi`'s `browser-bridge` feature (`src/bridge/`: broker, pairing
channel). This crate re-exports them under the old module names.
**Publish them on Gitea:** raise `version` in `package.json`, `src-tauri/tauri.conf.json` and
`src-tauri/Cargo.toml`, tag the commit (`git tag v<version> && git push origin v<version>`), then:
* **IPC** (`kv-ipc`): Unix socket `$XDG_RUNTIME_DIR/kestrove/nmh.sock` (macOS: the per-user temp
dir), dir 0700, socket 0600, peer uid checked on both ends. Windows: `\\.\pipe\kestrove-nmh-<user>`,
first instance only, local clients only, DACL for the current user, client token SID checked
(compile-checked only). `KESTROVE_IPC_DIR` isolates an instance for tests.
* **Pairing** (`bridge/channel.rs`, mirrored by kestrove-browser `src/channel.ts`, shared test vectors):
both UIs show 6 words; the user confirms in the app (needs an unlocked vault) and in the extension.
Each connection derives a fresh session key from both sides' nonces; messages carry strict
counters, so replay and reordering fail. `kestrove-nmh` only relays ciphertext.
* **Protocol** (`bridge/broker.rs`): `query` → identities for the origin (`items_for_url`); `fill`/`totp`
re-check that the item matches the origin and need an unlocked vault; `save` shows a prompt in
the app (new login or password update).
* **Native host manifests**: `native-messaging/install.sh` (Linux; macOS for development only, the Mac
app registers its own) and `install.ps1` (Windows, HKCU registry) fill in the host path and extension ids.
* **Tests**: `cargo test` (unit tests + `tests/nmh_stdio.rs`, which drives the real `kestrove-nmh`
over stdio through the paired channel). `examples/test-host.rs` is a headless, auto-approving
stand-in for the app used by kestrove-browser's interop and Chrome end-to-end tests.
```bash
GITEA_TOKEN=<token with write:repository> scripts/gitea-release.sh
```
## Auto-lock
It creates the release for the tag and uploads the installers, the browser helpers and a `SHA256SUMS` file.
10 minutes idle by default (Settings), plus screen lock and sleep (`autolock.rs`). Idle time is the
system's on Windows (`GetLastInputInfo`) and macOS; on Linux it is activity inside Kestrove (UI input
and fills). Screen lock: Windows input-desktop check, Linux logind `LockedHint`, macOS session dictionary.
Sleep: a jump in the wall clock between polls.
**Put it on the website and offer it as an update (Windows):**
## Sync and onboarding (docs/03, docs/10)
```bash
TAURI_SIGNING_PRIVATE_KEY_PATH=<updater key> scripts/publish-update.sh
```
* **First start** offers four ways in: **Use my phone** (default: type the server URL, scan the QR on screen
with a signed-in phone, compare the 6-digit code, then type only the master password), **Emergency Kit**
(PDF, or a PNG/JPEG photo of its QR), **Paste setup code**, and **New account** (server URL + the server's
`KV_SETUP_CODE`).
* **Device key** (`device_key.rs`): Windows TPM via CNG, else a DPAPI-sealed software key; Linux Secret Service,
else a 0600 file (with a warning in Settings); macOS dev runs use the keychain. `KESTROVE_DEVICE_KEY=file`
forces the file store.
* **Sync** (`sync.rs`) runs after unlock, after a save, every 15 minutes while unlocked, and on "Sync now".
Lock drops all keys (core), cancels a scan-to-join and revokes an "Add a device" ticket still on screen.
An integrity alarm shows a hard alert and a banner; syncing that vault stops.
* **Settings → Devices**: approve pending devices (compare fingerprints), remove devices, **Add a phone or
tablet** (10-minute single-use QR; Done/Cancel revokes it) and **Approve a computer** (paste the join code a
new computer shows under "No phone?").
* **Two instances on one machine**: `KESTROVE_DATA_DIR=/tmp/kv-b Kestrove.app/Contents/MacOS/kestrove-desktop`
(the second one has no browser integration). `KESTROVE_DEVICE_NAME` overrides the device name.
* **Live test**: `tests/live_sync.rs` (ignored) drives three instances against a running server; see its header.
This signs the installer with the updater key, copies it to `downloads/` in `kestrove-website` next to this repo,
and writes `updates/latest.json` there. Sign the installer with the code-signing certificate first, because the
updater signature covers the final file. Installed copies are offered the update once the website is deployed.
Keep the updater key and its password offline and backed up:
the app only accepts updates signed with it, so losing it means existing installs can't update.
## Roadmap (M3, kestrove-spec/docs/07)
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
- [x] Persistent vault (`VaultCore::open` in app data)
- [x] Sync, device approval and scan-to-join onboarding
- [x] Auto-lock on idle, screen lock and suspend
- [x] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
- [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`)
- [ ] **Quick Access** global hotkey (Ctrl+Shift+Space): search, then copy or auto-type (Windows `SendInput`, X11 XTest; Wayland gets copy only)
- [ ] MSI/deb installers run the manifest installers
- [ ] Windows 11 passkey plugin provider
## How the browser connection works
```
browser extension ─(native messaging)─▶ kestrove-nmh ─(local connection)─▶ Kestrove app
└──────────── encrypted end to end with a key you confirm by comparing six words ──────────┘
```
- `kestrove-nmh` is a tiny helper that the browser starts. It only passes encrypted messages along; it can't
read them.
- The browser has to be told where the helper is: `native-messaging/install.sh` (Linux) and
`native-messaging/install.ps1` (Windows) do that, for the extension's id only.
- The extension only ever learns logins for the site you're on, and fills only after you click. Saving a new
login, and every passkey use, is confirmed in the app.
- The same code runs inside the Mac app; it lives in `kestrove-core` (`kv-ffi`'s browser bridge, `kv-ipc`,
`kv-nmh`).
## Updates
On Windows, Kestrove checks for a new version a minute after it starts and once a day. The check is a plain request
for `updates/latest.json` on the Kestrove website: it reveals this computer's IP address and app version, and nothing
from your vault. A new version appears as a banner and installs only when you choose **Update and restart**. The app
verifies the download's signature before running it. Turn checks off in **Settings → Updates**. On Linux, updates
come through your package manager.
## Where things are kept
The vault and the app's files are in your user's app-data folder (`vault.sqlite`, `pairings.json`,
`settings.json`), readable only by you. The key that proves this is your device lives in the **TPM** on Windows
(or is sealed with your Windows login), and in the **Secret Service** keyring on Linux (or, when there is none,
in a private file, which Settings warns about).
## For developers
| Path | What it is |
|---|---|
| `src/` | The window's interface (TypeScript). It only displays things; it never holds keys. |
| `src-tauri/src/` | The app (Rust): `main.rs` commands and prompts, `sync.rs` sync and onboarding, `autolock.rs`, `device_key.rs`. |
| `src-tauri/examples/test-host.rs` | A stand-in for the app that approves everything automatically, for the browser extension's tests. Never ship it. |
| `native-messaging/` | Registering the browser helper. |
| `scripts/` | Release builds (`linux.Dockerfile`, `windows.sh`) and `gitea-release.sh`. |
Useful for testing: `KESTROVE_DATA_DIR=/tmp/kv-b` runs a second, separate instance, and `KESTROVE_DEVICE_NAME`
sets the device name. `tests/live_sync.rs` (ignored by default) drives three instances against a running
server; see its header.
The full design is in [`kestrove-spec`](https://gitea.samialabri.com/kestrove/kestrove-spec), especially
`docs/05-autofill.md`.
+8 -3
View File
@@ -1,9 +1,10 @@
# Native messaging manifests
`install.sh` (Linux, macOS) and `install.ps1` (Windows) fill in the placeholders and register the
manifests for the current user. Default ids are the development ones: the unpacked Chrome build is pinned
to `kogapfibnnpeapfcjkkpgjddbhnjfein` by the manifest key in kestrove-browser, Firefox uses
`[email protected]`. Pass `--chrome-id` / `-ChromeId` for a Web Store build.
manifests for the current user. Chrome allows two ids by default: the Chrome Web Store build
(`pmhfoafjoogobhfadaicklhkldgnafmk`) and the unpacked development build, pinned to
`kogapfibnnpeapfcjkkpgjddbhnjfein` by the manifest key in kestrove-browser. Firefox uses `[email protected]`.
Pass `--chrome-id a,b` / `-ChromeId a,b` to allow other ids instead.
```bash
native-messaging/install.sh # every browser with a profile dir, host from target/
@@ -26,5 +27,9 @@ native-messaging/install.sh --uninstall
| Firefox | `~/.mozilla/native-messaging-hosts/app.kestrove.nmh.json` | `HKCU\Software\Mozilla\NativeMessagingHosts\app.kestrove.nmh` |
| Brave | `~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/` | same key as Chrome |
The Linux `.deb` and `.rpm` install these manifests system-wide (`src-tauri/linux/`), so they need no script. That
covers snap Firefox too: it can't start the host itself, so the desktop portal does, after asking the user once.
AppImage users run `install.sh`.
`allowed_origins` / `allowed_extensions` pin the host to **your** extension id.
That is the first line of defense against other extensions (docs/04 A8).
+5 -4
View File
@@ -2,13 +2,14 @@
# Linux/macOS: install.sh.
#
# powershell -ExecutionPolicy Bypass -File install.ps1 [-Nmh C:\path\kestrove-nmh.exe]
# [-ChromeId ID] [-FirefoxId ID] [-Browsers chrome,chromium,edge,firefox] [-Uninstall]
# [-ChromeId ID[,ID…]] [-FirefoxId ID] [-Browsers chrome,chromium,edge,firefox] [-Uninstall]
#
# Chrome and Brave share the Google\Chrome key. The manifests go to %LOCALAPPDATA%\Kestrove\NativeMessagingHosts.
# allowed_origins / allowed_extensions pin the host to our extension ids (docs/04 A8).
param(
[string]$Nmh = "",
[string]$ChromeId = "kogapfibnnpeapfcjkkpgjddbhnjfein",
# The Chrome Web Store build, then the unpacked development build.
[string[]]$ChromeId = @("pmhfoafjoogobhfadaicklhkldgnafmk", "kogapfibnnpeapfcjkkpgjddbhnjfein"),
[string]$FirefoxId = "[email protected]",
[string[]]$Browsers = @("chrome", "chromium", "edge", "firefox"),
[switch]$Uninstall
@@ -17,7 +18,7 @@ $ErrorActionPreference = "Stop"
$HostName = "app.kestrove.nmh"
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
if ($ChromeId -notmatch '^[a-p]{32}$') { throw "-ChromeId must be 32 letters a-p" }
foreach ($id in $ChromeId) { if ($id -notmatch '^[a-p]{32}$') { throw "-ChromeId must be 32 letters a-p" } }
if ($FirefoxId -notmatch '^[A-Za-z0-9._@{}-]+$') { throw "bad -FirefoxId" }
$Keys = @{
@@ -48,7 +49,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
foreach ($flavor in @("chrome", "firefox")) {
$m = Get-Content -Raw (Join-Path $Here "$HostName.$flavor.json") | ConvertFrom-Json
$m.path = $Nmh
if ($flavor -eq "chrome") { $m.allowed_origins = @("chrome-extension://$ChromeId/") } else { $m.allowed_extensions = @($FirefoxId) }
if ($flavor -eq "chrome") { $m.allowed_origins = @($ChromeId | ForEach-Object { "chrome-extension://$_/" }) } else { $m.allowed_extensions = @($FirefoxId) }
$m | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 (Join-Path $OutDir "$HostName.$flavor.json")
}
+13 -6
View File
@@ -2,7 +2,7 @@
# Registers the Kestrove native-messaging host with the browsers of the current user (Linux, macOS).
# Windows: install.ps1.
#
# install.sh [--nmh /path/to/kestrove-nmh] [--chrome-id ID] [--firefox-id ID]
# install.sh [--nmh /path/to/kestrove-nmh] [--chrome-id ID[,ID…]] [--firefox-id ID]
# [--browsers chrome,chromium,edge,brave,firefox] [--user-data-dir DIR] [--uninstall]
#
# With neither --browsers nor --user-data-dir it installs for every browser whose profile directory exists.
@@ -14,8 +14,9 @@ set -eu
HOST=app.kestrove.nmh
HERE=$(cd "$(dirname "$0")" && pwd)
NMH=""
# Development ids: the unpacked Chrome build is pinned by the manifest key in kestrove-browser/scripts/build.mjs.
CHROME_ID=kogapfibnnpeapfcjkkpgjddbhnjfein
# Chrome: the Chrome Web Store build, then the unpacked development build (pinned by the manifest key in
# kestrove-browser/scripts/build.mjs).
CHROME_IDS="pmhfoafjoogobhfadaicklhkldgnafmk kogapfibnnpeapfcjkkpgjddbhnjfein"
FIREFOX_ID=[email protected]
BROWSERS=""
USER_DATA_DIR=""
@@ -26,7 +27,7 @@ die() { echo "install.sh: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--nmh) NMH=$2; shift 2 ;;
--chrome-id) CHROME_ID=$2; shift 2 ;;
--chrome-id) CHROME_IDS=$(echo "$2" | tr ',' ' '); shift 2 ;;
--firefox-id) FIREFOX_ID=$2; shift 2 ;;
--browsers) BROWSERS=$(echo "$2" | tr ',' ' '); shift 2 ;;
--user-data-dir) USER_DATA_DIR=$2; shift 2 ;;
@@ -36,7 +37,9 @@ while [ $# -gt 0 ]; do
esac
done
echo "$CHROME_ID" | grep -Eq '^[a-p]{32}$' || die "--chrome-id must be 32 letters a-p"
for id in $CHROME_IDS; do echo "$id" | grep -Eq '^[a-p]{32}$' || die "--chrome-id must be 32 letters a-p"; done
# "chrome-extension://a/", "chrome-extension://b/" (the template supplies the outer quotes).
CHROME_ORIGINS=$(for id in $CHROME_IDS; do printf '%s' "${sep-}chrome-extension://$id/"; sep='", "'; done)
echo "$FIREFOX_ID" | grep -Eq '^[A-Za-z0-9._@{}-]+$' || die "bad --firefox-id"
if [ "$UNINSTALL" = 0 ]; then
@@ -81,9 +84,13 @@ esac
eval "d=\$dir_$b"
[ -d "$d" ] && BROWSERS="$BROWSERS $b"
done
# Snap Firefox keeps its profile in ~/snap, but its portal reads the host from ~/.mozilla like any other Firefox.
case " $BROWSERS " in *" firefox "*) ;; *)
[ -n "$USER_DATA_DIR" ] || { [ -d /snap/firefox ] && BROWSERS="$BROWSERS firefox"; } || true ;;
esac
render() { # template → stdout
sed -e "s|__NMH_PATH__|$NMH|" -e "s|__CHROME_EXTENSION_ID__|$CHROME_ID|" -e "s|__FIREFOX_EXTENSION_ID__|$FIREFOX_ID|" "$HERE/$HOST.$1.json"
sed -e "s|__NMH_PATH__|$NMH|" -e "s|chrome-extension://__CHROME_EXTENSION_ID__/|$CHROME_ORIGINS|" -e "s|__FIREFOX_EXTENSION_ID__|$FIREFOX_ID|" "$HERE/$HOST.$1.json"
}
place() { # flavor dir
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kestrove-desktop",
"version": "1.1.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kestrove-desktop",
"version": "1.1.0",
"version": "1.2.1",
"license": "Apache-2.0",
"dependencies": {
"@tauri-apps/api": "2.12.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kestrove-desktop",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+6 -4
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# Creates (or reuses) the Gitea release for this version and uploads the desktop bundles to it.
# Creates (or reuses) the Gitea release for this version and uploads the desktop bundles to it. The website's
# downloads and the Windows app's updates come from scripts/publish-update.sh instead.
#
# GITEA_TOKEN=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop)
# GITEA_TOKEN=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop)
#
# GITEA_TOKEN: a Gitea access token with the write:repository scope (Settings → Applications). The tag
# v<version> must already be pushed. Files: *.AppImage, *.deb, *.rpm, *-setup.exe, kestrove-nmh-*, SHA256SUMS.
@@ -15,16 +16,17 @@ V=$(node -p 'require("./package.json").version')
TAG="v$V"
AUTH="Authorization: token $GITEA_TOKEN"
(cd "$DIR" && rm -f SHA256SUMS && shasum -a 256 -- *.AppImage *.deb *.rpm *-setup.exe kestrove-nmh-* 2>/dev/null > SHA256SUMS; cat SHA256SUMS)
(cd "$DIR" && rm -f SHA256SUMS && for f in *.AppImage *.deb *.rpm *-setup.exe kestrove-nmh-*; do if [ -f "$f" ]; then shasum -a 256 -- "$f"; fi; done > SHA256SUMS; cat SHA256SUMS)
ID=$(curl -fsS -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).id||"")}catch{console.log("")}})')
if [ -z "$ID" ]; then
BODY=$(node -e 'console.log(JSON.stringify({tag_name: process.argv[1], name: "Kestrove " + process.argv[2], body: "Kestrove desktop " + process.argv[2] + " for Windows and Linux. On a Mac, use the Kestrove app from the Mac App Store instead.\n\nVerify downloads with SHA256SUMS.", draft: false, prerelease: false}))' "$TAG" "$V")
ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" -d "$BODY" "$API/repos/$REPO/releases" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))')
fi
for f in "$DIR"/*.AppImage "$DIR"/*.deb "$DIR"/*.rpm "$DIR"/*-setup.exe "$DIR"/kestrove-nmh-* "$DIR"/SHA256SUMS; do
for f in "$DIR"/*.AppImage "$DIR"/*.deb "$DIR"/*.rpm "$DIR"/*-setup.exe "$DIR"/*-setup.exe.sig "$DIR"/kestrove-nmh-* "$DIR"/SHA256SUMS; do
[ -f "$f" ] || continue
echo "uploading $(basename "$f")"
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$ID/assets?name=$(basename "$f")" >/dev/null
done
echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Puts the Windows installer on the Kestrove website and offers it as an update: signs it with the updater key if
# needed, copies it to the website's downloads/, and writes the website's updates/latest.json, which the app checks
# (src-tauri/src/updates.rs).
#
# TAURI_SIGNING_PRIVATE_KEY_PATH=… scripts/publish-update.sh [dir-with-installer] (default ../release/desktop)
#
# Sign after Authenticode signing: the updater signature covers the final file. The key's password is asked for,
# or read from TAURI_SIGNING_PRIVATE_KEY_PASSWORD. Optional NOTES: the text shown with the update. Optional
# WEBSITE_DIR: the website folder (default ../kestrove-website). Users get the update once the website is deployed.
set -eu
cd "$(dirname "$0")/.."
DIR=${1:-../release/desktop}
SITE=${WEBSITE_DIR:-../kestrove-website}
V=$(node -p 'require("./package.json").version')
NAME="Kestrove_${V}_x64-setup.exe"
SETUP="$DIR/$NAME"
[ -f "$SETUP" ] || { echo "$SETUP not found" >&2; exit 1; }
if [ ! -f "$SETUP.sig" ]; then
: "${TAURI_SIGNING_PRIVATE_KEY_PATH:?$SETUP.sig is missing: set TAURI_SIGNING_PRIVATE_KEY_PATH to the updater key to sign it}"
# --app-version binds the signature to this version (the app requires it), so an older signed installer can't
# be offered again under a newer version number.
npx tauri signer sign -f "$TAURI_SIGNING_PRIVATE_KEY_PATH" ${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+-p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"} \
--app-version "$V" "$SETUP"
fi
mkdir -p "$SITE/downloads" "$SITE/updates"
cp "$SETUP" "$SITE/downloads/$NAME"
node -e '
const [out, v, url, sigFile, notes] = process.argv.slice(1);
const signature = require("fs").readFileSync(sigFile, "utf8").trim();
const doc = { version: v, notes, pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
platforms: { "windows-x86_64": { signature, url } } };
require("fs").writeFileSync(out, JSON.stringify(doc, null, 2) + "\n");
' "$SITE/updates/latest.json" "$V" "https://kestrove.samialabri.com/downloads/$NAME" "$SETUP.sig" "${NOTES:-Kestrove $V}"
echo "Copied $NAME to $SITE/downloads and wrote $SITE/updates/latest.json. Deploy the website to offer the update."
+288 -12
View File
@@ -963,6 +963,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -986,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -999,7 +1009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -1698,6 +1708,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -2628,9 +2648,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2949,6 +2971,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -3012,13 +3064,15 @@ dependencies = [
[[package]]
name = "kestrove-desktop"
version = "1.1.0"
version = "1.2.1"
dependencies = [
"arboard",
"base64 0.22.1",
"core-foundation",
"chacha20poly1305",
"core-foundation 0.10.1",
"getrandom 0.2.17",
"hayro",
"hkdf 0.12.4",
"image",
"keyring",
"kv-ffi",
@@ -3033,10 +3087,12 @@ dependencies = [
"sha2 0.10.9",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tempfile",
"tokio",
"url",
"uuid",
"windows",
"windows-sys 0.61.2",
"zeroize",
]
@@ -3385,6 +3441,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3740,6 +3802,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3755,6 +3818,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3846,6 +3921,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "openssl-probe"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3862,6 +3943,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "p256"
version = "0.13.2"
@@ -4510,15 +4605,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4621,6 +4721,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -4630,6 +4742,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
@@ -4662,6 +4801,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4779,7 +4927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -5060,6 +5208,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
@@ -5289,6 +5453,27 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5310,7 +5495,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [
"bitflags 2.13.2",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -5320,7 +5505,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -5353,6 +5538,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5376,7 +5572,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -5474,6 +5670,56 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
dependencies = [
"base64 0.23.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.5",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.12.2"
@@ -5484,7 +5730,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -5507,7 +5753,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -5581,7 +5827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -6458,6 +6704,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
@@ -6659,6 +6914,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -6924,7 +7190,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -7000,6 +7266,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kestrove-desktop"
version = "1.1.0"
version = "1.2.1"
edition = "2021"
rust-version = "1.82"
license = "Apache-2.0"
@@ -46,6 +46,9 @@ rqrr = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
hayro = { version = "0.8", default-features = false }
tokio = { version = "1", features = ["time", "sync", "macros"] }
# Windows Hello unlock (docs/02 "Local unlock"): a key derived from Hello's signature seals the LUK.
hkdf = "0.12"
chacha20poly1305 = "0.10"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -68,7 +71,12 @@ windows-sys = { version = "0.61", features = [
"Win32_System_StationsAndDesktops",
"Win32_System_SystemInformation",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
# In-app updates (updates.rs): signed installers listed in latest.json on the Kestrove website.
tauri-plugin-updater = "2"
# Windows Hello (KeyCredentialManager); the same version Tauri already pulls in.
windows = { version = "0.62", features = ["Foundation", "Security_Credentials", "Security_Cryptography", "Storage_Streams"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
+3
View File
@@ -1,3 +1,6 @@
fn main() {
// tauri-build only watches tauri.conf.json and capabilities/; without this an incremental build keeps the old
// icon in the exe resource and the window icon after the files in icons/ change.
println!("cargo:rerun-if-changed=icons");
tauri_build::build()
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#19604f"/><path fill="#fff" d="M14 34 C 18 22, 26 14, 38 11 C 33 16, 29 21, 27 27 L 34 37 L 27 37 L 22 30 Z"/><circle cx="18.5" cy="17.5" r="3.5" fill="#b4481f"/><rect x="16.5" y="21" width="4" height="13" rx="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "/usr/bin/kestrove-nmh",
"type": "stdio",
"allowed_origins": ["chrome-extension://pmhfoafjoogobhfadaicklhkldgnafmk/", "chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"]
}
@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "/usr/bin/kestrove-nmh",
"type": "stdio",
"allowed_extensions": ["[email protected]"]
}
+442
View File
@@ -0,0 +1,442 @@
//! Unlocking with Windows Hello (docs/02 "Local unlock"): face, fingerprint or PIN instead of the master password.
//!
//! Turning it on creates a Windows Hello key for this vault (`KeyCredentialManager`, kept in the TPM) and has it
//! sign a challenge: a fixed label and a random salt. Windows Hello signs with RSA PKCS#1 v1.5, which is
//! deterministic, so the same challenge always gives the same signature. HKDF turns that signature into the key
//! that seals the core's Local Unlock Key (LUK) with XChaCha20-Poly1305. `windows-hello.json` holds the salt and
//! the sealed LUK; without a signature from Windows Hello they open nothing.
//!
//! * The master password is still required every [`PASSWORD_EVERY`]. When it is due, Windows Hello is not offered,
//! but the LUK is kept and works again right after the password.
//! * The LUK is deleted only when it is really gone or wrong: Windows Hello's key no longer exists (Hello or the
//! PIN was reset) or its signature does not open the LUK. A cancelled prompt just falls back to the password.
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use hkdf::Hkdf;
use kv_ffi::{CoreError, VaultCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::private_file;
/// How often the master password is still asked for.
pub const PASSWORD_EVERY: Duration = Duration::from_secs(14 * 24 * 60 * 60);
const FILE: &str = "windows-hello.json";
const CHALLENGE_LABEL: &[u8] = b"kestrove/v1/windows-hello/challenge";
const WRAP_INFO: &[u8] = b"kestrove/v1/windows-hello/luk-wrap";
const AAD_LABEL: &[u8] = b"kestrove/v1/windows-hello/luk";
#[derive(Debug, PartialEq, Eq)]
pub enum HelloError {
/// Windows Hello isn't set up on this computer (or this isn't Windows).
Unavailable,
/// The user closed the prompt or chose another way.
Cancelled,
/// Windows Hello's key for this vault is gone or doesn't open it; it has been turned off.
Reset,
Other(String),
}
impl fmt::Display for HelloError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HelloError::Unavailable => {
f.write_str("Windows Hello isn't set up on this computer. Set it up in Windows Settings → Accounts.")
}
HelloError::Cancelled => f.write_str("Windows Hello was cancelled."),
HelloError::Reset => f.write_str(
"Windows Hello was reset on this computer, so Kestrove turned it off. Unlock with your master \
password, then turn it on again in Settings.",
),
HelloError::Other(m) => f.write_str(m),
}
}
}
impl From<CoreError> for HelloError {
fn from(e: CoreError) -> Self {
HelloError::Other(e.to_string())
}
}
/// `windows-hello.json`.
#[derive(Serialize, Deserialize)]
struct Record {
v: u32,
/// The Windows Hello key's name.
credential: String,
salt: String,
nonce: String,
ct: String,
/// The last master-password unlock (Unix seconds).
password_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Status {
/// Windows Hello can be turned on here.
pub available: bool,
/// It is on for this vault.
pub enabled: bool,
/// The unlock screen offers it now (on, and the master password isn't due).
pub offered: bool,
}
pub fn is_supported() -> bool {
platform::is_supported()
}
pub fn status(core: &VaultCore, data_dir: &Path, available: bool) -> Status {
let rec = load(data_dir);
let enabled = rec.is_some() && core.has_local_unlock();
let offered = enabled && rec.is_some_and(|r| !password_due(r.password_at, now()));
Status { available, enabled, offered }
}
/// Turns Windows Hello on for the unlocked vault: one Windows Hello prompt to create the key and sign.
pub fn enable(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> {
if core.is_locked() {
return Err(CoreError::Locked.into());
}
let account_id = core.account_id()?;
let name = credential_name(data_dir, &account_id);
let salt: [u8; 32] = random();
let sig = platform::create_and_sign(&name, &challenge(&salt))?;
let luk = Zeroizing::new(core.enable_local_unlock()?);
let saved = seal(&sig, &salt, &luk, &account_id).and_then(|(nonce, ct)| {
save(
data_dir,
&Record {
v: 1,
credential: name.clone(),
salt: B64.encode(salt),
nonce: B64.encode(nonce),
ct: B64.encode(ct),
password_at: now(),
},
)
});
if let Err(e) = saved {
let _ = core.disable_local_unlock();
platform::delete(&name);
return Err(e);
}
Ok(())
}
/// Unlocks with Windows Hello. `Cancelled` leaves everything as it was; `Reset` has turned it off.
pub fn unlock(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> {
let rec = load(data_dir).ok_or(HelloError::Unavailable)?;
if !core.has_local_unlock() {
forget(core, data_dir, &rec);
return Err(HelloError::Reset);
}
if password_due(rec.password_at, now()) {
return Err(HelloError::Other("Kestrove needs your master password this time.".into()));
}
let account_id = core.account_id()?;
let (Ok(salt), Ok(nonce), Ok(ct)) = (B64.decode(&rec.salt), B64.decode(&rec.nonce), B64.decode(&rec.ct)) else {
forget(core, data_dir, &rec);
return Err(HelloError::Reset);
};
let sig = match platform::sign(&rec.credential, &challenge(&salt)) {
Err(HelloError::Reset) => {
forget(core, data_dir, &rec);
return Err(HelloError::Reset);
}
r => r?,
};
let Some(luk) = open(&sig, &salt, &nonce, &ct, &account_id) else {
forget(core, data_dir, &rec);
return Err(HelloError::Reset);
};
match core.unlock_with_local_key(luk.to_vec()) {
Ok(()) => Ok(()),
// The LUK doesn't open the keyset (the core reports a failed decrypt as bad credentials).
Err(CoreError::BadCredentials) => {
forget(core, data_dir, &rec);
Err(HelloError::Reset)
}
Err(e) => Err(e.into()),
}
}
/// Turns Windows Hello off: its key, the sealed LUK and the core's LUK wrap.
pub fn disable(core: &VaultCore, data_dir: &Path) -> Result<(), HelloError> {
if let Some(rec) = load(data_dir) {
platform::delete(&rec.credential);
}
let _ = std::fs::remove_file(path(data_dir));
if core.has_local_unlock() {
core.disable_local_unlock()?;
}
Ok(())
}
/// After a master-password unlock: restarts the clock for the next one.
pub fn password_unlocked(data_dir: &Path) {
if let Some(mut rec) = load(data_dir) {
rec.password_at = now();
let _ = save(data_dir, &rec);
}
}
fn forget(core: &VaultCore, data_dir: &Path, rec: &Record) {
platform::delete(&rec.credential);
let _ = std::fs::remove_file(path(data_dir));
let _ = core.disable_local_unlock();
}
fn path(data_dir: &Path) -> PathBuf {
data_dir.join(FILE)
}
fn load(data_dir: &Path) -> Option<Record> {
let bytes = private_file::read(&path(data_dir)).ok().flatten()?;
serde_json::from_slice::<Record>(&bytes).ok().filter(|r| r.v == 1)
}
fn save(data_dir: &Path, rec: &Record) -> Result<(), HelloError> {
let bytes = serde_json::to_vec(rec).map_err(|e| HelloError::Other(e.to_string()))?;
private_file::write(&path(data_dir), &bytes).map_err(|e| HelloError::Other(e.to_string()))
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Due after [`PASSWORD_EVERY`], and when the clock went backwards.
fn password_due(password_at: u64, now: u64) -> bool {
now < password_at || now - password_at >= PASSWORD_EVERY.as_secs()
}
fn random<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
getrandom::getrandom(&mut b).expect("OS random number generator");
b
}
/// One Windows Hello key per vault folder (a second instance with `KESTROVE_DATA_DIR` gets its own).
fn credential_name(data_dir: &Path, account_id: &str) -> String {
let folder = Sha256::digest(data_dir.to_string_lossy().as_bytes());
let short: String = folder[..4].iter().map(|b| format!("{b:02x}")).collect();
format!("Kestrove {account_id} {short}")
}
fn challenge(salt: &[u8]) -> Vec<u8> {
[CHALLENGE_LABEL, salt].concat()
}
fn wrap_key(signature: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
let mut out = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(salt), signature).expand(WRAP_INFO, out.as_mut()).expect("32 bytes is a valid length");
out
}
fn aad(account_id: &str) -> Vec<u8> {
[AAD_LABEL, account_id.as_bytes()].concat()
}
fn seal(signature: &[u8], salt: &[u8], luk: &[u8], account_id: &str) -> Result<([u8; 24], Vec<u8>), HelloError> {
let key = wrap_key(signature, salt);
let nonce: [u8; 24] = random();
let ct = XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
.encrypt(XNonce::from_slice(&nonce), Payload { msg: luk, aad: &aad(account_id) })
.map_err(|_| HelloError::Other("Couldn't seal the unlock key.".into()))?;
Ok((nonce, ct))
}
fn open(signature: &[u8], salt: &[u8], nonce: &[u8], ct: &[u8], account_id: &str) -> Option<Zeroizing<Vec<u8>>> {
if nonce.len() != 24 {
return None;
}
let key = wrap_key(signature, salt);
XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
.decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad: &aad(account_id) })
.ok()
.map(Zeroizing::new)
}
#[cfg(windows)]
mod platform {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use windows::core::{Array, HSTRING};
use windows::Security::Credentials::{
KeyCredential, KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus,
};
use windows::Security::Cryptography::CryptographicBuffer;
use windows_sys::Win32::UI::WindowsAndMessaging::{FindWindowW, SetForegroundWindow};
use zeroize::Zeroizing;
use super::HelloError;
fn other(e: windows::core::Error) -> HelloError {
HelloError::Other(format!("Windows Hello failed: {}", e.message()))
}
pub fn is_supported() -> bool {
KeyCredentialManager::IsSupportedAsync().and_then(|op| op.join()).unwrap_or(false)
}
/// Creates (or replaces) the key and signs `challenge` with it.
pub fn create_and_sign(name: &str, challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, HelloError> {
if !is_supported() {
return Err(HelloError::Unavailable);
}
let _focus = raise_prompt();
let r = KeyCredentialManager::RequestCreateAsync(
&HSTRING::from(name),
KeyCredentialCreationOption::ReplaceExisting,
)
.and_then(|op| op.join())
.map_err(other)?;
check(r.Status().map_err(other)?)?;
sign_with(&r.Credential().map_err(other)?, challenge)
}
pub fn sign(name: &str, challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, HelloError> {
if !is_supported() {
return Err(HelloError::Unavailable);
}
let r = KeyCredentialManager::OpenAsync(&HSTRING::from(name)).and_then(|op| op.join()).map_err(other)?;
check(r.Status().map_err(other)?)?;
let _focus = raise_prompt();
sign_with(&r.Credential().map_err(other)?, challenge)
}
pub fn delete(name: &str) {
let _ = KeyCredentialManager::DeleteAsync(&HSTRING::from(name)).and_then(|op| op.join());
}
fn sign_with(cred: &KeyCredential, challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, HelloError> {
let data = CryptographicBuffer::CreateFromByteArray(challenge).map_err(other)?;
let r = cred.RequestSignAsync(&data).and_then(|op| op.join()).map_err(other)?;
check(r.Status().map_err(other)?)?;
let mut out = Array::<u8>::new();
CryptographicBuffer::CopyToByteArray(&r.Result().map_err(other)?, &mut out).map_err(other)?;
let sig = Zeroizing::new(out.to_vec());
out.fill(0);
Ok(sig)
}
fn check(status: KeyCredentialStatus) -> Result<(), HelloError> {
match status {
KeyCredentialStatus::Success => Ok(()),
KeyCredentialStatus::UserCanceled | KeyCredentialStatus::UserPrefersPassword => Err(HelloError::Cancelled),
KeyCredentialStatus::NotFound => Err(HelloError::Reset),
KeyCredentialStatus::SecurityDeviceLocked => {
Err(HelloError::Other("Windows Hello is locked after too many tries. Use your master password.".into()))
}
_ => Err(HelloError::Other("Windows Hello couldn't confirm it's you. Try again.".into())),
}
}
/// Stops raising prompts when dropped.
struct Focus(Arc<AtomicBool>);
impl Drop for Focus {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Windows Hello's prompt for a desktop app can open behind the app's window: bring each new one to the front.
fn raise_prompt() -> Focus {
let done = Arc::new(AtomicBool::new(false));
let stop = done.clone();
let _ = std::thread::Builder::new().name("hello-focus".into()).spawn(move || {
let class: Vec<u16> = "Credential Dialog Xaml Host\0".encode_utf16().collect();
let mut raised = std::ptr::null_mut();
for _ in 0..600 {
if stop.load(Ordering::Relaxed) {
return;
}
// SAFETY: a NUL-terminated class name and no window title.
let hwnd = unsafe { FindWindowW(class.as_ptr(), std::ptr::null()) };
if !hwnd.is_null() && hwnd != raised {
// SAFETY: a window handle FindWindowW just returned; a stale one only makes the call fail.
unsafe { SetForegroundWindow(hwnd) };
raised = hwnd;
}
std::thread::sleep(Duration::from_millis(100));
}
});
Focus(done)
}
}
#[cfg(not(windows))]
mod platform {
use zeroize::Zeroizing;
use super::HelloError;
pub fn is_supported() -> bool {
false
}
pub fn create_and_sign(_name: &str, _challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, HelloError> {
Err(HelloError::Unavailable)
}
pub fn sign(_name: &str, _challenge: &[u8]) -> Result<Zeroizing<Vec<u8>>, HelloError> {
Err(HelloError::Unavailable)
}
pub fn delete(_name: &str) {}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCOUNT: &str = "6f1c2c1e-6c0a-4b8e-9d55-0d6c3c1b2a10";
#[test]
fn seal_open_round_trip() {
let (sig, salt, luk) = ([7u8; 256], [1u8; 32], [9u8; 32]);
let (nonce, ct) = seal(&sig, &salt, &luk, ACCOUNT).unwrap();
assert_eq!(open(&sig, &salt, &nonce, &ct, ACCOUNT).unwrap().as_slice(), &luk);
}
#[test]
fn wrong_signature_salt_or_account_does_not_open() {
let (sig, salt, luk) = ([7u8; 256], [1u8; 32], [9u8; 32]);
let (nonce, ct) = seal(&sig, &salt, &luk, ACCOUNT).unwrap();
let mut other_sig = sig;
other_sig[0] ^= 1;
assert!(open(&other_sig, &salt, &nonce, &ct, ACCOUNT).is_none());
assert!(open(&sig, &[2u8; 32], &nonce, &ct, ACCOUNT).is_none());
assert!(open(&sig, &salt, &nonce, &ct, "another-account").is_none());
assert!(open(&sig, &salt, &nonce[..12], &ct, ACCOUNT).is_none());
}
#[test]
fn password_is_due_every_14_days_and_when_the_clock_goes_back() {
let t = 1_800_000_000;
assert!(!password_due(t, t));
assert!(!password_due(t, t + PASSWORD_EVERY.as_secs() - 1));
assert!(password_due(t, t + PASSWORD_EVERY.as_secs()));
assert!(password_due(t, t - 1));
}
#[test]
fn challenge_and_name_depend_on_salt_and_folder() {
assert_ne!(challenge(&[1; 32]), challenge(&[2; 32]));
let a = credential_name(Path::new("C:/a"), ACCOUNT);
assert_eq!(a, credential_name(Path::new("C:/a"), ACCOUNT));
assert_ne!(a, credential_name(Path::new("C:/b"), ACCOUNT));
assert!(a.starts_with("Kestrove "));
}
}
+2
View File
@@ -5,9 +5,11 @@
pub mod autolock;
pub mod device_key;
pub mod hello;
pub mod qr;
pub mod sync;
pub mod title_bar;
pub mod updates;
pub use kv_ffi::bridge::{broker, channel, passkey, words};
pub use kv_ipc::{framing, ipc, private_file};
+135 -9
View File
@@ -3,19 +3,21 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{io, thread};
use kestrove_desktop::autolock::{self, Activity, Reason};
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::device_key::DeviceKey;
use kestrove_desktop::hello::{self, HelloError};
use kestrove_desktop::passkey::PasskeyView;
use kestrove_desktop::sync::{
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
SyncStatus,
};
use kestrove_desktop::updates::{UpdateInfo, Updates};
use kestrove_desktop::{ipc, private_file};
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize};
@@ -34,6 +36,9 @@ struct App {
data_dir: PathBuf,
ipc_error: Mutex<Option<String>>,
idle_minutes: Arc<AtomicU64>,
/// Windows Hello is set up on this computer (checked off the main thread; see `refresh_hello`).
hello_available: Arc<AtomicBool>,
updates: Arc<Updates>,
// Kept alive: on X11 the clipboard owner must outlive the copy.
clipboard: Mutex<Option<arboard::Clipboard>>,
}
@@ -42,11 +47,35 @@ impl App {
fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json")
}
fn save_settings(&self) -> CmdResult<()> {
let s = Settings {
idle_minutes: self.idle_minutes.load(Ordering::Relaxed),
check_updates: self.updates.automatic(),
};
let bytes = serde_json::to_vec(&s).map_err(|e| e.to_string())?;
private_file::write(&self.settings_path(), &bytes).map_err(|e| e.to_string())
}
/// Re-checks Windows Hello in the background (it can be set up or removed while Kestrove runs).
fn refresh_hello(&self) {
let available = self.hello_available.clone();
let _ = thread::Builder::new().name("hello-check".into()).spawn(move || {
available.store(hello::is_supported(), Ordering::Relaxed);
});
}
}
#[derive(Serialize, Deserialize)]
struct Settings {
idle_minutes: u64,
/// Check for a new version once a day (Windows; see `updates`).
#[serde(default = "yes")]
check_updates: bool,
}
fn yes() -> bool {
true
}
#[derive(Serialize)]
@@ -58,6 +87,8 @@ struct AppState {
/// Browser integration could not start (e.g. another instance is running).
ipc_error: Option<String>,
idle_minutes: u64,
/// Unlock with Windows Hello: can be turned on, is on, is offered on the unlock screen now.
hello: hello::Status,
}
#[derive(Serialize)]
@@ -179,6 +210,7 @@ fn state(app: State<'_, App>) -> AppState {
remembers_secret_key: app.sync.stored_secret_key().is_some(),
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
hello: hello::status(&app.core, &app.data_dir, app.hello_available.load(Ordering::Relaxed)),
}
}
@@ -210,11 +242,45 @@ async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option
if let Some(sk) = typed {
app.sync.remember_secret_key(&sk);
}
hello::password_unlocked(&app.data_dir);
app.activity.touch();
app.sync.request_sync();
Ok(())
}
/// Windows Hello calls block until the prompt closes, so they run off the main thread and the async runtime.
async fn off_thread<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> CmdResult<T> {
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| e.to_string())
}
/// Unlock with Windows Hello. `false` when the prompt was cancelled (the password field stays).
#[tauri::command]
async fn hello_unlock(app: State<'_, App>) -> CmdResult<bool> {
let (core, dir) = (app.core.clone(), app.data_dir.clone());
match off_thread(move || hello::unlock(&core, &dir)).await? {
Ok(()) => {
app.activity.touch();
app.sync.request_sync();
Ok(true)
}
Err(HelloError::Cancelled) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
/// Settings → "Unlock with Windows Hello". `false` when turning it on was cancelled.
#[tauri::command]
async fn set_hello(app: State<'_, App>, enabled: bool) -> CmdResult<bool> {
let (core, dir) = (app.core.clone(), app.data_dir.clone());
let r = off_thread(move || if enabled { hello::enable(&core, &dir) } else { hello::disable(&core, &dir) }).await?;
app.refresh_hello();
match r {
Ok(()) => Ok(true),
Err(HelloError::Cancelled) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn lock(app: State<'_, App>) -> CmdResult<()> {
app.core.lock();
@@ -232,8 +298,32 @@ fn touch(app: State<'_, App>) {
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
let minutes = minutes.clamp(1, 240);
app.idle_minutes.store(minutes, Ordering::Relaxed);
let bytes = serde_json::to_vec(&Settings { idle_minutes: minutes }).map_err(|e| e.to_string())?;
private_file::write(&app.settings_path(), &bytes).map_err(|e| e.to_string())
app.save_settings()
}
// ---------------------------------------------------------------- updates (Windows; see `updates`)
#[tauri::command]
fn update_info(app: State<'_, App>, handle: AppHandle) -> UpdateInfo {
app.updates.info(&handle)
}
#[tauri::command]
async fn check_update(app: State<'_, App>, handle: AppHandle) -> CmdResult<UpdateInfo> {
Ok(app.updates.check(&handle).await)
}
#[tauri::command]
fn set_update_checks(app: State<'_, App>, enabled: bool) -> CmdResult<()> {
app.updates.set_automatic(enabled);
app.save_settings()
}
/// "Update and restart": returns only if it failed; on success the installer replaces and restarts the app.
#[tauri::command]
async fn install_update(app: State<'_, App>, handle: AppHandle) -> CmdResult<()> {
let core = app.core.clone();
app.updates.install(&handle, move || core.lock()).await
}
#[tauri::command]
@@ -408,6 +498,26 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()>
Ok(())
}
/// The colour theme and appearance saved for all the account's devices (docs/02 "App settings").
#[derive(Serialize)]
struct ThemeView {
theme: Option<String>,
appearance: Option<String>,
}
#[tauri::command]
fn app_theme(app: State<'_, App>) -> CmdResult<ThemeView> {
let t = app.core.app_theme().map_err(err)?;
Ok(ThemeView { theme: t.theme, appearance: t.appearance })
}
#[tauri::command]
fn set_app_theme(app: State<'_, App>, theme: Option<String>, appearance: Option<String>) -> CmdResult<()> {
app.core.set_app_theme(theme, appearance).map_err(err)?;
app.sync.request_sync();
Ok(())
}
#[tauri::command]
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
app.broker.pending_passkeys()
@@ -619,12 +729,14 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
let idle_minutes = private_file::read(&data_dir.join("settings.json"))
let settings = private_file::read(&data_dir.join("settings.json"))
.ok()
.flatten()
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok())
.map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok());
let idle_minutes = settings.as_ref().map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
let idle_minutes = Arc::new(AtomicU64::new(idle_minutes));
let updates = Updates::new(settings.as_ref().is_none_or(|s| s.check_updates));
updates.clone().spawn(app.handle().clone());
let ipc_error = start_ipc(broker.clone()).err().map(|e| {
eprintln!("kestrove: browser integration disabled: {e}");
@@ -693,13 +805,19 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
data_dir,
ipc_error: Mutex::new(ipc_error),
idle_minutes,
hello_available: Arc::new(AtomicBool::new(false)),
updates,
clipboard: Mutex::new(None),
});
app.state::<App>().refresh_hello();
Ok(())
}
fn main() {
tauri::Builder::default()
let builder = tauri::Builder::default();
#[cfg(windows)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder
.setup(setup)
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
@@ -713,6 +831,8 @@ fn main() {
set_title_bar_colors,
create_account,
unlock,
hello_unlock,
set_hello,
lock,
touch,
set_idle_minutes,
@@ -730,6 +850,8 @@ fn main() {
pending_saves,
pending_passkeys,
set_tags,
app_theme,
set_app_theme,
sign_in_providers,
resolve_passkey,
resolve_save,
@@ -752,7 +874,11 @@ fn main() {
cancel_add_device,
approve_join,
send_join,
kit_qr
kit_qr,
update_info,
check_update,
set_update_checks,
install_update
])
.run(tauri::generate_context!())
.expect("error while running Kestrove");
+169
View File
@@ -0,0 +1,169 @@
//! In-app updates on Windows. The Microsoft Store doesn't update EXE installs, so the app does it itself.
//!
//! Tauri's updater fetches `latest.json` from the first address in tauri.conf.json → plugins.updater.endpoints
//! that answers (the Kestrove website; a mirror can be added after it), and offers the newer version. Nothing is
//! downloaded until the user chooses "Update and restart". The installer must carry a signature from the
//! Kestrove updater key, whose public half is in tauri.conf.json, so a compromised download server can hold
//! updates back but can't ship one.
//!
//! The check is a plain HTTPS request for that file: it tells the server this computer's IP address and the
//! app version, nothing from the vault. Settings can turn automatic checks off. Linux packages are updated by
//! their package manager, so there `supported` is false and nothing is checked.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
/// After startup, so the check never competes with unlocking and the first sync.
const FIRST_CHECK: Duration = Duration::from_secs(60);
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, Default, Serialize)]
pub struct UpdateInfo {
/// This build can update itself (Windows).
pub supported: bool,
pub current: String,
/// A newer version, ready to install.
pub available: Option<String>,
pub notes: Option<String>,
/// Checks once a day on its own.
pub automatic: bool,
pub checked_at: Option<u64>,
pub last_error: Option<String>,
}
#[derive(Default)]
struct State {
#[cfg(windows)]
pending: Option<tauri_plugin_updater::Update>,
available: Option<(String, Option<String>)>,
checked_at: Option<u64>,
last_error: Option<String>,
}
pub struct Updates {
automatic: AtomicBool,
state: Mutex<State>,
}
impl Updates {
pub fn new(automatic: bool) -> Arc<Self> {
Arc::new(Self { automatic: AtomicBool::new(automatic), state: Mutex::default() })
}
pub fn automatic(&self) -> bool {
self.automatic.load(Ordering::Relaxed)
}
pub fn set_automatic(&self, on: bool) {
self.automatic.store(on, Ordering::Relaxed);
}
pub fn info(&self, app: &AppHandle) -> UpdateInfo {
let s = self.state.lock().unwrap_or_else(|e| e.into_inner());
UpdateInfo {
supported: cfg!(windows),
current: app.package_info().version.to_string(),
available: s.available.as_ref().map(|(v, _)| v.clone()),
notes: s.available.as_ref().and_then(|(_, n)| n.clone()),
automatic: self.automatic(),
checked_at: s.checked_at,
last_error: s.last_error.clone(),
}
}
/// Asks the update server now. Emits `update-available` when there is a newer version.
pub async fn check(&self, app: &AppHandle) -> UpdateInfo {
#[cfg(windows)]
{
use tauri_plugin_updater::UpdaterExt;
let result = match app.updater() {
Ok(u) => u.check().await,
Err(e) => Err(e),
};
let found = {
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
s.checked_at = Some(now());
match result {
Ok(Some(update)) => {
s.available = Some((update.version.clone(), update.body.clone()));
s.pending = Some(update);
s.last_error = None;
true
}
Ok(None) => {
(s.available, s.pending, s.last_error) = (None, None, None);
false
}
// Offline or the server is down: keep what an earlier check found.
Err(e) => {
s.last_error = Some(format!("Couldn't check for updates: {e}"));
false
}
}
};
let info = self.info(app);
if found {
let _ = app.emit("update-available", &info);
}
info
}
#[cfg(not(windows))]
self.info(app)
}
/// Downloads the update the last check found, checks its signature and starts the installer. On success the
/// app exits (after `before_exit`) and the installer starts the new version, so this returns only on failure.
pub async fn install(&self, app: &AppHandle, before_exit: impl Fn() + Send + Sync + 'static) -> Result<(), String> {
#[cfg(windows)]
{
use tauri_plugin_updater::UpdaterExt;
let pending = self.state.lock().unwrap_or_else(|e| e.into_inner()).pending.clone();
let Some(update) = pending else { return Err("No update to install. Check again.".into()) };
// The installer must be signed by the updater key; the builder reads the key and endpoints from
// tauri.conf.json, plus the hook that locks the vault before the installer takes over.
let updater = app.updater_builder().on_before_exit(before_exit).build().map_err(|e| e.to_string())?;
let update = updater.check().await.map_err(|e| e.to_string())?.filter(|u| u.version == update.version);
let update = update.ok_or("That update is no longer offered. Check again.")?;
let (handle, mut done) = (app.clone(), 0u64);
update
.download_and_install(
move |chunk, total| {
done += chunk as u64;
let _ = handle.emit("update-progress", (done, total));
},
|| {},
)
.await
.map_err(|e| format!("The update couldn't be installed: {e}"))
}
#[cfg(not(windows))]
{
let _ = (app, before_exit);
Err("Install updates with your package manager.".into())
}
}
/// Checks a minute after startup, then once a day, while automatic checks are on.
pub fn spawn(self: Arc<Self>, app: AppHandle) {
if !cfg!(windows) {
return;
}
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FIRST_CHECK).await;
loop {
if self.automatic() {
self.check(&app).await;
}
tokio::time::sleep(EVERY).await;
}
});
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
+31 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Kestrove",
"version": "1.1.0",
"version": "1.2.1",
"identifier": "app.kestrove.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -41,11 +41,41 @@
"icons/icon.ico",
"icons/icon.png"
],
"linux": {
"deb": {
"files": {
"/usr/lib/mozilla/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.firefox.json",
"/etc/opt/chrome/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/chromium/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/opt/edge/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json"
}
},
"rpm": {
"files": {
"/usr/lib/mozilla/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.firefox.json",
"/etc/opt/chrome/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/chromium/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/opt/edge/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json"
}
}
},
"windows": {
"nsis": {
"installerHooks": "windows/hooks.nsh",
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZFRUM4RDQ2Mzk0Rjg5Q0IKUldUTGlVODVSbzNzYml1ZTN3d0pBN1IwWkRoZXNROTRmbk5WOE5CR2VJQVYrTUJLN21PNzI4eHEK",
"requireSignedVersion": true,
"endpoints": [
"https://kestrove.samialabri.com/updates/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+16 -1
View File
@@ -3,11 +3,25 @@
; native-messaging/install.ps1: a manifest next to kestrove-nmh.exe ("path" relative to the manifest, which
; Chrome and Firefox allow on Windows) and one registry key per browser.
; Extension ids: keep in sync with kestrove-apple/App/BrowserIntegration.swift (NativeHostRegistration).
; Chrome: the Chrome Web Store build, then the unpacked development build (pinned by the manifest key).
!define KV_HOST "app.kestrove.nmh"
!define KV_CHROME_ORIGINS '"chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"'
!define KV_CHROME_ORIGINS '"chrome-extension://pmhfoafjoogobhfadaicklhkldgnafmk/", "chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"'
!define KV_FIREFOX_IDS '"[email protected]"'
; A browser keeps kestrove-nmh.exe running while the extension is connected, which locks the file: an update then
; can't replace it and an uninstall can't delete it. The installer only closes the app itself, so stop the helper
; too. It holds no state (it only relays encrypted messages); the browser starts it again when it's next needed.
!macro KV_STOP_HELPER
nsExec::Exec '"$SYSDIR\taskkill.exe" /F /IM kestrove-nmh.exe'
Pop $0
Sleep 500
!macroend
!macro NSIS_HOOK_PREINSTALL
!insertmacro KV_STOP_HELPER
!macroend
!macro NSIS_HOOK_POSTINSTALL
FileOpen $0 "$INSTDIR\${KV_HOST}.chrome.json" w
FileWrite $0 '{"name": "${KV_HOST}", "description": "Kestrove", "path": "kestrove-nmh.exe", "type": "stdio", "allowed_origins": [${KV_CHROME_ORIGINS}]}'
@@ -24,6 +38,7 @@
!macroend
!macro NSIS_HOOK_PREUNINSTALL
!insertmacro KV_STOP_HELPER
DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\${KV_HOST}"
DeleteRegKey HKCU "Software\Chromium\NativeMessagingHosts\${KV_HOST}"
DeleteRegKey HKCU "Software\Microsoft\Edge\NativeMessagingHosts\${KV_HOST}"
+196 -6
View File
@@ -8,7 +8,9 @@ import {
pill, ring, secret, tile, type IconName,
} from "./ui/kv";
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
interface HelloStatus { available: boolean; enabled: boolean; offered: boolean }
interface UpdateInfo { supported: boolean; current: string; available: string | null; notes: string | null; automatic: boolean; checked_at: number | null; last_error: string | null }
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number; hello: HelloStatus }
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean; tags: string[] }
interface SignInView { provider: string; provider_name: string; account: string | null }
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; tags: string[]; sign_in_with: SignInView | null }
@@ -111,6 +113,64 @@ function setTheme(t: Theme) {
applyTheme(storedTheme());
// ------------------------------------------------------------------ colour theme (kestrove-spec/design/themes.json)
const ACCENTS = [
["spruce", "Spruce"], ["ocean", "Ocean"], ["violet", "Violet"], ["rose", "Rose"],
["crimson", "Crimson"], ["amber", "Amber"], ["graphite", "Graphite"],
] as const;
type Accent = (typeof ACCENTS)[number][0];
const isAccent = (s: unknown): s is Accent => ACCENTS.some(([id]) => id === s);
function stored(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function store(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
// Not persisted; still applied for this run.
}
}
function storedAccent(): Accent {
const a = stored("accent");
return isAccent(a) ? a : "spruce";
}
function applyAccent(a: Accent) {
if (a === "spruce") delete document.documentElement.dataset.accent;
else document.documentElement.dataset.accent = a;
}
/** "Same theme on all my devices": follow the theme saved in the vault (docs/02 "App settings"). */
const syncsTheme = () => stored("syncTheme") === "1";
/** After unlocking and after each sync: take the account's theme, if this computer follows it. */
async function adoptSyncedTheme() {
if (!syncsTheme()) return;
const t = await invoke<{ theme: string | null; appearance: string | null }>("app_theme").catch(() => null);
if (!t) return;
if (isAccent(t.theme) && t.theme !== storedAccent()) {
store("accent", t.theme);
applyAccent(t.theme);
}
if ((t.appearance === "system" || t.appearance === "light" || t.appearance === "dark") && t.appearance !== storedTheme()) setTheme(t.appearance);
}
/** The theme changed here: save it for the other devices, when this computer follows the account's theme. */
async function publishTheme() {
if (!syncsTheme()) return;
await invoke("set_app_theme", { theme: storedAccent(), appearance: storedTheme() }).catch(() => undefined);
}
applyAccent(storedAccent());
// ------------------------------------------------------------------ chrome
/// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge).
@@ -416,7 +476,22 @@ function renderUnlock(state: AppState) {
const pw = input({ type: "password", autocomplete: "current-password" });
const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true);
const err = errBox();
const btn = button("Unlock", "primary", undefined, "block");
// Windows Hello, when offered, is the screen's one primary action; the password stays below it.
const btn = button("Unlock", state.hello.offered ? "quiet" : "primary", undefined, "block");
const hello = button("Unlock with Windows Hello", "primary", undefined, "block");
hello.onclick = async () => {
hello.disabled = true;
err.replaceChildren();
try {
if (await invoke<boolean>("hello_unlock")) await render();
else hello.disabled = false;
} catch (e) {
setErr(err, e);
// A reset turned Windows Hello off: drop the button.
if (!(await invoke<AppState>("state")).hello.offered) hello.remove();
else hello.disabled = false;
}
};
const submit = async () => {
btn.disabled = true;
err.replaceChildren();
@@ -436,11 +511,15 @@ function renderUnlock(state: AppState) {
pw.onkeydown = (e) => {
if (e.key === "Enter") void submit();
};
const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"), field("Master password", pw));
const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"));
if (state.hello.offered) card.append(hello, p("kv-small kv-muted", "Or use your master password:"));
else if (state.hello.enabled) card.append(p("kv-small kv-muted", "Kestrove asks for your master password every 14 days, even with Windows Hello on."));
card.append(field("Master password", pw));
if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional."));
card.append(err, btn);
centered(card);
pw.focus();
if (state.hello.offered) hello.focus();
else pw.focus();
}
// ------------------------------------------------------------------ vault
@@ -608,6 +687,7 @@ function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) {
if (s.restored_items > 0) {
parts.push(banner("info", `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} came back`, "They were deleted on another device but edited here."));
}
if (update?.available) parts.unshift(updateBanner(update.available));
const now = iconButton("sync", "Sync now", async () => {
now.disabled = true;
await invoke("sync_now").catch(() => undefined);
@@ -767,6 +847,54 @@ async function renderSettings(target: HTMLElement, state: AppState) {
}));
theme.setAttribute("role", "group");
theme.setAttribute("aria-label", "Appearance");
theme.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => void publishTheme()));
const swatches = h("div", "swatches", ...ACCENTS.map(([id, name]) => {
const b = h("button", "swatch", h("span", "dot"), h("span", "", name));
b.type = "button";
b.dataset.swatch = id;
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", String(storedAccent() === id));
b.onclick = () => {
store("accent", id);
applyAccent(id);
swatches.querySelectorAll("button").forEach((x) => x.setAttribute("aria-checked", String(x === b)));
void publishTheme();
};
return b;
}));
swatches.setAttribute("role", "radiogroup");
swatches.setAttribute("aria-label", "Colour");
const syncTheme = input({ type: "checkbox", checked: syncsTheme() });
syncTheme.className = "switch";
syncTheme.setAttribute("aria-label", "Same theme on all my devices");
syncTheme.onchange = async () => {
store("syncTheme", syncTheme.checked ? "1" : "0");
if (!syncTheme.checked) return;
// Adopt the account's theme if it has one, else share this computer's.
const t = await invoke<{ theme: string | null; appearance: string | null }>("app_theme").catch(() => null);
if (t && (t.theme || t.appearance)) await adoptSyncedTheme();
else await publishTheme();
await renderSettings(target, state);
};
const helloSwitch = input({ type: "checkbox", checked: state.hello.enabled });
helloSwitch.className = "switch";
helloSwitch.setAttribute("aria-label", "Unlock with Windows Hello");
const helloErr = errBox();
helloSwitch.onchange = async () => {
helloSwitch.disabled = true;
helloErr.replaceChildren();
try {
if (!(await invoke<boolean>("set_hello", { enabled: helloSwitch.checked }))) helloSwitch.checked = !helloSwitch.checked;
} catch (e) {
helloSwitch.checked = !helloSwitch.checked;
setErr(helloErr, e);
}
helloSwitch.disabled = false;
};
const helloRow = state.hello.available || state.hello.enabled
? h("div", "kv-frow", h("div", "val", h("span", "", "Unlock with Windows Hello"),
h("span", "kv-hint", "Your face, fingerprint or PIN instead of your master password. Kestrove still asks for the password every 14 days."), helloErr), helloSwitch)
: null;
const list = h("div", "kv-card rows");
for (const pr of pairings) {
list.append(h("div", "kv-frow",
@@ -784,12 +912,17 @@ async function renderSettings(target: HTMLElement, state: AppState) {
h("h2", "kv-title-2", "Settings"),
section("General",
h("div", "kv-card rows",
h("div", "kv-frow", h("div", "val", h("span", "", "Appearance")), theme),
h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))),
h("div", "kv-frow", h("div", "val", h("span", "", "Colour")), swatches),
h("div", "kv-frow", h("div", "val", h("span", "", "Light or dark")), theme),
h("div", "kv-frow", h("div", "val", h("span", "", "Same theme on all my devices"),
h("span", "kv-hint", "Saved in your encrypted vault, and used on every device where this is on.")), syncTheme),
h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle),
helloRow)),
section("Sync", syncSection),
section("Devices", devicesSection),
section("Emergency Kit", kitSection()),
section("Paired browsers", list),
updatesSection(await invoke<UpdateInfo>("update_info"), () => renderSettings(target, state)),
));
const status = await invoke<SyncStatus>("sync_status");
renderSyncSettings(syncSection, status, () => renderSettings(target, state));
@@ -1141,8 +1274,64 @@ function saveCard(s: SaveView): HTMLElement {
h("div", "actions end", no, yes));
}
// ------------------------------------------------------------------ updates (Windows; src-tauri/src/updates.rs)
let update: UpdateInfo | null = null;
/// "Downloading… 40%" while an update downloads; the installer then closes the app.
let updateStatus: HTMLElement | null = null;
function updateBanner(version: string): HTMLElement {
updateStatus = h("span", "kv-caption kv-muted");
const go = button("Update and restart", "text", async () => {
go.disabled = true;
updateStatus?.replaceChildren("Downloading…");
try {
await invoke("install_update");
} catch (e) {
updateStatus?.replaceChildren(String(e));
go.disabled = false;
}
});
return banner("info", `Kestrove ${version} is available`, updateStatus, go);
}
async function refreshUpdate(info?: UpdateInfo) {
update = info ?? (await invoke<UpdateInfo>("update_info").catch(() => null));
if (syncBox?.isConnected) showSync(await invoke<SyncStatus>("sync_status"));
}
function updatesSection(info: UpdateInfo, reload: () => Promise<void>): HTMLElement | null {
if (!info.supported) return null;
const auto = input({ type: "checkbox", checked: info.automatic });
auto.className = "switch";
auto.setAttribute("aria-label", "Check for updates automatically");
auto.onchange = () => void invoke("set_update_checks", { enabled: auto.checked });
const status = info.available
? `Kestrove ${info.available} is available.`
: info.last_error ?? (info.checked_at ? `Up to date. Checked ${ago(info.checked_at)}.` : "Not checked yet.");
const check = button("Check now", "quiet", async () => {
check.disabled = true;
await refreshUpdate(await invoke<UpdateInfo>("check_update"));
await reload();
});
const rows = h("div", "kv-card rows",
h("div", "kv-frow", h("div", "val", h("span", "", `Kestrove ${info.current}`),
h("span", `kv-hint ${info.last_error && !info.available ? "kv-error-text" : ""}`, status)), check),
h("div", "kv-frow", h("div", "val", h("span", "", "Check for updates automatically"),
h("span", "kv-hint", "Once a day, Kestrove asks its update server for the latest version. Nothing from your vault is sent. Updates install only when you choose.")), auto));
if (info.available) rows.append(h("div", "kv-frow", updateBanner(info.available)));
return section("Updates", rows);
}
// ------------------------------------------------------------------ wiring
void refreshUpdate();
void listen<UpdateInfo>("update-available", (e) => void refreshUpdate(e.payload));
void listen<[number, number | null]>("update-progress", (e) => {
const [done, total] = e.payload;
updateStatus?.replaceChildren(total ? `Downloading… ${Math.floor((done / total) * 100)}%` : "Downloading…");
});
void listen("pairing-request", () => render());
void listen("save-request", () => render());
void listen("passkey-request", () => render());
@@ -1157,6 +1346,7 @@ void listen<SyncStatus>("sync-status", (e) => {
showSync(s);
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
lastChanges = s.changes;
void adoptSyncedTheme();
if (s.integrity_alarm && !alarmShown) {
alarmShown = true;
openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal)));
+14
View File
@@ -104,3 +104,17 @@ summary { cursor: pointer; color: var(--text-2); }
.tag-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1\.5); margin: 0 0 var(--space-3); }
.tag-row .tag { font: 500 12px/16px var(--font-sans); color: var(--text-2); background: var(--surface-2); border-radius: var(--radius-sm); padding: 4px 8px; }
.rail-tags { display: contents; }
/* Colour theme swatches (Settings). Fixed colours: each dot shows its theme, whatever theme is active. */
.swatches { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.swatch { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); padding: var(--space-1); border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-2); font: 500 12px/16px var(--font-sans); cursor: pointer; }
.swatch[aria-checked="true"] { color: var(--text); }
.swatch .dot { width: 26px; height: 26px; border-radius: 50%; box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px transparent; }
.swatch[aria-checked="true"] .dot { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--text-2); }
.swatch[data-swatch="spruce"] .dot { background: #19604f; }
.swatch[data-swatch="ocean"] .dot { background: #1d5a9e; }
.swatch[data-swatch="violet"] .dot { background: #5a43ad; }
.swatch[data-swatch="rose"] .dot { background: #a3305f; }
.swatch[data-swatch="crimson"] .dot { background: #ad2828; }
.swatch[data-swatch="amber"] .dot { background: #8f4a00; }
.swatch[data-swatch="graphite"] .dot { background: #3d4a52; }
+113
View File
@@ -122,3 +122,116 @@
--shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080;
--shadow-lg: 0 24px 64px -16px #000000b3;
}
/* Colour themes (kestrove-spec/design/themes.json): only the brand colour changes; Spruce is the default above.
html[data-accent] comes from Settings; the dark rules are more specific, so they win whenever dark applies. */
:root[data-accent="ocean"] {
--primary: #1d5a9e;
--primary-hover: #174a83;
--primary-soft: #dde9f7;
--on-primary: #ffffff;
}
:root[data-accent="violet"] {
--primary: #5a43ad;
--primary-hover: #4a378f;
--primary-soft: #e8e3f7;
--on-primary: #ffffff;
}
:root[data-accent="rose"] {
--primary: #a3305f;
--primary-hover: #86274e;
--primary-soft: #f7e1eb;
--on-primary: #ffffff;
}
:root[data-accent="crimson"] {
--primary: #ad2828;
--primary-hover: #8f2121;
--primary-soft: #f8e0de;
--on-primary: #ffffff;
}
:root[data-accent="amber"] {
--primary: #8f4a00;
--primary-hover: #753d00;
--primary-soft: #f8e8d6;
--on-primary: #ffffff;
}
:root[data-accent="graphite"] {
--primary: #3d4a52;
--primary-hover: #2f3a40;
--primary-soft: #e3e8eb;
--on-primary: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root[data-accent="ocean"]:not([data-theme="light"]) {
--primary: #7ab4f0;
--primary-hover: #9cc8f5;
--primary-soft: #162a40;
--on-primary: #061527;
}
:root[data-accent="violet"]:not([data-theme="light"]) {
--primary: #b3a2f0;
--primary-hover: #c7baf5;
--primary-soft: #272042;
--on-primary: #140d2b;
}
:root[data-accent="rose"]:not([data-theme="light"]) {
--primary: #f28fbd;
--primary-hover: #f6aecf;
--primary-soft: #3f1c2d;
--on-primary: #2a0a19;
}
:root[data-accent="crimson"]:not([data-theme="light"]) {
--primary: #f3908a;
--primary-hover: #f6aea9;
--primary-soft: #401b1a;
--on-primary: #2b0807;
}
:root[data-accent="amber"]:not([data-theme="light"]) {
--primary: #f2a65a;
--primary-hover: #f6bd84;
--primary-soft: #3d2711;
--on-primary: #241200;
}
:root[data-accent="graphite"]:not([data-theme="light"]) {
--primary: #b9c6ce;
--primary-hover: #cfd9df;
--primary-soft: #232c31;
--on-primary: #10171b;
}
}
:root[data-accent="ocean"][data-theme="dark"] {
--primary: #7ab4f0;
--primary-hover: #9cc8f5;
--primary-soft: #162a40;
--on-primary: #061527;
}
:root[data-accent="violet"][data-theme="dark"] {
--primary: #b3a2f0;
--primary-hover: #c7baf5;
--primary-soft: #272042;
--on-primary: #140d2b;
}
:root[data-accent="rose"][data-theme="dark"] {
--primary: #f28fbd;
--primary-hover: #f6aecf;
--primary-soft: #3f1c2d;
--on-primary: #2a0a19;
}
:root[data-accent="crimson"][data-theme="dark"] {
--primary: #f3908a;
--primary-hover: #f6aea9;
--primary-soft: #401b1a;
--on-primary: #2b0807;
}
:root[data-accent="amber"][data-theme="dark"] {
--primary: #f2a65a;
--primary-hover: #f6bd84;
--primary-soft: #3d2711;
--on-primary: #241200;
}
:root[data-accent="graphite"][data-theme="dark"] {
--primary: #b9c6ce;
--primary-hover: #cfd9df;
--primary-soft: #232c31;
--on-primary: #10171b;
}