From ea08c207cbe0bb0d4da871c76a8de78291167ef5 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 11 May 2026 16:59:33 -0400 Subject: [PATCH 01/11] Add minimal Windows port skeleton for compile + launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three minimum-viable changes to unblock building Maple on Windows. Atomic migration, keyring storage, signing, TTS DLL shipping, and the CI matrix job are deferred to follow-up epics. tauri.conf.json (MPLR-uyqowcnn / 1a): - bundle.targets switched from "all" to an explicit list excluding "msi". Tauri's WiX template hard-codes HKLM + perMachine install, which would shadow the HKCU keys we rely on for cloud.opensecret.maple deep links. - bundle.windows.nsis.installMode = "currentUser" so the NSIS installer is per-user (no UAC) and writes deep-link registration under HKCU. - bundle.windows.webviewInstallMode.type = "downloadBootstrapper" set explicitly to avoid surprise behavior if the Tauri default changes. capabilities/default.json (MPLR-zivybmgl / 1b): - Dropped $HOME/.config/maple/** scope entries from every fs:allow-* permission. The path tokens don't resolve to anything meaningful on Windows, no JS code touches them via the Tauri fs plugin, and proxy.rs writes via raw std::fs which bypasses the capability system entirely. $APPCONFIG/** entries are kept. src/proxy.rs (MPLR-oshibkah / 1c): - get_config_path now takes &AppHandle and resolves to %APPDATA%\cloud.opensecret.maple\ on Windows via Tauri's path resolver. macOS and Linux behavior is byte-identical (still ~/.config/maple/). - AppHandle is plumbed through save_proxy_config, load_saved_proxy_config, start_proxy, load_proxy_config, save_proxy_settings, and the auto-start initializer. Frontend invoke() calls are unchanged — Tauri auto-injects AppHandle on commands. - USERPROFILE fallback removed; Windows now uses the proper Tauri path. cargo check + cargo clippy -D warnings + cargo fmt --check all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src-tauri/capabilities/default.json | 25 ++------- frontend/src-tauri/src/proxy.rs | 55 +++++++++++--------- frontend/src-tauri/tauri.conf.json | 10 +++- 3 files changed, 43 insertions(+), 47 deletions(-) diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 9e75622f8..6263b21e1 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/**" }] }, { "identifier": "fs:allow-write-file", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }] }, { "identifier": "fs:allow-create", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }] }, { "identifier": "fs:allow-exists", - "allow": [ - { "path": "$APPCONFIG/**" }, - { "path": "$HOME/.config/maple/**" } - ] + "allow": [{ "path": "$APPCONFIG/**" }] }, { "identifier": "fs:allow-mkdir", - "allow": [ - { "path": "$APPCONFIG" }, - { "path": "$HOME/.config/maple" } - ] + "allow": [{ "path": "$APPCONFIG" }] }, { "identifier": "opener:allow-open-url", diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index 1b2053575..d101e297e 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -64,6 +64,7 @@ impl ProxyState { #[tauri::command] pub async fn start_proxy( + app_handle: AppHandle, state: State<'_, ProxyState>, config: ProxyConfig, ) -> Result { @@ -132,7 +133,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 +186,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 +215,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. +// Epic 2 (PR 3) will unify all platforms onto app_config_dir() and add atomic +// migration + keyring-backed secret storage. This minimal arm just unblocks +// Windows compile + launch. +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 +240,8 @@ 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?; let json = serde_json::to_string_pretty(config)?; // Write the config file @@ -253,8 +258,8 @@ 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()); @@ -268,7 +273,7 @@ async fn load_saved_proxy_config() -> Result { // 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 +283,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/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index f26a64dcb..dcddd2239 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 } From 206546bf1ea1a2f8d26c4481116d60dc033fd5a2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 20 May 2026 20:40:32 -0400 Subject: [PATCH 02/11] Add windows-latest CI jobs for unsigned NSIS builds Introduces Windows as a third desktop build target across both desktop CI workflows. Both jobs are born conformant with the patterns shipped during PR review (pinned actions, Bun 1.3.5, Rust 1.95.0, sha256-verified binary fetches via onnxruntime-pins.sh + sibling helper script). desktop-build.yml (MPLR-hzehaxpp / 1e): - New build-windows job runs on push to master, mirrors build-macos / build-linux step-for-step, builds via tauri-action (signing env wired up so the action doesn't error mid-build, though Authenticode is deliberately out of scope here), uploads the .exe artifact. - All third-party actions SHA-pinned to the same commits used by the Linux/macOS jobs: actions/checkout, oven-sh/setup-bun, dtolnay/rust-toolchain, actions/cache, tauri-apps/tauri-action, actions/upload-artifact. - bundle.targets in tauri.conf.json already constrains the Windows build to NSIS (MPLR-zsvmhjjx / 1i), so the redundant `--bundles nsis` tauri-action arg is omitted. desktop-pr-build.yml (MPLR-hzehaxpp / 1e): - Parallel build-windows job runs on pull_request, modeled on the existing macOS/Linux PR jobs: no secrets, no codesigning, no updater artifacts (`bun tauri build --no-sign --config '{"bundle":{"createUpdaterArtifacts":false}}'`), dev-environment VITE URLs, artifact suffixed `-pr`. sccache install (MPLR-gelmlpfg / 1h): - Mirrors the Linux job's style (long-form curl, `sha256sum --check -`, current-dir extract). Windows twist: download/verify/extract run inside a subshell cd'd to RUNNER_TEMP so all paths stay relative. MSYS2 tar mangles absolute D:\ paths passed via -C/-f even with --force-local; relative paths sidestep it. Inline comment explains. ONNX Runtime install (MPLR-hzehaxpp / 1e): - New provide-windows-onnxruntime.sh helper mirrors provide-linux-onnxruntime.sh one-for-one: same sha256_file / verify_sha256 helpers, archive + DLL double SHA256 verify, emits ORT_LIB_LOCATION / ORT_SKIP_DOWNLOAD / ORT_DYLIB_PATH for the GITHUB_ENV file. Workflow step reduces to a single invocation piped to GITHUB_ENV. - onnxruntime-pins.sh extended with windows_x64_archive_sha256 and windows_x64_dll_sha256 lookups for 1.22.0 (matches the existing linux_x64 pattern). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/desktop-build.yml | 87 +++++++++++++++++++ .github/workflows/desktop-pr-build.yml | 84 ++++++++++++++++++ .../src-tauri/scripts/onnxruntime-pins.sh | 24 +++++ .../scripts/provide-windows-onnxruntime.sh | 80 +++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100755 frontend/src-tauri/scripts/provide-windows-onnxruntime.sh diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index febe7951b..4569b048d 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -193,3 +193,90 @@ 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: 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 + retention-days: 5 diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 4cba67b17..075b38e42 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -122,3 +122,87 @@ 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: 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/scripts/onnxruntime-pins.sh b/frontend/src-tauri/scripts/onnxruntime-pins.sh index fdb41f718..176981e12 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 000000000..4fbf3142c --- /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}")" From 32630ffd59256714c0f0839495ec6095fd31db0f Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 26 May 2026 13:31:38 -0400 Subject: [PATCH 03/11] Restore $HOME/.config/maple capability scope for proxy config proxy.rs writes proxy_config.json to ~/.config/maple on macOS/Linux (byte-identical behavior deferred to PR3's app_config_dir() unification), but the capability only allowed $APPCONFIG/** ($APPCONFIG resolves to the bundle-identifier dir, e.g. ~/.config/cloud.opensecret.maple on Linux). Those writes fell outside the allowed scope and failed with permission errors. Restore the $HOME/.config/maple entries to match proxy.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src-tauri/capabilities/default.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 6263b21e1..8695013b0 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -11,23 +11,23 @@ "fs:default", { "identifier": "fs:allow-read-file", - "allow": [{ "path": "$APPCONFIG/**" }] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-write-file", - "allow": [{ "path": "$APPCONFIG/**" }] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-create", - "allow": [{ "path": "$APPCONFIG/**" }] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-exists", - "allow": [{ "path": "$APPCONFIG/**" }] + "allow": [{ "path": "$APPCONFIG/**" }, { "path": "$HOME/.config/maple/**" }] }, { "identifier": "fs:allow-mkdir", - "allow": [{ "path": "$APPCONFIG" }] + "allow": [{ "path": "$APPCONFIG" }, { "path": "$HOME/.config/maple" }] }, { "identifier": "opener:allow-open-url", From 4bd47a6f01a30e8f62db024aac45475bc6360f91 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 26 May 2026 13:31:38 -0400 Subject: [PATCH 04/11] Upload Windows updater artifacts from release build The build-windows job signs builds and tauri.conf.json sets createUpdaterArtifacts: true, so Tauri emits .nsis.zip and .nsis.zip.sig alongside the .exe installer. The upload step only globbed *.exe, silently discarding the updater artifacts (inconsistent with the Linux job). Add the .nsis.zip and .nsis.zip.sig globs so auto-update artifacts are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/desktop-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4569b048d..8adbd0239 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -279,4 +279,6 @@ jobs: 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 From c20a3a541f45cab77d7cb06cb6df9206db539d77 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 26 May 2026 17:24:32 -0400 Subject: [PATCH 05/11] Store proxy API key in Windows Credential Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Credential Manager via keyring (windows-native) and scrub it from the JSON; restore it on load. If secure storage is unavailable, fall back to plaintext-in-JSON. All keyring code and the dependency are gated to target_os="windows" — macOS/Linux keep their existing local plaintext-with-0o600 behavior, byte-for-byte unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src-tauri/Cargo.lock | 15 ++++- frontend/src-tauri/Cargo.toml | 5 ++ frontend/src-tauri/src/proxy.rs | 103 ++++++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 451ae2fd7..b75bde7ec 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 2fd0e8b1f..ab291a63f 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/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index d101e297e..feb096e3f 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -62,6 +62,70 @@ 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). +#[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() { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) => log::warn!("Failed to clear Credential Manager entry: {e}"), + } + return Ok(true); + } + + 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, @@ -216,9 +280,9 @@ pub async fn test_proxy_port(host: String, port: u16) -> Result { } // Helper functions for config persistence. -// Epic 2 (PR 3) will unify all platforms onto app_config_dir() and add atomic -// migration + keyring-backed secret storage. This minimal arm just unblocks -// Windows compile + launch. +// 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). @@ -242,6 +306,26 @@ async fn get_config_path(app_handle: &AppHandle) -> Result { 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(), + 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 @@ -266,7 +350,18 @@ async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result } 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) } From a9d65b55065a89bee4bb93ed0be9beffd1916b5b Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 27 May 2026 11:38:03 -0400 Subject: [PATCH 06/11] Log Windows deep-link scheme registration at startup On Windows, OAuth/payment deep links only reach the app if HKCU\Software\Classes\cloud.opensecret.maple\shell\open\command points at the running exe. A missing or stale key (installer skipped, dev build deleted, exe moved) silently breaks the OAuth callback with no signal. Log the result of DeepLinkExt::is_registered() once at startup so support can diagnose "the callback button does nothing" reports. Windows-gated; no behavior change on macOS/Linux. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src-tauri/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a9cee65f2..4159f6ccc 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)] { From 87023dfcef475a998158248bfe2dca14f5d71555 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 27 May 2026 11:38:03 -0400 Subject: [PATCH 07/11] Add Windows to desktop platforms in document upload dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Desktop" card listed only "macOS • Linux". Windows is now a supported desktop target, so include it. Listed last to reflect relative priority. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/DocumentPlatformDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/DocumentPlatformDialog.tsx b/frontend/src/components/DocumentPlatformDialog.tsx index 75db1e209..f97655ec0 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
From c16dcaf90c381e164cf3ddea451b85261dcba542 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 27 May 2026 14:38:24 -0400 Subject: [PATCH 08/11] Bundle ONNX Runtime + VC++ runtime DLLs in Windows installer maple.exe links onnxruntime.dll by ordinal. On a clean Windows install the loader binds that import to the OS-shipped Windows-ML onnxruntime.dll (a forwarder to onnxruntime_x64.dll, currently v1.17), which lets the app launch but hangs TTS at the first Session::builder call. Ship ONNX Runtime 1.22.0 and its four MSVC C++ runtime DLLs (VCRUNTIME140, VCRUNTIME140_1, MSVCP140, MSVCP140_1) next to maple.exe so the executable's own directory wins the Windows DLL search order over System32. The four CRT DLLs are deployed app-local (rather than via vc_redist) to avoid a UAC prompt on the per-user install. Deployment uses an NSIS installer hook, not bundle.resources: 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 never land in the install dir (verified: only maple.exe + uninstall.exe were installed). The hook copies them explicitly into $INSTDIR and removes them on uninstall. - resources/windows/install-dlls.nsh: NSIS_HOOK_POSTINSTALL File-copies the 5 DLLs from /resources/windows into $INSTDIR (anchored on ${MAINBINARYSRCPATH}); NSIS_HOOK_PREUNINSTALL deletes them. - tauri.windows.conf.json: register the hook via bundle.windows.nsis.installerHooks (Windows-only; macOS/Linux/iOS unaffected). - resources/windows/: README + .gitignore (the redistributable DLLs are staged at build time, not committed). - desktop-pr-build.yml / desktop-build.yml: stage the 5 DLLs into resources/windows before the Tauri build, from the already-downloaded ORT (ORT_DYLIB_PATH) and the MSVC CRT redist located via vswhere (year/edition agnostic, with a System32 fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/desktop-build.yml | 30 ++++++ .github/workflows/desktop-pr-build.yml | 30 ++++++ .../src-tauri/resources/windows/.gitignore | 4 + .../src-tauri/resources/windows/README.md | 92 +++++++++++++++++++ .../resources/windows/install-dlls.nsh | 31 +++++++ frontend/src-tauri/tauri.windows.conf.json | 10 ++ 6 files changed, 197 insertions(+) create mode 100644 frontend/src-tauri/resources/windows/.gitignore create mode 100644 frontend/src-tauri/resources/windows/README.md create mode 100644 frontend/src-tauri/resources/windows/install-dlls.nsh create mode 100644 frontend/src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 8adbd0239..13ef38d82 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -246,6 +246,36 @@ jobs: 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 diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 075b38e42..dfb1d31ca 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -175,6 +175,36 @@ jobs: 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 diff --git a/frontend/src-tauri/resources/windows/.gitignore b/frontend/src-tauri/resources/windows/.gitignore new file mode 100644 index 000000000..558b5dcff --- /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 000000000..9147f34a1 --- /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 000000000..9e2fb18aa --- /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/tauri.windows.conf.json b/frontend/src-tauri/tauri.windows.conf.json new file mode 100644 index 000000000..af4b19f39 --- /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" + } + } + } +} From 159c5631ec19a98cc7588b6937207339ccd17029 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 27 May 2026 22:08:23 -0400 Subject: [PATCH 09/11] Remove in-window menu bar on Windows and Linux The desktop menu was built as a flat in-window bar on Windows/Linux (About / Check for Updates / Undo / Redo / Cut / Copy / Paste / Select All / Quit), adding a cluttered, dated row at the top of the window. The edit items are handled natively by the webview regardless of the menu, About is available in the in-app account menu, and updates are applied by the automatic check (startup + hourly), so the bar added only noise. Gate the entire menu-creation block (and its event handler) to macOS, which keeps its proper nested app/Edit submenus. Windows and Linux now get a clean window with just the title bar. The updater auto-check loop is untouched and still uses check_for_updates, so no code is orphaned. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src-tauri/src/lib.rs | 115 +++++++++++++--------------------- 1 file changed, 45 insertions(+), 70 deletions(-) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 4159f6ccc..1749ede55 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -132,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(); From bf6c3b1b9f73c6fab95cc232a460a1bf22a680f3 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 1 Jun 2026 13:10:58 -0400 Subject: [PATCH 10/11] Fail loudly when clearing the proxy API key from Credential Manager A hard delete_credential() failure left the stale credential in place but still reported success, so save_proxy_config scrubbed the key from the JSON. On the next load the surviving credential won over the (empty) JSON value and the cleared key was silently resurrected. Propagate the delete failure instead of warning + returning Ok(true), and have save_proxy_config surface a clear failure rather than scrubbing the JSON. Store failures keep the existing plaintext-fallback behavior. Co-Authored-By: Claude Opus 4.8 --- frontend/src-tauri/src/proxy.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index feb096e3f..0eb6345c1 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -73,7 +73,9 @@ 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). +/// 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) { @@ -85,11 +87,15 @@ fn store_api_key(key: &str) -> Result { }; if key.is_empty() { - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} - Err(e) => log::warn!("Failed to clear Credential Manager entry: {e}"), - } - return Ok(true); + 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) { @@ -318,6 +324,12 @@ async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Resu ..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() From 02da761cfb167bf5750676e29fc57cc08e10b756 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 2 Jun 2026 12:02:03 -0400 Subject: [PATCH 11/11] Surface TTS model download errors instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failure branch in tts_download_models returned Err(String) without logging, so a failed download produced no log line after "Downloading TTS model: ..." and the exact cause was invisible. The frontend compounded this with `err instanceof Error ? err.message : "..."`, which discards the plain-string error a Tauri Result<_, String> rejects with — the user only ever saw a generic "Failed to download TTS models". - tts.rs: wrap the flow so any propagated error is logged once via log::error!; include the URL (and existing HTTP status) on the network branches so HTTP / TLS / timeout / DNS are distinguishable from size / checksum failures. - TTSContext.tsx: surface a string rejection directly so the real message reaches the dialog (covers download and load failures). Reported by @AnthonyRonning testing on Windows 11 ARM (PR #520 review). Co-Authored-By: Claude Opus 4.8 --- frontend/src-tauri/src/tts.rs | 15 +++++++++++++-- frontend/src/services/tts/TTSContext.tsx | 11 ++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/src-tauri/src/tts.rs b/frontend/src-tauri/src/tts.rs index 602cd8e07..2fb9d2d24 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/services/tts/TTSContext.tsx b/frontend/src/services/tts/TTSContext.tsx index 2a5089d54..42acf19b8 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(); }