diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index febe7951..13ef38d8 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -193,3 +193,122 @@ jobs: - name: Verify desktop artifact reproducibility proofs run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts linux macos + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # was v1 + with: + bun-version: 1.3.5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable + with: + toolchain: 1.95.0 + + - name: Install sccache + shell: bash + run: | + SCCACHE_VERSION=0.8.2 + SCCACHE_SHA256="de5e9f66bb8a6bbdf0e28cb8a086a8d12699af796bf70bcd9dc40d80715bf9b8" + SCCACHE_ARCHIVE="sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc.tar.gz" + SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${SCCACHE_ARCHIVE}" + # Run download/verify/extract inside a subshell cd'd to RUNNER_TEMP so + # the archive and target dir are referenced by relative names. MSYS2 tar + # mangles Windows paths like D:\a when given absolute -C/-f arguments + # (even with --force-local); relative paths sidestep that. + ( + cd "$RUNNER_TEMP" + curl --fail --location --show-error --silent "$SCCACHE_URL" --output "$SCCACHE_ARCHIVE" + echo "${SCCACHE_SHA256} ${SCCACHE_ARCHIVE}" | sha256sum --check - + tar xzf "$SCCACHE_ARCHIVE" + ) + SCCACHE_BIN_DIR="$RUNNER_TEMP/sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc" + echo "$SCCACHE_BIN_DIR" >> "$GITHUB_PATH" + "$SCCACHE_BIN_DIR/sccache.exe" --version + + - name: Cache sccache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 + with: + path: ~\AppData\Local\Mozilla\sccache + key: ${{ runner.os }}-sccache-windows-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-sccache-windows- + ${{ runner.os }}-sccache- + + - name: Provide ONNX Runtime (Windows) + shell: bash + run: | + ./frontend/src-tauri/scripts/provide-windows-onnxruntime.sh >> "$GITHUB_ENV" + + - name: Stage Windows runtime DLLs for bundling + shell: pwsh + run: | + # maple.exe links onnxruntime.dll by ordinal; without these next to the + # exe the loader binds to the OS Windows-ML onnxruntime.dll (v1.17) and + # TTS hangs at Session::builder. See resources/windows/README.md. + $dest = "frontend/src-tauri/resources/windows" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + # ONNX Runtime 1.22.0 (already downloaded + SHA-verified; path in env) + Copy-Item "$env:ORT_DYLIB_PATH" (Join-Path $dest "onnxruntime.dll") -Force + # MSVC C++ runtime DLLs onnxruntime.dll depends on. Find a source dir + # holding all four, independent of the runner's VS year/edition: prefer + # the versioned redist (located via vswhere), fall back to System32. + $crtDlls = 'VCRUNTIME140.dll','VCRUNTIME140_1.dll','MSVCP140.dll','MSVCP140_1.dll' + $candidates = @() + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $vs = & $vswhere -latest -products * -property installationPath + if ($vs) { + $candidates += Get-ChildItem (Join-Path $vs 'VC\Redist\MSVC\*\x64') -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^Microsoft\.VC\d+\.CRT$' } | ForEach-Object FullName + } + } + $candidates += "$env:WINDIR\System32" + $src = $candidates | Where-Object { $d = $_; -not ($crtDlls | Where-Object { -not (Test-Path (Join-Path $d $_)) }) } | Select-Object -First 1 + if (-not $src) { throw "No directory has all CRT DLLs. Searched: $($candidates -join '; ')" } + Write-Host "CRT source: $src" + foreach ($dll in $crtDlls) { Copy-Item (Join-Path $src $dll) (Join-Path $dest $dll) -Force } + Get-ChildItem $dest | Select-Object Name, Length + + - name: Install frontend dependencies + working-directory: ./frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Configure sccache + shell: bash + run: | + { + echo "RUSTC_WRAPPER=sccache" + echo "SCCACHE_CACHE_SIZE=2G" + } >> "$GITHUB_ENV" + + - name: Build Tauri App (Windows) + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # was v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + VITE_OPEN_SECRET_API_URL: https://enclave.trymaple.ai + VITE_MAPLE_BILLING_API_URL: https://billing.opensecret.cloud + VITE_CLIENT_ID: ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 + with: + projectPath: './frontend' + + - name: Show sccache stats + run: sccache --show-stats + + - name: Upload Windows Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 + with: + name: maple-windows-x64 + path: | + frontend/src-tauri/target/release/bundle/nsis/*.exe + frontend/src-tauri/target/release/bundle/nsis/*.nsis.zip + frontend/src-tauri/target/release/bundle/nsis/*.nsis.zip.sig + retention-days: 5 diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 4cba67b1..dfb1d31c 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -122,3 +122,117 @@ jobs: frontend/src-tauri/target/reproducibility/desktop-pr-linux-fake-updater.pub frontend/src-tauri/target/reproducibility/desktop-pr-linux-*.sha256 retention-days: 5 + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # was v1 + with: + bun-version: 1.3.5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable + with: + toolchain: 1.95.0 + + - name: Install sccache + shell: bash + run: | + SCCACHE_VERSION=0.8.2 + SCCACHE_SHA256="de5e9f66bb8a6bbdf0e28cb8a086a8d12699af796bf70bcd9dc40d80715bf9b8" + SCCACHE_ARCHIVE="sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc.tar.gz" + SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${SCCACHE_ARCHIVE}" + # Run download/verify/extract inside a subshell cd'd to RUNNER_TEMP so + # the archive and target dir are referenced by relative names. MSYS2 tar + # mangles Windows paths like D:\a when given absolute -C/-f arguments + # (even with --force-local); relative paths sidestep that. + ( + cd "$RUNNER_TEMP" + curl --fail --location --show-error --silent "$SCCACHE_URL" --output "$SCCACHE_ARCHIVE" + echo "${SCCACHE_SHA256} ${SCCACHE_ARCHIVE}" | sha256sum --check - + tar xzf "$SCCACHE_ARCHIVE" + ) + SCCACHE_BIN_DIR="$RUNNER_TEMP/sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc" + echo "$SCCACHE_BIN_DIR" >> "$GITHUB_PATH" + "$SCCACHE_BIN_DIR/sccache.exe" --version + + - name: Cache sccache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 + with: + path: ~\AppData\Local\Mozilla\sccache + key: ${{ runner.os }}-sccache-windows-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-sccache-windows- + ${{ runner.os }}-sccache- + + - name: Provide ONNX Runtime (Windows) + shell: bash + run: | + ./frontend/src-tauri/scripts/provide-windows-onnxruntime.sh >> "$GITHUB_ENV" + + - name: Stage Windows runtime DLLs for bundling + shell: pwsh + run: | + # maple.exe links onnxruntime.dll by ordinal; without these next to the + # exe the loader binds to the OS Windows-ML onnxruntime.dll (v1.17) and + # TTS hangs at Session::builder. See resources/windows/README.md. + $dest = "frontend/src-tauri/resources/windows" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + # ONNX Runtime 1.22.0 (already downloaded + SHA-verified; path in env) + Copy-Item "$env:ORT_DYLIB_PATH" (Join-Path $dest "onnxruntime.dll") -Force + # MSVC C++ runtime DLLs onnxruntime.dll depends on. Find a source dir + # holding all four, independent of the runner's VS year/edition: prefer + # the versioned redist (located via vswhere), fall back to System32. + $crtDlls = 'VCRUNTIME140.dll','VCRUNTIME140_1.dll','MSVCP140.dll','MSVCP140_1.dll' + $candidates = @() + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $vs = & $vswhere -latest -products * -property installationPath + if ($vs) { + $candidates += Get-ChildItem (Join-Path $vs 'VC\Redist\MSVC\*\x64') -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^Microsoft\.VC\d+\.CRT$' } | ForEach-Object FullName + } + } + $candidates += "$env:WINDIR\System32" + $src = $candidates | Where-Object { $d = $_; -not ($crtDlls | Where-Object { -not (Test-Path (Join-Path $d $_)) }) } | Select-Object -First 1 + if (-not $src) { throw "No directory has all CRT DLLs. Searched: $($candidates -join '; ')" } + Write-Host "CRT source: $src" + foreach ($dll in $crtDlls) { Copy-Item (Join-Path $src $dll) (Join-Path $dest $dll) -Force } + Get-ChildItem $dest | Select-Object Name, Length + + - name: Install frontend dependencies + working-directory: ./frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Configure sccache + shell: bash + run: | + { + echo "RUSTC_WRAPPER=sccache" + echo "SCCACHE_CACHE_SIZE=2G" + } >> "$GITHUB_ENV" + + - name: Build Tauri App (Windows, unsigned) + working-directory: ./frontend + shell: bash + run: bun tauri build --no-sign --config '{"bundle":{"createUpdaterArtifacts":false}}' + env: + VITE_OPEN_SECRET_API_URL: https://enclave.secretgpt.ai + VITE_MAPLE_BILLING_API_URL: https://billing-dev.opensecret.cloud + VITE_CLIENT_ID: ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 + + - name: Show sccache stats + run: sccache --show-stats + + - name: Upload Windows PR Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 + with: + name: maple-windows-x64-pr + path: | + frontend/src-tauri/target/release/bundle/nsis/*.exe + retention-days: 5 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 451ae2fd..b75bde7e 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2815,6 +2815,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2961,6 +2973,7 @@ dependencies = [ "dirs", "futures-util", "hound", + "keyring", "log", "maple-proxy", "ndarray", @@ -5872,7 +5885,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.17", - "toml 0.9.8", + "toml 1.1.0+spec-1.1.0", "url", "urlpattern", "uuid", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 2fd0e8b1..ab291a63 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -90,6 +90,11 @@ sha2 = "0.10" [target.'cfg(target_os = "android")'.dependencies] openssl = { version = "0.10.80", default-features = false, features = ["vendored"] } +[target.'cfg(target_os = "windows")'.dependencies] +# Store the proxy API key in Windows Credential Manager rather than as +# plaintext in the roaming %APPDATA% config (which can sync across machines). +keyring = { version = "3", features = ["windows-native"] } + [patch.crates-io] # Local patch for tao 0.35.2 Android intent crashes: # https://github.com/tauri-apps/tao/issues/1217 diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 9e75622f..8695013b 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -11,38 +11,23 @@ "fs:default", { "identifier": "fs:allow-read-file", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-write-file", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-create", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-exists", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-mkdir", - "allow": [ - { "path": "$APPCONFIG" }, - { "path": "$HOME/.config/maple" } - ] + "allow": [{ "path": "$APPCONFIG" }, { "path": "$HOME/.config/maple" }] }, { "identifier": "opener:allow-open-url", diff --git a/frontend/src-tauri/resources/windows/.gitignore b/frontend/src-tauri/resources/windows/.gitignore new file mode 100644 index 00000000..558b5dcf --- /dev/null +++ b/frontend/src-tauri/resources/windows/.gitignore @@ -0,0 +1,4 @@ +# The Windows runtime DLLs are staged here at build time by CI (see README.md). +# They are large redistributable binaries with their own provenance, so they are +# not committed to the repo. +*.dll diff --git a/frontend/src-tauri/resources/windows/README.md b/frontend/src-tauri/resources/windows/README.md new file mode 100644 index 00000000..9147f34a --- /dev/null +++ b/frontend/src-tauri/resources/windows/README.md @@ -0,0 +1,92 @@ +# Windows bundled runtime DLLs + +`maple.exe` links **ONNX Runtime 1.22.0** with a load-time (compile-time +dynamic) import of `onnxruntime.dll`. On a clean Windows install, the Windows +loader would otherwise bind that import to the OS-shipped Windows-ML +`C:\Windows\System32\onnxruntime.dll` (a forwarder to `onnxruntime_x64.dll`, +currently **v1.17.x**). The version mismatch lets the app launch but **hangs** +the first time TTS calls `Session::builder()` (see `src/tts.rs`). + +To force the correct runtime, we ship these DLLs **next to `maple.exe`**. The +executable's own directory is searched **before** `System32` in the Windows DLL +search order, so our 1.22.0 `onnxruntime.dll` wins over the OS one. + +`onnxruntime.dll` 1.22.0 in turn depends on the MSVC C++ runtime +(`VCRUNTIME140.dll`, `VCRUNTIME140_1.dll`, `MSVCP140.dll`, `MSVCP140_1.dll`), +which is **not** present on a fresh Windows install. Rather than running +`vc_redist.x64.exe` (which needs admin/UAC and clashes with our per-user +install), we deploy those four redistributable DLLs **app-local** — the same +mechanism as `onnxruntime.dll`. (`maple.exe` itself only needs the Universal +CRT `api-ms-win-crt-*`, which is part of Windows 10+.) + +Deployment is done by an NSIS install hook, `install-dlls.nsh`, registered via +`bundle.windows.nsis.installerHooks` in `tauri.windows.conf.json`. Its +`NSIS_HOOK_POSTINSTALL` `File`-copies each DLL from here into `$INSTDIR` (next +to `maple.exe`); `NSIS_HOOK_PREUNINSTALL` removes them again. + +We do **not** use `bundle.resources` for this: in Tauri 2.11 a `resources` map +supplied via the platform config gets the DLLs into the installer payload but +not into the NSIS install file-list, so they ride along in the `.exe` yet never +land in the install dir (verified empirically — only `maple.exe` + +`uninstall.exe` were installed). The hook bypasses that plumbing. + +## Files (5) + +| File | Source | +|-----------------------|-----------------------------------------------------| +| `onnxruntime.dll` | Microsoft ONNX Runtime 1.22.0 win-x64 release | +| `VCRUNTIME140.dll` | MSVC 2015–2022 x64 redistributable | +| `VCRUNTIME140_1.dll` | MSVC 2015–2022 x64 redistributable | +| `MSVCP140.dll` | MSVC 2015–2022 x64 redistributable | +| `MSVCP140_1.dll` | MSVC 2015–2022 x64 redistributable | + +These are gitignored (large redistributables with their own provenance) and +must be staged here **before `bun tauri build`** on Windows. + +## CI staging + +The Windows CI workflows (`desktop-pr-build.yml`, `desktop-build.yml`) stage +these automatically in the **"Stage Windows runtime DLLs for bundling"** step, +which runs after "Provide ONNX Runtime (Windows)" and before the Tauri build. + +For a **local** Windows build, run `scripts/provide-windows-onnxruntime.sh` +first (it exports `ORT_DYLIB_PATH`), then stage the same five files: + +1. **`onnxruntime.dll`** — from the SHA-verified ONNX Runtime download: + + ```bash + cp "$ORT_DYLIB_PATH" frontend/src-tauri/resources/windows/onnxruntime.dll + ``` + +2. **The 4 MSVC CRT DLLs** — from the Visual Studio redist on the machine, + located via `vswhere` so it's independent of the VS year/edition (the + `^Microsoft\.VC\d+\.CRT$` filter avoids the neighbouring `DebugCRT`/`OPENMP` + folders); falls back to `System32`: + + ```powershell + $crtDlls = 'VCRUNTIME140.dll','VCRUNTIME140_1.dll','MSVCP140.dll','MSVCP140_1.dll' + $candidates = @() + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $vs = & $vswhere -latest -products * -property installationPath + if ($vs) { + $candidates += Get-ChildItem (Join-Path $vs 'VC\Redist\MSVC\*\x64') -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^Microsoft\.VC\d+\.CRT$' } | ForEach-Object FullName + } + } + $candidates += "$env:WINDIR\System32" + $src = $candidates | Where-Object { $d = $_; -not ($crtDlls | Where-Object { -not (Test-Path (Join-Path $d $_)) }) } | Select-Object -First 1 + $crtDlls | ForEach-Object { Copy-Item (Join-Path $src $_) frontend\src-tauri\resources\windows\ } + ``` + +The hook reads these files at makensis compile time, so they must be present +here before the build runs (the CI staging step guarantees that); a missing +file fails the build loudly rather than shipping a broken installer. + +## Verify after a CI build + +Confirm the DLLs land **next to `maple.exe`** in the installed app +(`%LOCALAPPDATA%\Maple\`), not in a `resources\` subfolder — only then does the +search-order override work. The running process should load +`...\AppData\Local\Maple\onnxruntime.dll ver 1.22.0` (check via +`(Get-Process maple).Modules`), not the System32 copy. diff --git a/frontend/src-tauri/resources/windows/install-dlls.nsh b/frontend/src-tauri/resources/windows/install-dlls.nsh new file mode 100644 index 00000000..9e2fb18a --- /dev/null +++ b/frontend/src-tauri/resources/windows/install-dlls.nsh @@ -0,0 +1,31 @@ +; Install the staged Windows runtime DLLs next to maple.exe. +; +; Why a hook instead of bundle.resources: in Tauri 2.11 a `resources` map +; supplied via the platform config (tauri.windows.conf.json) gets the DLLs into +; the installer payload but NOT into the NSIS install file-list, so they never +; land in the install dir (verified empirically). This hook copies them +; explicitly, independent of that plumbing. +; +; Path math: ${MAINBINARYSRCPATH} is the absolute build path to the main exe, +; \target\release\maple.exe +; so THREE "..\" segments climb maple.exe -> release -> target -> , +; then into resources\windows where CI (and local builds) stage the DLLs. +; `File` embeds them at makensis compile time, so they must exist then (the +; build's "Stage Windows runtime DLLs" step guarantees that) and a missing file +; fails the build loudly rather than shipping a broken installer. +!macro NSIS_HOOK_POSTINSTALL + SetOutPath "$INSTDIR" + File "/oname=onnxruntime.dll" "${MAINBINARYSRCPATH}\..\..\..\resources\windows\onnxruntime.dll" + File "/oname=VCRUNTIME140.dll" "${MAINBINARYSRCPATH}\..\..\..\resources\windows\VCRUNTIME140.dll" + File "/oname=VCRUNTIME140_1.dll" "${MAINBINARYSRCPATH}\..\..\..\resources\windows\VCRUNTIME140_1.dll" + File "/oname=MSVCP140.dll" "${MAINBINARYSRCPATH}\..\..\..\resources\windows\MSVCP140.dll" + File "/oname=MSVCP140_1.dll" "${MAINBINARYSRCPATH}\..\..\..\resources\windows\MSVCP140_1.dll" +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + Delete "$INSTDIR\onnxruntime.dll" + Delete "$INSTDIR\VCRUNTIME140.dll" + Delete "$INSTDIR\VCRUNTIME140_1.dll" + Delete "$INSTDIR\MSVCP140.dll" + Delete "$INSTDIR\MSVCP140_1.dll" +!macroend diff --git a/frontend/src-tauri/scripts/onnxruntime-pins.sh b/frontend/src-tauri/scripts/onnxruntime-pins.sh index fdb41f71..176981e1 100644 --- a/frontend/src-tauri/scripts/onnxruntime-pins.sh +++ b/frontend/src-tauri/scripts/onnxruntime-pins.sh @@ -48,6 +48,30 @@ onnxruntime_linux_aarch64_dylib_sha256_for_version() { esac } +onnxruntime_windows_x64_archive_sha256_for_version() { + case "$1" in + 1.22.0) + printf '%s\n' "174c616efc0271194488642a72f1a514e01487da4dfe84c49296d66e40ebe0da" + ;; + *) + echo "No pinned Windows x64 ONNX Runtime archive SHA-256 for version '$1'." >&2 + return 1 + ;; + esac +} + +onnxruntime_windows_x64_dll_sha256_for_version() { + case "$1" in + 1.22.0) + printf '%s\n' "579b636403983254346a5c1d80bd28f1519cd1e284cd204f8d4ff41f8d711559" + ;; + *) + echo "No pinned Windows x64 ONNX Runtime DLL SHA-256 for version '$1'." >&2 + return 1 + ;; + esac +} + onnxruntime_ios_commit_for_version() { case "$1" in 1.22.2) diff --git a/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh b/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh new file mode 100755 index 00000000..4fbf3142 --- /dev/null +++ b/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ORT_VERSION="${ORT_VERSION:-1.22.0}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/onnxruntime-pins.sh" + +TAURI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ORT_ROOT="${TAURI_DIR}/onnxruntime-windows" +ORT_DIR="${ORT_ROOT}/onnxruntime-win-x64-${ORT_VERSION}" +ORT_ARCHIVE="onnxruntime-win-x64-${ORT_VERSION}.zip" +ORT_URL="https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${ORT_ARCHIVE}" +ORT_DLL="${ORT_DIR}/lib/onnxruntime.dll" +ORT_ARCHIVE_SHA256="$(onnxruntime_windows_x64_archive_sha256_for_version "${ORT_VERSION}")" +ORT_DLL_SHA256="$(onnxruntime_windows_x64_dll_sha256_for_version "${ORT_VERSION}")" + +sha256_file() { + local path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${path}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${path}" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "${path}" | awk '{print $1}' + else + echo "No SHA-256 tool found. Install sha256sum, shasum, or openssl." >&2 + return 1 + fi +} + +verify_sha256() { + local label="$1" + local path="$2" + local expected="$3" + local actual + + actual="$(sha256_file "${path}")" + if [ "${actual}" != "${expected}" ]; then + echo "${label} SHA-256 mismatch for ${path}" >&2 + echo "expected: ${expected}" >&2 + echo "actual: ${actual}" >&2 + return 1 + fi +} + +# Internal bash operations (curl, unzip, sha256sum, file checks) work fine with +# MSYS2-style /d/a/... paths. But ORT_LIB_LOCATION and ORT_DYLIB_PATH are +# consumed by the native Windows Rust toolchain (ort crate build script) in a +# later step, which interprets a leading /d/... as drive-relative and fails to +# resolve. Convert paths to native Windows form at the GITHUB_ENV boundary. +# Falls through unchanged on platforms without cygpath so the script stays +# runnable for local sanity checks. +to_native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s' "$1" + fi +} + +if [ ! -f "${ORT_DLL}" ]; then + rm -rf "${ORT_ROOT}" + mkdir -p "${ORT_ROOT}" + archive_path="${ORT_ROOT}/${ORT_ARCHIVE}" + + curl -fL --retry 5 --retry-delay 2 --retry-all-errors \ + "${ORT_URL}" \ + --output "${archive_path}" + + verify_sha256 "ONNX Runtime archive" "${archive_path}" "${ORT_ARCHIVE_SHA256}" + unzip -q "${archive_path}" -d "${ORT_ROOT}" + rm -f "${archive_path}" +fi + +verify_sha256 "ONNX Runtime DLL" "${ORT_DLL}" "${ORT_DLL_SHA256}" + +echo "ORT_LIB_LOCATION=$(to_native_path "${ORT_DIR}")" +echo "ORT_SKIP_DOWNLOAD=true" +echo "ORT_DYLIB_PATH=$(to_native_path "${ORT_DLL}")" diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a9cee65f..1749ede5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -84,6 +84,19 @@ pub fn run() { if let Err(e) = app.deep_link().register("cloud.opensecret.maple") { log::error!("[Deep Link] Failed to register scheme: {e}"); } + // Windows startup diagnostic: confirm the OS has our custom scheme + // pointed at the running exe. Most "OAuth callback does nothing" + // reports trace back to a missing/stale + // HKCU\Software\Classes\cloud.opensecret.maple key (no installer run, + // a deleted dev build, or a stale path), so log it once at startup. + #[cfg(target_os = "windows")] + match app.deep_link().is_registered("cloud.opensecret.maple") { + Ok(true) => log::info!("[Deep Link] scheme 'cloud.opensecret.maple' is registered"), + Ok(false) => log::warn!( + "[Deep Link] scheme 'cloud.opensecret.maple' is NOT registered; OAuth/payment deep links will not reach the app" + ), + Err(e) => log::error!("[Deep Link] is_registered check failed: {e}"), + } // Create the application menu with update options #[cfg(desktop)] { @@ -119,85 +132,60 @@ pub fn run() { }); }); - // Create a native menu with a "Check for Updates" option + // Create the application menu (macOS only). + // + // On Windows/Linux this menu renders as an in-window bar at the top of + // the window. Its edit items (undo/redo/cut/copy/paste/select-all) are + // handled natively by the webview regardless of the menu, About lives in + // the in-app account menu, and updates are applied by the automatic check + // (startup + hourly, above). The bar adds only clutter, so we omit it + // entirely on those platforms for a cleaner window. + #[cfg(target_os = "macos")] { - #[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, SubmenuBuilder}; - #[cfg(not(target_os = "macos"))] - use tauri::menu::MenuBuilder; - // Define menu item ID for "Check for Updates" let check_updates_id = "check-for-updates"; // Get app handle for menu operations let handle = app.handle(); - // Build platform-specific menus - #[cfg(target_os = "macos")] - { - // For macOS, we need to create a proper submenu structure - // First create the app submenu (first submenu becomes the application menu) - let app_submenu = SubmenuBuilder::new(handle, &app.package_info().name) - // Add about menu item (standard macOS menu item) - .about(None) - // Add our update checker to the app menu - .text(check_updates_id, "Check for Updates") - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .quit() - .build()?; - - // Create edit submenu with standard clipboard operations - let edit_submenu = SubmenuBuilder::new(handle, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .separator() - .select_all() - .build()?; - - // Create the main menu and add our app submenu and edit submenu - let menu = MenuBuilder::new(handle).items(&[&app_submenu, &edit_submenu]).build()?; - - // Set as the application menu - app.set_menu(menu)?; - - // Log that we're setting up the menu - log::info!( - "Setting up macOS menu with app submenu and edit submenu (copy/paste)" - ); - } - - #[cfg(not(target_os = "macos"))] - { - // For Windows/Linux, we need to include edit functionality while keeping a simpler structure - let menu = MenuBuilder::new(handle) - .about(None) - .text(check_updates_id, "Check for Updates") - .separator() - // Add standard edit operations - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .separator() - .quit() - .build()?; - - app.set_menu(menu)?; - - log::info!("Setting up Windows/Linux menu with About, Check for Updates, and Edit options"); - } + // For macOS, we need to create a proper submenu structure + // First create the app submenu (first submenu becomes the application menu) + let app_submenu = SubmenuBuilder::new(handle, &app.package_info().name) + // Add about menu item (standard macOS menu item) + .about(None) + // Add our update checker to the app menu + .text(check_updates_id, "Check for Updates") + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; + + // Create edit submenu with standard clipboard operations + let edit_submenu = SubmenuBuilder::new(handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .separator() + .select_all() + .build()?; + + // Create the main menu and add our app submenu and edit submenu + let menu = MenuBuilder::new(handle) + .items(&[&app_submenu, &edit_submenu]) + .build()?; + + // Set as the application menu + app.set_menu(menu)?; + + log::info!("Setting up macOS menu with app submenu and edit submenu (copy/paste)"); // Handle menu events let app_handle_for_menu = app.handle().clone(); diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index 1b205357..0eb6345c 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -62,8 +62,79 @@ impl ProxyState { } } +// On Windows the proxy config lives in the roaming %APPDATA% profile, so a +// plaintext api_key could sync across machines in a domain/AAD environment. +// Store it in Windows Credential Manager instead and keep it out of the JSON. +// macOS/Linux keep their local plaintext-with-0o600 behavior unchanged. +#[cfg(target_os = "windows")] +const KEYRING_SERVICE: &str = "cloud.opensecret.maple"; +#[cfg(target_os = "windows")] +const KEYRING_USER: &str = "proxy_api_key"; + +/// Persist the API key in Windows Credential Manager. An empty key clears the +/// entry. Returns `Ok(true)` when the key was stored (or cleared), `Ok(false)` +/// when no secure storage is available (caller keeps the plaintext fallback), +/// and `Err` when a clear was requested but the stale credential could not be +/// removed (caller must not scrub the JSON, or the old key would be resurrected). +#[cfg(target_os = "windows")] +fn store_api_key(key: &str) -> Result { + let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) { + Ok(entry) => entry, + Err(e) => { + log::warn!("Credential Manager unavailable, keeping plaintext config: {e}"); + return Ok(false); + } + }; + + if key.is_empty() { + return match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(true), + // A hard delete failure leaves the old credential in place. Surface + // it instead of reporting success: the caller must not scrub the + // JSON, or the stale key would be resurrected on the next load. + Err(e) => Err(anyhow!( + "Failed to clear API key from Credential Manager: {e}" + )), + }; + } + + match entry.set_password(key) { + Ok(()) => Ok(true), + Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_)) => { + log::warn!("No secure credential storage available; keeping plaintext config"); + Ok(false) + } + Err(e) => Err(anyhow!( + "Failed to store API key in Credential Manager: {e}" + )), + } +} + +/// Load the API key from Windows Credential Manager. +/// - `Ok(Some(key))` — Credential Manager is available (`key` may be empty). +/// - `Ok(None)` — unavailable; caller should fall back to the JSON value. +#[cfg(target_os = "windows")] +fn load_api_key() -> Result> { + let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) { + Ok(entry) => entry, + Err(_) => return Ok(None), + }; + + match entry.get_password() { + Ok(key) => Ok(Some(key)), + Err(keyring::Error::NoEntry) => Ok(Some(String::new())), + Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_)) => { + Ok(None) + } + Err(e) => Err(anyhow!( + "Failed to read API key from Credential Manager: {e}" + )), + } +} + #[tauri::command] pub async fn start_proxy( + app_handle: AppHandle, state: State<'_, ProxyState>, config: ProxyConfig, ) -> Result { @@ -132,7 +203,7 @@ pub async fn start_proxy( *running = true; // Save config to disk - if let Err(e) = save_proxy_config(&config).await { + if let Err(e) = save_proxy_config(&app_handle, &config).await { log::error!("Failed to save proxy config: {e}"); } @@ -185,15 +256,15 @@ pub async fn get_proxy_status(state: State<'_, ProxyState>) -> Result Result { - load_saved_proxy_config() +pub async fn load_proxy_config(app_handle: AppHandle) -> Result { + load_saved_proxy_config(&app_handle) .await .map_err(|e| format!("Failed to load proxy config: {e}")) } #[tauri::command] -pub async fn save_proxy_settings(config: ProxyConfig) -> Result<(), String> { - save_proxy_config(&config) +pub async fn save_proxy_settings(app_handle: AppHandle, config: ProxyConfig) -> Result<(), String> { + save_proxy_config(&app_handle, &config) .await .map_err(|e| format!("Failed to save proxy config: {e}")) } @@ -214,20 +285,24 @@ pub async fn test_proxy_port(host: String, port: u16) -> Result { } } -// Helper functions for config persistence -async fn get_config_path() -> Result { - // Use a hardcoded app name for the data directory - let app_name = "maple"; - let home_dir = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| anyhow!("Failed to get home directory"))?; - - // Note: We use ~/.config on all platforms for simplicity. - // This works well on Linux and macOS (our currently supported platforms). - // While macOS traditionally uses ~/Library/Application Support, many modern - // cross-platform tools use ~/.config on macOS as well. - // If Windows support is added in the future, consider using %APPDATA% instead. - let app_dir = PathBuf::from(home_dir).join(".config").join(app_name); +// Helper functions for config persistence. +// Windows uses the Tauri-managed app_config_dir() (%APPDATA%); macOS/Linux keep +// the historical ~/.config/maple/ location. The Windows API key is additionally +// stored in Credential Manager rather than plaintext (see store/load_api_key). +async fn get_config_path(app_handle: &AppHandle) -> Result { + let app_dir = if cfg!(target_os = "windows") { + // Resolves to %APPDATA%\cloud.opensecret.maple\ (Roaming). + app_handle + .path() + .app_config_dir() + .map_err(|e| anyhow!("Failed to resolve app config dir: {e}"))? + } else { + // macOS/Linux: ~/.config/maple/ — unchanged for byte-identical behavior. + let app_name = "maple"; + let home_dir = + std::env::var("HOME").map_err(|_| anyhow!("Failed to get home directory"))?; + PathBuf::from(home_dir).join(".config").join(app_name) + }; // Ensure directory exists tokio::fs::create_dir_all(&app_dir).await?; @@ -235,8 +310,34 @@ async fn get_config_path() -> Result { Ok(app_dir.join("proxy_config.json")) } -async fn save_proxy_config(config: &ProxyConfig) -> Result<()> { - let path = get_config_path().await?; +async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Result<()> { + let path = get_config_path(app_handle).await?; + + // On Windows, move the API key into Credential Manager and scrub it from + // the JSON (the config dir is the roaming profile). Other platforms keep + // the existing plaintext-in-JSON behavior unchanged. + #[cfg(target_os = "windows")] + let json = { + let scrubbed = match store_api_key(&config.api_key) { + Ok(true) => ProxyConfig { + api_key: String::new(), + ..config.clone() + }, + Ok(false) => config.clone(), + // Clearing the key failed: don't scrub the JSON and report success, + // since the stale credential survives and would be resurrected on + // the next load. Propagate so the failure is visible. + Err(e) if config.api_key.is_empty() => return Err(e), + // Storing failed for another reason: fall back to persisting the key + // in plaintext JSON so it isn't lost. + Err(e) => { + log::warn!("{e}"); + config.clone() + } + }; + serde_json::to_string_pretty(&scrubbed)? + }; + #[cfg(not(target_os = "windows"))] let json = serde_json::to_string_pretty(config)?; // Write the config file @@ -253,22 +354,33 @@ async fn save_proxy_config(config: &ProxyConfig) -> Result<()> { Ok(()) } -async fn load_saved_proxy_config() -> Result { - let path = get_config_path().await?; +async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result { + let path = get_config_path(app_handle).await?; if !path.exists() { return Ok(ProxyConfig::default()); } let json = tokio::fs::read_to_string(path).await?; - let config: ProxyConfig = serde_json::from_str(&json)?; + #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] + let mut config: ProxyConfig = serde_json::from_str(&json)?; + + // On Windows, prefer the API key from Credential Manager; fall back to any + // plaintext value still in the JSON if it's unavailable. + #[cfg(target_os = "windows")] + if let Some(key) = load_api_key()? { + if !key.is_empty() { + config.api_key = key; + } + } + Ok(config) } // Initialize proxy on app startup if auto_start is enabled pub async fn init_proxy_on_startup_simple(app_handle: AppHandle) -> Result<()> { // Load saved config - let config = load_saved_proxy_config().await?; + let config = load_saved_proxy_config(&app_handle).await?; // Check if auto-start is enabled and we have an API key if config.auto_start && !config.api_key.is_empty() { @@ -278,7 +390,7 @@ pub async fn init_proxy_on_startup_simple(app_handle: AppHandle) -> Result<()> { let proxy_state: tauri::State = app_handle.state(); // Try to start the proxy - match start_proxy(proxy_state, config.clone()).await { + match start_proxy(app_handle.clone(), proxy_state, config.clone()).await { Ok(_) => { log::info!( "Proxy auto-started successfully on {}:{}", diff --git a/frontend/src-tauri/src/tts.rs b/frontend/src-tauri/src/tts.rs index 602cd8e0..2fb9d2d2 100644 --- a/frontend/src-tauri/src/tts.rs +++ b/frontend/src-tauri/src/tts.rs @@ -802,6 +802,16 @@ struct DownloadProgress { #[tauri::command] pub async fn tts_download_models(app: AppHandle) -> Result<(), String> { + // Log the exact cause of any failure (each error string names the file and + // the underlying error). Without this, a failed download produced no log + // line after "Downloading TTS model: ...", and the frontend swallowed the + // plain-string error behind a generic message. See PR #520 review. + tts_download_models_impl(&app).await.inspect_err(|e| { + log::error!("TTS model download failed: {e}"); + }) +} + +async fn tts_download_models_impl(app: &AppHandle) -> Result<(), String> { use std::time::Duration; let models_dir = get_tts_models_dir().map_err(|e| e.to_string())?; @@ -851,12 +861,13 @@ pub async fn tts_download_models(app: AppHandle) -> Result<(), String> { .get(&url) .send() .await - .map_err(|e| format!("Failed to download {file_name}: {e}"))?; + .map_err(|e| format!("Failed to download {file_name} from {url}: {e}"))?; if !response.status().is_success() { return Err(format!( - "Failed to download {}: HTTP {}", + "Failed to download {} from {}: HTTP {}", file_name, + url, response.status() )); } diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index f26a64dc..dcddd223 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -63,7 +63,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["nsis", "deb", "appimage", "rpm", "dmg", "app"], "publisher": "OpenSecret", "icon": [ "icons/32x32.png", @@ -102,7 +102,13 @@ "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", - "timestampUrl": "" + "timestampUrl": "", + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "nsis": { + "installMode": "currentUser" + } }, "createUpdaterArtifacts": true } diff --git a/frontend/src-tauri/tauri.windows.conf.json b/frontend/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..af4b19f3 --- /dev/null +++ b/frontend/src-tauri/tauri.windows.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "bundle": { + "windows": { + "nsis": { + "installerHooks": "resources/windows/install-dlls.nsh" + } + } + } +} diff --git a/frontend/src/components/DocumentPlatformDialog.tsx b/frontend/src/components/DocumentPlatformDialog.tsx index 75db1e20..f97655ec 100644 --- a/frontend/src/components/DocumentPlatformDialog.tsx +++ b/frontend/src/components/DocumentPlatformDialog.tsx @@ -55,7 +55,7 @@ export function DocumentPlatformDialog({
Desktop - macOS • Linux + macOS • Linux • Windows
diff --git a/frontend/src/services/tts/TTSContext.tsx b/frontend/src/services/tts/TTSContext.tsx index 2a5089d5..42acf19b 100644 --- a/frontend/src/services/tts/TTSContext.tsx +++ b/frontend/src/services/tts/TTSContext.tsx @@ -165,7 +165,16 @@ export function TTSProvider({ children }: { children: ReactNode }) { } catch (err) { console.error("TTS download failed:", err); setStatus("error"); - setError(err instanceof Error ? err.message : "Failed to download TTS models"); + // Tauri commands returning Result<_, String> reject with a plain string, + // so `err instanceof Error` is false — surface the string directly instead + // of hiding the real cause behind a generic message. See PR #520 review. + let message = "Failed to download TTS models"; + if (typeof err === "string") { + message = err; + } else if (err instanceof Error) { + message = err.message; + } + setError(message); } finally { cleanupDownloadListener(); }