Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608d85c297 | ||
|
|
32eadc4d03 | ||
|
|
57b728c7e3 | ||
|
|
d70dcc04b6 | ||
|
|
70ccff34f2 | ||
|
|
c2273e202e | ||
|
|
95ce03ba06 | ||
|
|
7b61a4851b | ||
|
|
27f13b4743 | ||
|
|
10a9507aa2 | ||
|
|
fb53b7d907 |
@@ -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`
|
The Kestrove desktop app for **Windows 10/11 and Linux**: your password manager, with a vault encrypted on
|
||||||
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
|
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.
|
**What it does:**
|
||||||
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.
|
- 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
|
```bash
|
||||||
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
|
npm ci
|
||||||
npm install
|
npm run tauri dev # run it from source
|
||||||
npm run tauri dev
|
npm run tauri build # an installer for the system you're on (Windows: .exe; Linux: .deb/.rpm)
|
||||||
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)
|
|
||||||
(cd src-tauri && cargo clippy --all-targets -- -D warnings && cargo test)
|
(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
|
**Release builds from a Mac** (both write to `../release/desktop/`):
|
||||||
`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.
|
|
||||||
|
|
||||||
## 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 .
|
||||||
|
|
||||||
```
|
# Windows installer, cross-compiled. Setting XWIN_ACCEPT_LICENSE=1 accepts Microsoft's
|
||||||
extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (broker)
|
# license for the Windows SDK files it downloads; the script header lists what to install first.
|
||||||
└──────────── pairing channel: X25519 → 6 words → AES-256-GCM, end to end ───────────┘
|
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),
|
**Publish them on Gitea:** raise `version` in `package.json`, `src-tauri/tauri.conf.json` and
|
||||||
`crates/kv-nmh` (the host) and `kv-ffi`'s `browser-bridge` feature (`src/bridge/`: broker, pairing
|
`src-tauri/Cargo.toml`, tag the commit (`git tag v<version> && git push origin v<version>`), then:
|
||||||
channel). This crate re-exports them under the old module names.
|
|
||||||
|
|
||||||
* **IPC** (`kv-ipc`): Unix socket `$XDG_RUNTIME_DIR/kestrove/nmh.sock` (macOS: the per-user temp
|
```bash
|
||||||
dir), dir 0700, socket 0600, peer uid checked on both ends. Windows: `\\.\pipe\kestrove-nmh-<user>`,
|
GITEA_TOKEN=<token with write:repository> scripts/gitea-release.sh
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
**Put it on the website and offer it as an update (Windows):**
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
This signs the installer with the updater key, copies it to `downloads/` in `kestrove-website` next to this repo,
|
||||||
with a signed-in phone, compare the 6-digit code, then type only the master password), **Emergency Kit**
|
and writes `updates/latest.json` there. Sign the installer with the code-signing certificate first, because the
|
||||||
(PDF, or a PNG/JPEG photo of its QR), **Paste setup code**, and **New account** (server URL + the server's
|
updater signature covers the final file. Installed copies are offered the update once the website is deployed.
|
||||||
`KV_SETUP_CODE`).
|
Keep the updater key and its password offline and backed up:
|
||||||
* **Device key** (`device_key.rs`): Windows TPM via CNG, else a DPAPI-sealed software key; Linux Secret Service,
|
the app only accepts updates signed with it, so losing it means existing installs can't update.
|
||||||
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.
|
|
||||||
|
|
||||||
## Roadmap (M3, kestrove-spec/docs/07)
|
## How the browser connection works
|
||||||
- [ ] 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
|
browser extension ─(native messaging)─▶ kestrove-nmh ─(local connection)─▶ Kestrove app
|
||||||
- [x] Auto-lock on idle, screen lock and suspend
|
└──────────── encrypted end to end with a key you confirm by comparing six words ──────────┘
|
||||||
- [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)
|
- `kestrove-nmh` is a tiny helper that the browser starts. It only passes encrypted messages along; it can't
|
||||||
- [ ] MSI/deb installers run the manifest installers
|
read them.
|
||||||
- [ ] Windows 11 passkey plugin provider
|
- 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`.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Native messaging manifests
|
# Native messaging manifests
|
||||||
|
|
||||||
`install.sh` (Linux, macOS) and `install.ps1` (Windows) fill in the placeholders and register the
|
`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
|
manifests for the current user. Chrome allows two ids by default: the Chrome Web Store build
|
||||||
to `kogapfibnnpeapfcjkkpgjddbhnjfein` by the manifest key in kestrove-browser, Firefox uses
|
(`pmhfoafjoogobhfadaicklhkldgnafmk`) and the unpacked development build, pinned to
|
||||||
`[email protected]`. Pass `--chrome-id` / `-ChromeId` for a Web Store build.
|
`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
|
```bash
|
||||||
native-messaging/install.sh # every browser with a profile dir, host from target/
|
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` |
|
| 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 |
|
| 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.
|
`allowed_origins` / `allowed_extensions` pin the host to **your** extension id.
|
||||||
That is the first line of defense against other extensions (docs/04 A8).
|
That is the first line of defense against other extensions (docs/04 A8).
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
# Linux/macOS: install.sh.
|
# Linux/macOS: install.sh.
|
||||||
#
|
#
|
||||||
# powershell -ExecutionPolicy Bypass -File install.ps1 [-Nmh C:\path\kestrove-nmh.exe]
|
# 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.
|
# 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).
|
# allowed_origins / allowed_extensions pin the host to our extension ids (docs/04 A8).
|
||||||
param(
|
param(
|
||||||
[string]$Nmh = "",
|
[string]$Nmh = "",
|
||||||
[string]$ChromeId = "kogapfibnnpeapfcjkkpgjddbhnjfein",
|
# The Chrome Web Store build, then the unpacked development build.
|
||||||
|
[string[]]$ChromeId = @("pmhfoafjoogobhfadaicklhkldgnafmk", "kogapfibnnpeapfcjkkpgjddbhnjfein"),
|
||||||
[string]$FirefoxId = "[email protected]",
|
[string]$FirefoxId = "[email protected]",
|
||||||
[string[]]$Browsers = @("chrome", "chromium", "edge", "firefox"),
|
[string[]]$Browsers = @("chrome", "chromium", "edge", "firefox"),
|
||||||
[switch]$Uninstall
|
[switch]$Uninstall
|
||||||
@@ -17,7 +18,7 @@ $ErrorActionPreference = "Stop"
|
|||||||
$HostName = "app.kestrove.nmh"
|
$HostName = "app.kestrove.nmh"
|
||||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$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" }
|
if ($FirefoxId -notmatch '^[A-Za-z0-9._@{}-]+$') { throw "bad -FirefoxId" }
|
||||||
|
|
||||||
$Keys = @{
|
$Keys = @{
|
||||||
@@ -48,7 +49,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
|||||||
foreach ($flavor in @("chrome", "firefox")) {
|
foreach ($flavor in @("chrome", "firefox")) {
|
||||||
$m = Get-Content -Raw (Join-Path $Here "$HostName.$flavor.json") | ConvertFrom-Json
|
$m = Get-Content -Raw (Join-Path $Here "$HostName.$flavor.json") | ConvertFrom-Json
|
||||||
$m.path = $Nmh
|
$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")
|
$m | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 (Join-Path $OutDir "$HostName.$flavor.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Registers the Kestrove native-messaging host with the browsers of the current user (Linux, macOS).
|
# Registers the Kestrove native-messaging host with the browsers of the current user (Linux, macOS).
|
||||||
# Windows: install.ps1.
|
# 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]
|
# [--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.
|
# 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
|
HOST=app.kestrove.nmh
|
||||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
NMH=""
|
NMH=""
|
||||||
# Development ids: the unpacked Chrome build is pinned by the manifest key in kestrove-browser/scripts/build.mjs.
|
# Chrome: the Chrome Web Store build, then the unpacked development build (pinned by the manifest key in
|
||||||
CHROME_ID=kogapfibnnpeapfcjkkpgjddbhnjfein
|
# kestrove-browser/scripts/build.mjs).
|
||||||
|
CHROME_IDS="pmhfoafjoogobhfadaicklhkldgnafmk kogapfibnnpeapfcjkkpgjddbhnjfein"
|
||||||
FIREFOX_ID=[email protected]
|
FIREFOX_ID=[email protected]
|
||||||
BROWSERS=""
|
BROWSERS=""
|
||||||
USER_DATA_DIR=""
|
USER_DATA_DIR=""
|
||||||
@@ -26,7 +27,7 @@ die() { echo "install.sh: $*" >&2; exit 1; }
|
|||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--nmh) NMH=$2; shift 2 ;;
|
--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 ;;
|
--firefox-id) FIREFOX_ID=$2; shift 2 ;;
|
||||||
--browsers) BROWSERS=$(echo "$2" | tr ',' ' '); shift 2 ;;
|
--browsers) BROWSERS=$(echo "$2" | tr ',' ' '); shift 2 ;;
|
||||||
--user-data-dir) USER_DATA_DIR=$2; shift 2 ;;
|
--user-data-dir) USER_DATA_DIR=$2; shift 2 ;;
|
||||||
@@ -36,7 +37,9 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
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"
|
echo "$FIREFOX_ID" | grep -Eq '^[A-Za-z0-9._@{}-]+$' || die "bad --firefox-id"
|
||||||
|
|
||||||
if [ "$UNINSTALL" = 0 ]; then
|
if [ "$UNINSTALL" = 0 ]; then
|
||||||
@@ -81,9 +84,13 @@ esac
|
|||||||
eval "d=\$dir_$b"
|
eval "d=\$dir_$b"
|
||||||
[ -d "$d" ] && BROWSERS="$BROWSERS $b"
|
[ -d "$d" ] && BROWSERS="$BROWSERS $b"
|
||||||
done
|
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
|
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
|
place() { # flavor dir
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kestrove-desktop",
|
"name": "kestrove-desktop",
|
||||||
"version": "1.1.0",
|
"version": "1.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kestrove-desktop",
|
"name": "kestrove-desktop",
|
||||||
"version": "1.1.0",
|
"version": "1.2.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.12.3"
|
"@tauri-apps/api": "2.12.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kestrove-desktop",
|
"name": "kestrove-desktop",
|
||||||
"version": "1.1.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/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)
|
||||||
#
|
#
|
||||||
@@ -15,16 +16,17 @@ V=$(node -p 'require("./package.json").version')
|
|||||||
TAG="v$V"
|
TAG="v$V"
|
||||||
AUTH="Authorization: token $GITEA_TOKEN"
|
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("")}})')
|
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
|
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")
|
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))')
|
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
|
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
|
[ -f "$f" ] || continue
|
||||||
echo "uploading $(basename "$f")"
|
echo "uploading $(basename "$f")"
|
||||||
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$ID/assets?name=$(basename "$f")" >/dev/null
|
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$ID/assets?name=$(basename "$f")" >/dev/null
|
||||||
done
|
done
|
||||||
echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG"
|
echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -963,6 +963,16 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -986,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -999,7 +1009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1698,6 +1708,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -2628,9 +2648,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2949,6 +2971,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"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]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3012,13 +3064,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kestrove-desktop"
|
name = "kestrove-desktop"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation",
|
"chacha20poly1305",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hayro",
|
"hayro",
|
||||||
|
"hkdf 0.12.4",
|
||||||
"image",
|
"image",
|
||||||
"keyring",
|
"keyring",
|
||||||
"kv-ffi",
|
"kv-ffi",
|
||||||
@@ -3033,10 +3087,12 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -3385,6 +3441,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3740,6 +3802,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3755,6 +3818,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3846,6 +3921,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3862,6 +3943,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -4510,15 +4605,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -4621,6 +4721,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -4630,6 +4742,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.16"
|
version = "0.103.16"
|
||||||
@@ -4662,6 +4801,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -4779,7 +4927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -5060,6 +5208,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
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]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -5289,6 +5453,27 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -5310,7 +5495,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -5320,7 +5505,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -5353,6 +5538,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -5376,7 +5572,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -5474,6 +5670,56 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
@@ -5484,7 +5730,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -5507,7 +5753,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -5581,7 +5827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -6458,6 +6704,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.10"
|
version = "1.0.10"
|
||||||
@@ -6659,6 +6914,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6924,7 +7190,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -7000,6 +7266,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kestrove-desktop"
|
name = "kestrove-desktop"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "Apache-2.0"
|
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"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
hayro = { version = "0.8", default-features = false }
|
hayro = { version = "0.8", default-features = false }
|
||||||
tokio = { version = "1", features = ["time", "sync", "macros"] }
|
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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -68,7 +71,12 @@ windows-sys = { version = "0.61", features = [
|
|||||||
"Win32_System_StationsAndDesktops",
|
"Win32_System_StationsAndDesktops",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
"Win32_UI_Input_KeyboardAndMouse",
|
"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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
core-foundation = "0.10"
|
core-foundation = "0.10"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
fn main() {
|
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()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 764 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -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]"]
|
||||||
|
}
|
||||||
@@ -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 "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
|
|
||||||
pub mod autolock;
|
pub mod autolock;
|
||||||
pub mod device_key;
|
pub mod device_key;
|
||||||
|
pub mod hello;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod title_bar;
|
pub mod title_bar;
|
||||||
|
pub mod updates;
|
||||||
|
|
||||||
pub use kv_ffi::bridge::{broker, channel, passkey, words};
|
pub use kv_ffi::bridge::{broker, channel, passkey, words};
|
||||||
pub use kv_ipc::{framing, ipc, private_file};
|
pub use kv_ipc::{framing, ipc, private_file};
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{io, thread};
|
use std::{io, thread};
|
||||||
|
|
||||||
use kestrove_desktop::autolock::{self, Activity, Reason};
|
use kestrove_desktop::autolock::{self, Activity, Reason};
|
||||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||||
use kestrove_desktop::passkey::PasskeyView;
|
|
||||||
use kestrove_desktop::device_key::DeviceKey;
|
use kestrove_desktop::device_key::DeviceKey;
|
||||||
|
use kestrove_desktop::hello::{self, HelloError};
|
||||||
|
use kestrove_desktop::passkey::PasskeyView;
|
||||||
use kestrove_desktop::sync::{
|
use kestrove_desktop::sync::{
|
||||||
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
};
|
};
|
||||||
|
use kestrove_desktop::updates::{UpdateInfo, Updates};
|
||||||
use kestrove_desktop::{ipc, private_file};
|
use kestrove_desktop::{ipc, private_file};
|
||||||
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -34,6 +36,9 @@ struct App {
|
|||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
ipc_error: Mutex<Option<String>>,
|
ipc_error: Mutex<Option<String>>,
|
||||||
idle_minutes: Arc<AtomicU64>,
|
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.
|
// Kept alive: on X11 the clipboard owner must outlive the copy.
|
||||||
clipboard: Mutex<Option<arboard::Clipboard>>,
|
clipboard: Mutex<Option<arboard::Clipboard>>,
|
||||||
}
|
}
|
||||||
@@ -42,11 +47,35 @@ impl App {
|
|||||||
fn settings_path(&self) -> PathBuf {
|
fn settings_path(&self) -> PathBuf {
|
||||||
self.data_dir.join("settings.json")
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct Settings {
|
struct Settings {
|
||||||
idle_minutes: u64,
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -58,6 +87,8 @@ struct AppState {
|
|||||||
/// Browser integration could not start (e.g. another instance is running).
|
/// Browser integration could not start (e.g. another instance is running).
|
||||||
ipc_error: Option<String>,
|
ipc_error: Option<String>,
|
||||||
idle_minutes: u64,
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -179,6 +210,7 @@ fn state(app: State<'_, App>) -> AppState {
|
|||||||
remembers_secret_key: app.sync.stored_secret_key().is_some(),
|
remembers_secret_key: app.sync.stored_secret_key().is_some(),
|
||||||
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||||
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
|
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 {
|
if let Some(sk) = typed {
|
||||||
app.sync.remember_secret_key(&sk);
|
app.sync.remember_secret_key(&sk);
|
||||||
}
|
}
|
||||||
|
hello::password_unlocked(&app.data_dir);
|
||||||
app.activity.touch();
|
app.activity.touch();
|
||||||
app.sync.request_sync();
|
app.sync.request_sync();
|
||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
async fn lock(app: State<'_, App>) -> CmdResult<()> {
|
async fn lock(app: State<'_, App>) -> CmdResult<()> {
|
||||||
app.core.lock();
|
app.core.lock();
|
||||||
@@ -232,8 +298,32 @@ fn touch(app: State<'_, App>) {
|
|||||||
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
|
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
|
||||||
let minutes = minutes.clamp(1, 240);
|
let minutes = minutes.clamp(1, 240);
|
||||||
app.idle_minutes.store(minutes, Ordering::Relaxed);
|
app.idle_minutes.store(minutes, Ordering::Relaxed);
|
||||||
let bytes = serde_json::to_vec(&Settings { idle_minutes: minutes }).map_err(|e| e.to_string())?;
|
app.save_settings()
|
||||||
private_file::write(&app.settings_path(), &bytes).map_err(|e| e.to_string())
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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]
|
#[tauri::command]
|
||||||
@@ -408,6 +498,26 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()>
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
||||||
app.broker.pending_passkeys()
|
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 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 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()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok())
|
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok());
|
||||||
.map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
|
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 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| {
|
let ipc_error = start_ipc(broker.clone()).err().map(|e| {
|
||||||
eprintln!("kestrove: browser integration disabled: {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,
|
data_dir,
|
||||||
ipc_error: Mutex::new(ipc_error),
|
ipc_error: Mutex::new(ipc_error),
|
||||||
idle_minutes,
|
idle_minutes,
|
||||||
|
hello_available: Arc::new(AtomicBool::new(false)),
|
||||||
|
updates,
|
||||||
clipboard: Mutex::new(None),
|
clipboard: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
app.state::<App>().refresh_hello();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
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)
|
.setup(setup)
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
@@ -713,6 +831,8 @@ fn main() {
|
|||||||
set_title_bar_colors,
|
set_title_bar_colors,
|
||||||
create_account,
|
create_account,
|
||||||
unlock,
|
unlock,
|
||||||
|
hello_unlock,
|
||||||
|
set_hello,
|
||||||
lock,
|
lock,
|
||||||
touch,
|
touch,
|
||||||
set_idle_minutes,
|
set_idle_minutes,
|
||||||
@@ -730,6 +850,8 @@ fn main() {
|
|||||||
pending_saves,
|
pending_saves,
|
||||||
pending_passkeys,
|
pending_passkeys,
|
||||||
set_tags,
|
set_tags,
|
||||||
|
app_theme,
|
||||||
|
set_app_theme,
|
||||||
sign_in_providers,
|
sign_in_providers,
|
||||||
resolve_passkey,
|
resolve_passkey,
|
||||||
resolve_save,
|
resolve_save,
|
||||||
@@ -752,7 +874,11 @@ fn main() {
|
|||||||
cancel_add_device,
|
cancel_add_device,
|
||||||
approve_join,
|
approve_join,
|
||||||
send_join,
|
send_join,
|
||||||
kit_qr
|
kit_qr,
|
||||||
|
update_info,
|
||||||
|
check_update,
|
||||||
|
set_update_checks,
|
||||||
|
install_update
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Kestrove");
|
.expect("error while running Kestrove");
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Kestrove",
|
"productName": "Kestrove",
|
||||||
"version": "1.1.0",
|
"version": "1.2.1",
|
||||||
"identifier": "app.kestrove.desktop",
|
"identifier": "app.kestrove.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -41,11 +41,41 @@
|
|||||||
"icons/icon.ico",
|
"icons/icon.ico",
|
||||||
"icons/icon.png"
|
"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": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installerHooks": "windows/hooks.nsh",
|
"installerHooks": "windows/hooks.nsh",
|
||||||
"installMode": "currentUser"
|
"installMode": "currentUser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZFRUM4RDQ2Mzk0Rjg5Q0IKUldUTGlVODVSbzNzYml1ZTN3d0pBN1IwWkRoZXNROTRmbk5WOE5CR2VJQVYrTUJLN21PNzI4eHEK",
|
||||||
|
"requireSignedVersion": true,
|
||||||
|
"endpoints": [
|
||||||
|
"https://kestrove.samialabri.com/updates/latest.json"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,25 @@
|
|||||||
; native-messaging/install.ps1: a manifest next to kestrove-nmh.exe ("path" relative to the manifest, which
|
; 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.
|
; Chrome and Firefox allow on Windows) and one registry key per browser.
|
||||||
; Extension ids: keep in sync with kestrove-apple/App/BrowserIntegration.swift (NativeHostRegistration).
|
; 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_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]"'
|
!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
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
FileOpen $0 "$INSTDIR\${KV_HOST}.chrome.json" w
|
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}]}'
|
FileWrite $0 '{"name": "${KV_HOST}", "description": "Kestrove", "path": "kestrove-nmh.exe", "type": "stdio", "allowed_origins": [${KV_CHROME_ORIGINS}]}'
|
||||||
@@ -24,6 +38,7 @@
|
|||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_PREUNINSTALL
|
!macro NSIS_HOOK_PREUNINSTALL
|
||||||
|
!insertmacro KV_STOP_HELPER
|
||||||
DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\${KV_HOST}"
|
DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\${KV_HOST}"
|
||||||
DeleteRegKey HKCU "Software\Chromium\NativeMessagingHosts\${KV_HOST}"
|
DeleteRegKey HKCU "Software\Chromium\NativeMessagingHosts\${KV_HOST}"
|
||||||
DeleteRegKey HKCU "Software\Microsoft\Edge\NativeMessagingHosts\${KV_HOST}"
|
DeleteRegKey HKCU "Software\Microsoft\Edge\NativeMessagingHosts\${KV_HOST}"
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
pill, ring, secret, tile, type IconName,
|
pill, ring, secret, tile, type IconName,
|
||||||
} from "./ui/kv";
|
} 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 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 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 }
|
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());
|
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
|
// ------------------------------------------------------------------ chrome
|
||||||
|
|
||||||
/// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge).
|
/// 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 pw = input({ type: "password", autocomplete: "current-password" });
|
||||||
const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true);
|
const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true);
|
||||||
const err = errBox();
|
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 () => {
|
const submit = async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
err.replaceChildren();
|
err.replaceChildren();
|
||||||
@@ -436,11 +511,15 @@ function renderUnlock(state: AppState) {
|
|||||||
pw.onkeydown = (e) => {
|
pw.onkeydown = (e) => {
|
||||||
if (e.key === "Enter") void submit();
|
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."));
|
if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional."));
|
||||||
card.append(err, btn);
|
card.append(err, btn);
|
||||||
centered(card);
|
centered(card);
|
||||||
pw.focus();
|
if (state.hello.offered) hello.focus();
|
||||||
|
else pw.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ vault
|
// ------------------------------------------------------------------ vault
|
||||||
@@ -608,6 +687,7 @@ function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) {
|
|||||||
if (s.restored_items > 0) {
|
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."));
|
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 () => {
|
const now = iconButton("sync", "Sync now", async () => {
|
||||||
now.disabled = true;
|
now.disabled = true;
|
||||||
await invoke("sync_now").catch(() => undefined);
|
await invoke("sync_now").catch(() => undefined);
|
||||||
@@ -767,6 +847,54 @@ async function renderSettings(target: HTMLElement, state: AppState) {
|
|||||||
}));
|
}));
|
||||||
theme.setAttribute("role", "group");
|
theme.setAttribute("role", "group");
|
||||||
theme.setAttribute("aria-label", "Appearance");
|
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");
|
const list = h("div", "kv-card rows");
|
||||||
for (const pr of pairings) {
|
for (const pr of pairings) {
|
||||||
list.append(h("div", "kv-frow",
|
list.append(h("div", "kv-frow",
|
||||||
@@ -784,12 +912,17 @@ async function renderSettings(target: HTMLElement, state: AppState) {
|
|||||||
h("h2", "kv-title-2", "Settings"),
|
h("h2", "kv-title-2", "Settings"),
|
||||||
section("General",
|
section("General",
|
||||||
h("div", "kv-card rows",
|
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", "", "Colour")), swatches),
|
||||||
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", "", "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("Sync", syncSection),
|
||||||
section("Devices", devicesSection),
|
section("Devices", devicesSection),
|
||||||
section("Emergency Kit", kitSection()),
|
section("Emergency Kit", kitSection()),
|
||||||
section("Paired browsers", list),
|
section("Paired browsers", list),
|
||||||
|
updatesSection(await invoke<UpdateInfo>("update_info"), () => renderSettings(target, state)),
|
||||||
));
|
));
|
||||||
const status = await invoke<SyncStatus>("sync_status");
|
const status = await invoke<SyncStatus>("sync_status");
|
||||||
renderSyncSettings(syncSection, status, () => renderSettings(target, state));
|
renderSyncSettings(syncSection, status, () => renderSettings(target, state));
|
||||||
@@ -1141,8 +1274,64 @@ function saveCard(s: SaveView): HTMLElement {
|
|||||||
h("div", "actions end", no, yes));
|
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
|
// ------------------------------------------------------------------ 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("pairing-request", () => render());
|
||||||
void listen("save-request", () => render());
|
void listen("save-request", () => render());
|
||||||
void listen("passkey-request", () => render());
|
void listen("passkey-request", () => render());
|
||||||
@@ -1157,6 +1346,7 @@ void listen<SyncStatus>("sync-status", (e) => {
|
|||||||
showSync(s);
|
showSync(s);
|
||||||
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
|
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
|
||||||
lastChanges = s.changes;
|
lastChanges = s.changes;
|
||||||
|
void adoptSyncedTheme();
|
||||||
if (s.integrity_alarm && !alarmShown) {
|
if (s.integrity_alarm && !alarmShown) {
|
||||||
alarmShown = true;
|
alarmShown = true;
|
||||||
openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal)));
|
openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal)));
|
||||||
|
|||||||
@@ -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 { 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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -122,3 +122,116 @@
|
|||||||
--shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080;
|
--shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080;
|
||||||
--shadow-lg: 0 24px 64px -16px #000000b3;
|
--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;
|
||||||
|
}
|
||||||
|
|||||||