From aee682f63d9da3f943ad8f32ec42fde369314988 Mon Sep 17 00:00:00 2001 From: Jaden Yuros Date: Mon, 13 Apr 2026 15:47:59 -0700 Subject: [PATCH 1/7] feat: add AWS Bedrock (Mantle) as post-processing provider (#1288) --- src-tauri/src/settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d930599cc..878d5a98e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -589,6 +589,16 @@ fn default_post_process_providers() -> Vec { }); } + // AWS Bedrock via Mantle (OpenAI-compatible endpoint) + providers.push(PostProcessProvider { + id: "bedrock_mantle".to_string(), + label: "AWS Bedrock (Mantle)".to_string(), + base_url: "https://bedrock-mantle.us-east-1.api.aws/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), + supports_structured_output: true, + }); + // Custom provider always comes last providers.push(PostProcessProvider { id: "custom".to_string(), From a4d671a601a5dd5c98d27828d515e4fd307c39f6 Mon Sep 17 00:00:00 2001 From: Christoph Noetel <88427028+ChristophNoetel@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:58:16 +0200 Subject: [PATCH 2/7] fix: improve German translation quality (#1292) --- src/i18n/locales/de/translation.json | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 821b94463..c031bea6d 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -103,11 +103,11 @@ "description": "Handy benötigt einige Berechtigungen, um richtig zu funktionieren.", "microphone": { "title": "Mikrofonzugriff", - "description": "Erforderlich, um Ihre Stimme für die Transkription zu hören." + "description": "Erforderlich, um deine Stimme für die Transkription zu hören." }, "accessibility": { "title": "Bedienungshilfen-Zugriff", - "description": "Erforderlich, um transkribierten Text in Ihre Anwendungen einzugeben." + "description": "Erforderlich, um transkribierten Text in deine Anwendungen einzugeben." }, "grant": "Berechtigung erteilen", "granted": "Erteilt", @@ -185,7 +185,7 @@ "bindings": { "transcribe": { "name": "Transkriptions-Tastenkürzel", - "description": "Das Tastenkürzel zum Aufnehmen und Transkribieren Ihrer Stimme." + "description": "Das Tastenkürzel zum Aufnehmen und Transkribieren deiner Stimme." }, "cancel": { "name": "Abbrechen-Tastenkürzel", @@ -193,7 +193,7 @@ }, "transcribe_with_post_process": { "name": "Nachbearbeitungs-Tastenkürzel", - "description": "Optional: Ein dediziertes Tastenkürzel, das immer die KI-Nachbearbeitung auf Ihre Transkription anwendet." + "description": "Optional: Ein dediziertes Tastenkürzel, das immer die KI-Nachbearbeitung auf deine Transkription anwendet." } }, "errors": { @@ -266,7 +266,7 @@ }, "gpuDevice": { "title": "GPU-Gerät", - "description": "Wählen Sie die GPU für die Whisper-Inferenz. Auto wählt die dedizierte GPU.", + "description": "Wähle die GPU für die Whisper-Inferenz. Auto wählt die dedizierte GPU.", "auto": "Auto" } }, @@ -306,7 +306,7 @@ }, "typingTool": { "title": "Eingabetool", - "description": "Wählen Sie, welches Linux-Eingabetool für die Direkt-Einfügen-Methode verwendet werden soll. Auto erkennt und verwendet automatisch das beste verfügbare Tool für Ihr System.", + "description": "Wähle, welches Linux-Eingabetool für die Direkt-Einfügen-Methode verwendet werden soll. Auto erkennt und verwendet automatisch das beste verfügbare Tool für dein System.", "options": { "auto": "Auto (Empfohlen)" } @@ -502,11 +502,11 @@ }, "pasteDelay": { "title": "Einfügeverzögerung", - "description": "Verzögerung vor dem Senden des Einfüge-Tastendrucks (in Millisekunden). Erhöhen Sie den Wert, wenn falscher Text eingefügt wird." + "description": "Verzögerung vor dem Senden des Einfüge-Tastendrucks (in Millisekunden). Erhöhe den Wert, wenn falscher Text eingefügt wird." }, "recordingBuffer": { "title": "Zusätzlicher Aufnahmepuffer", - "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiterzuzeichnen, um nachlaufendes Audio aufzunehmen. 0 = kein zusätzlicher Puffer." + "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiter aufzunehmen, um nachlaufendes Audio zu erfassen. 0 = kein zusätzlicher Puffer." } }, "about": { @@ -552,9 +552,9 @@ "installing": "Wird installiert...", "preparing": "Wird vorbereitet...", "checkForUpdates": "Nach Updates suchen", - "portableUpdateTitle": "Manual update required", - "portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.", - "portableUpdateButton": "Open GitHub Releases" + "portableUpdateTitle": "Manuelles Update erforderlich", + "portableUpdateMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Zum Aktualisieren: Lade den neuesten NSIS-Installer von GitHub Releases herunter, installiere ihn im selben Ordner und kopiere dann deinen Data/-Ordner (Einstellungen, Modelle, Aufnahmen) von der alten Version in die neue.", + "portableUpdateButton": "GitHub Releases öffnen" }, "common": { "loading": "Wird geladen...", @@ -588,13 +588,13 @@ "loadDirectory": "Fehler beim Laden des Verzeichnisses: {{error}}", "micPermissionDeniedTitle": "Mikrofonzugriff verweigert", "micPermissionDenied": { - "generic": "Der Mikrofonzugriff wurde vom Betriebssystem verweigert. Bitte erteilen Sie die Mikrofonberechtigung in Ihren Systemeinstellungen.", - "windows": "Aktivieren Sie den Mikrofonzugriff unter Einstellungen → Datenschutz und Sicherheit → Mikrofon (einschließlich Desktop-App-Zugriff).", - "macos": "Erteilen Sie den Mikrofonzugriff in Systemeinstellungen → Datenschutz & Sicherheit → Mikrofon.", - "linux": "Erteilen Sie den Mikrofonzugriff in den Sound- oder Datenschutzeinstellungen Ihres Systems." + "generic": "Der Mikrofonzugriff wurde vom Betriebssystem verweigert. Bitte erteile die Mikrofonberechtigung in deinen Systemeinstellungen.", + "windows": "Aktiviere den Mikrofonzugriff unter Einstellungen → Datenschutz und Sicherheit → Mikrofon (einschließlich Desktop-App-Zugriff).", + "macos": "Erteile den Mikrofonzugriff in Systemeinstellungen → Datenschutz & Sicherheit → Mikrofon.", + "linux": "Erteile den Mikrofonzugriff in den Sound- oder Datenschutzeinstellungen deines Systems." }, "noInputDeviceTitle": "Kein Mikrofon gefunden", - "noInputDevice": "Es wurde kein Audio-Eingabegerät erkannt. Bitte schließen Sie ein Mikrofon oder Headset an und versuchen Sie es erneut.", + "noInputDevice": "Es wurde kein Audio-Eingabegerät erkannt. Bitte schließe ein Mikrofon oder Headset an und versuche es erneut.", "recordingFailed": "Aufnahme konnte nicht gestartet werden: {{error}}", "modelLoadFailed": "Modell konnte nicht geladen werden: {{model}}", "modelLoadFailedUnknown": "unbekanntes Modell", From c1e11faa71f010436d4ff63b3467f8d6973ecba8 Mon Sep 17 00:00:00 2001 From: Evgeny Khudoba Date: Thu, 16 Apr 2026 06:06:59 +0700 Subject: [PATCH 3/7] refactor(nix): replace manual bindgen env with rustPlatform.bindgenHook (#1255) --- flake.nix | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 005af740d..52f69b521 100644 --- a/flake.nix +++ b/flake.nix @@ -60,8 +60,6 @@ # Shared environment variables for Rust/native builds commonEnv = pkgs: let lib = pkgs.lib; in { - LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; - BINDGEN_EXTRA_CLANG_ARGS = "-isystem ${pkgs.llvmPackages.libclang.lib}/lib/clang/${lib.getVersion pkgs.llvmPackages.libclang}/include -isystem ${pkgs.glibc.dev}/include"; ORT_LIB_LOCATION = "${pkgs.onnxruntime}/lib"; ORT_PREFER_DYNAMIC_LINK = "1"; GST_PLUGIN_SYSTEM_PATH_1_0 = "${lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" (gstPlugins pkgs)}"; @@ -148,7 +146,7 @@ pkgs.bun2nix.hook # Sets up node_modules from pre-fetched bun cache jq cmake - llvmPackages.libclang + rustPlatform.bindgenHook shaderc ]; @@ -246,13 +244,11 @@ # Build tools cargo-tauri pkg-config - llvmPackages.libclang + rustPlatform.bindgenHook cmake ]); inherit (commonEnv pkgs) - LIBCLANG_PATH - BINDGEN_EXTRA_CLANG_ARGS ORT_LIB_LOCATION ORT_PREFER_DYNAMIC_LINK GST_PLUGIN_SYSTEM_PATH_1_0; From af6ec6c903e4c315dbbc395263069b24488596d9 Mon Sep 17 00:00:00 2001 From: Evgeny Khudoba Date: Sun, 19 Apr 2026 20:32:12 +0700 Subject: [PATCH 4/7] chore(nix): add bun node_modules normalization scripts (#1256) --- .nix/scripts/canonicalize-node-modules.ts | 97 +++++++++ .nix/scripts/heal-peer-dep-bins.ts | 229 ++++++++++++++++++++++ .nix/scripts/normalize-bun-binaries.ts | 102 ++++++++++ .nix/scripts/normalize-install.ts | 36 ++++ 4 files changed, 464 insertions(+) create mode 100644 .nix/scripts/canonicalize-node-modules.ts create mode 100644 .nix/scripts/heal-peer-dep-bins.ts create mode 100644 .nix/scripts/normalize-bun-binaries.ts create mode 100644 .nix/scripts/normalize-install.ts diff --git a/.nix/scripts/canonicalize-node-modules.ts b/.nix/scripts/canonicalize-node-modules.ts new file mode 100644 index 000000000..323569675 --- /dev/null +++ b/.nix/scripts/canonicalize-node-modules.ts @@ -0,0 +1,97 @@ +/** + * Canonicalize bun's internal node_modules symlinks for reproducible FOD hashes. + * + * Isolated-install layout produced by `bun install --linker=isolated`: + * + * node_modules/ + * ├── react → .bun/react@18.3.1/node_modules/react (symlink) + * ├── minimatch → .bun/minimatch@3.1.2/node_modules/minimatch (symlink) + * └── .bun/ + * ├── react@18.3.1/ + * │ └── node_modules/ + * │ └── react/ ← real package content + * ├── minimatch@3.1.2/ + * │ └── node_modules/ + * │ └── minimatch/ ← real package content + * └── node_modules/ ← target of this script + * ├── react → ../react@18.3.1/node_modules/react + * ├── minimatch → ../minimatch@3.1.2/node_modules/minimatch + * └── @babel/ + * └── core → ../../@babel+core@7.28.5+…/node_modules/@babel/core + * + * Real package content lives in .bun/@/node_modules//. + * The .bun/node_modules/ directory (linkRoot) holds only symlinks — it acts + * as a fallback upward-resolution path for packages inside .bun/. + * + * Bun's creation order for those symlinks is not guaranteed to be stable + * across hosts or filesystems, which can break fixed-output derivation hashes. + * This script reads the existing symlinks, removes them, and recreates them + * in lexicographic order while preserving the exact targets bun picked. + */ + +import { lstat, mkdir, readdir, readlink, rm, symlink } from "fs/promises"; +import { join } from "path"; + +type LinkEntry = { + slug: string; + target: string; +}; + +async function isDirectory(path: string) { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch { + return false; + } +} + +async function collectLinks(dir: string, prefix: string): Promise { + const result: LinkEntry[] = []; + const names = await readdir(dir); + for (const name of names) { + const full = join(dir, name); + const info = await lstat(full); + if (info.isSymbolicLink()) { + const target = await readlink(full); + const slug = prefix ? `${prefix}/${name}` : name; + result.push({ slug, target }); + } else if (info.isDirectory() && !prefix && name.startsWith("@")) { + result.push(...(await collectLinks(full, name))); + } + } + return result; +} + +export async function canonicalizeNodeModules(): Promise { + const root = process.cwd(); + const linkRoot = join(root, "node_modules/.bun/node_modules"); + + if (!(await isDirectory(linkRoot))) { + console.log( + "[canonicalize-node-modules] no .bun/node_modules directory, skipping", + ); + return; + } + + const entries = await collectLinks(linkRoot, ""); + entries.sort((a, b) => a.slug.localeCompare(b.slug)); + + await rm(linkRoot, { recursive: true, force: true }); + await mkdir(linkRoot, { recursive: true }); + + for (const { slug, target } of entries) { + const parts = slug.split("/"); + const leaf = parts.pop(); + if (!leaf) continue; + const parent = join(linkRoot, ...parts); + await mkdir(parent, { recursive: true }); + await symlink(target, join(parent, leaf)); + } + + console.log(`[canonicalize-node-modules] rebuilt ${entries.length} links`); +} + +if (import.meta.main) { + await canonicalizeNodeModules(); +} diff --git a/.nix/scripts/heal-peer-dep-bins.ts b/.nix/scripts/heal-peer-dep-bins.ts new file mode 100644 index 000000000..80eb8888b --- /dev/null +++ b/.nix/scripts/heal-peer-dep-bins.ts @@ -0,0 +1,229 @@ +/** + * Heal missing `.bin/` symlinks produced by bun's isolated installer. + * + * ## The bug + * + * Bun's `--linker=isolated` installer creates `.bin/` symlinks inside + * each package's private node_modules/.bin/ for every dependency that has a + * `bin` field in its manifest — regular, optional, AND peer dependencies all + * go through the same code path (`Installer.zig::linkDependencyBins`), and + * the decision to link is made purely on whether the source file exists on + * disk at the moment the linker looks (`bin.zig`: + * + * if (!bun.sys.exists(abs_target)) { + * this.skipped_due_to_missing_bin = true; + * return; + * } + * + * ). For most dependencies the installer blocks the consuming package on the + * provider via `isTaskBlocked`, so by the time `linkDependencyBins` runs for + * package A the provider's file is guaranteed to be in place. But for + * circular peer dependency pairs — A declares B as a peer, B (transitively) + * depends on A — that blocking would deadlock, so bun's `Store.isCycle` + * detector explicitly bypasses it and lets both sides run in parallel. + * + * The consequence is a plain timing race between two worker threads. Which + * side wins depends on anything that shifts the relative scheduling of the + * two workers — CPU load, thread-pool size, filesystem write latency and + * caching, the kernel scheduler, NICE / cgroup limits — so the same bun + * version with the same bun.lock and the same install flags can produce + * different `.bin/` sets not just between different hosts but in principle + * between two consecutive runs on the same host. In practice we have + * observed divergence between a local NixOS sandbox, a GitHub Actions + * ubuntu-latest runner, and a GitHub Actions macos-latest runner, which is + * enough to break any single-hash FOD. + * + * Concretely, the Handy install hits this with + * - update-browserslist-db/.bin/browserslist (update-browserslist-db + * declares browserslist as a peer, browserslist has + * update-browserslist-db in its regular dependencies → cycle), and + * - @eslint-community/eslint-utils/.bin/eslint (eslint has eslint-utils + * as a regular dep, eslint-utils declares eslint as a peer → cycle). + * + * There is no bun configuration flag or env var that makes the outcome + * deterministic, and no upstream issue yet tracks this specific symptom + * (oven-sh/bun#28147 is the closest family match, different project). See + * the header of `canonicalize-node-modules.ts` for our sibling normalization + * pass that rebuilds `.bun/node_modules/` in sorted order. + * + * ## The fix this script applies + * + * For every package under `node_modules/.bun/` we walk its declared + * `peerDependencies`, find each resolved peer inside the package's private + * `node_modules/`, and create any `.bin/ → ..//` + * symlinks that bun's installer "intended" to create but may have skipped. + * Entries that already exist are left alone (the script is idempotent). + * + * This is the "fix by adding" approach — we produce the complete `.bin/` + * set that bun would have produced without the race, rather than stripping + * the inconsistent subset. Advantages: + * + * - Matches bun's intended behavior; if bun ever fixes the race upstream, + * this script becomes a no-op (every entry it would add already exists) + * and the FOD hash is unchanged. + * - Preserves `.bin/` entries that real code might depend on. We don't + * rely on the (true but brittle) argument that peer-dep `.bin/` entries + * are dead code in Tauri apps. + * - Easy to explain in review: we're patching a known upstream race bug + * with the exact output the upstream code is trying to produce. + */ + +import { lstat, mkdir, readdir, readlink, symlink } from "fs/promises"; +import { join } from "path"; + +type Manifest = { + name?: string; + bin?: string | Record; + peerDependencies?: Record; +}; + +async function isDirectory(path: string) { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch { + return false; + } +} + +async function readManifest(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return null; + return (await file.json()) as Manifest; +} + +// Parse a .bun entry directory name (e.g. "@babel+core@7.28.5+a1c3dd1b9adf390b") +// back into an npm package name ("@babel/core"). Returns null for entries that +// do not look like @[+]. +function parsePkgName(bunEntry: string): string | null { + const at = bunEntry.startsWith("@") + ? bunEntry.indexOf("@", 1) + : bunEntry.indexOf("@"); + if (at <= 0) return null; + return bunEntry.slice(0, at).replace(/\+/g, "/"); +} + +// Unscoped name used as the default bin name when `bin` is a bare string. +// For "@scope/pkg" returns "pkg"; for "pkg" returns "pkg". +function defaultBinName(pkgName: string): string { + const slash = pkgName.lastIndexOf("/"); + return slash >= 0 ? pkgName.slice(slash + 1) : pkgName; +} + +type BinSpec = { name: string; path: string }; + +function parseBinField(pkgName: string, binField: Manifest["bin"]): BinSpec[] { + if (!binField) return []; + if (typeof binField === "string") { + return [{ name: defaultBinName(pkgName), path: binField }]; + } + return Object.entries(binField).map(([name, path]) => ({ + name: defaultBinName(name), + path, + })); +} + +// Produce the relative symlink target that bun itself uses for a .bin entry +// sitting inside `.bun//node_modules/.bin/`, pointing to a file +// under `.bun//node_modules//...`. +function binTarget(peerName: string, binPath: string): string { + const clean = binPath.replace(/^\.\//, ""); + return `../${peerName}/${clean}`; +} + +type HealedEntry = { + containingEntry: string; + containingPkg: string; + peerName: string; + binName: string; + target: string; +}; + +export async function healPeerDepBins(): Promise { + const root = process.cwd(); + const bunRoot = join(root, "node_modules/.bun"); + + if (!(await isDirectory(bunRoot))) { + console.log("[heal-peer-dep-bins] no .bun directory, skipping"); + return; + } + + const bunEntries = (await readdir(bunRoot)).sort(); + const healed: HealedEntry[] = []; + + for (const entry of bunEntries) { + const pkgName = parsePkgName(entry); + if (!pkgName) continue; + const containingNodeModules = join(bunRoot, entry, "node_modules"); + if (!(await isDirectory(containingNodeModules))) continue; + + const manifest = await readManifest( + join(containingNodeModules, pkgName, "package.json"), + ); + if (!manifest) continue; + + const peers = Object.keys(manifest.peerDependencies ?? {}); + if (peers.length === 0) continue; + + const binRoot = join(containingNodeModules, ".bin"); + + for (const peerName of peers) { + // Peer may be optional and unresolved, or may not even be a real package + // directory in this install layout (e.g. bundled peer). Skip anything we + // cannot verify as "the peer's package.json is reachable from here". + const peerManifest = await readManifest( + join(containingNodeModules, peerName, "package.json"), + ); + if (!peerManifest) continue; + + const bins = parseBinField(peerName, peerManifest.bin); + if (bins.length === 0) continue; + + // Ensure the bin directory exists (it may be absent entirely if bun's + // race skipped every link it would have created for this package). + await mkdir(binRoot, { recursive: true }); + + for (const bin of bins) { + const linkPath = join(binRoot, bin.name); + const target = binTarget(peerName, bin.path); + + // Idempotent: skip if anything already occupies this path. We do not + // overwrite entries bun created; if bun already wrote a .bin/, + // that is either the correct target (race won) or a close variant we + // should not second-guess. + try { + await lstat(linkPath); + continue; + } catch { + // Does not exist — fall through to create. + } + + await symlink(target, linkPath); + healed.push({ + containingEntry: entry, + containingPkg: pkgName, + peerName, + binName: bin.name, + target, + }); + } + } + } + + if (healed.length > 0) { + console.log( + `[heal-peer-dep-bins] healed ${healed.length} missing peer .bin entries:`, + ); + for (const h of healed) { + console.log( + ` ${h.containingEntry}/node_modules/.bin/${h.binName} → ${h.target} (peer ${h.peerName} of ${h.containingPkg})`, + ); + } + } else { + console.log("[heal-peer-dep-bins] nothing to heal"); + } +} + +if (import.meta.main) { + await healPeerDepBins(); +} diff --git a/.nix/scripts/normalize-bun-binaries.ts b/.nix/scripts/normalize-bun-binaries.ts new file mode 100644 index 000000000..a5c2daf98 --- /dev/null +++ b/.nix/scripts/normalize-bun-binaries.ts @@ -0,0 +1,102 @@ +/** + * Normalize .bin symlinks inside bun's internal module directories. + * + * In an isolated install, every package under .bun/ gets its own private + * node_modules/ with a .bin/ directory holding symlinks to its dependencies' + * executables: + * + * node_modules/.bun/ + * ├── @vitejs+plugin-react@4.7.0+…/ + * │ └── node_modules/ + * │ ├── vite → ../../vite@6.4.1+…/node_modules/vite (peer symlink) + * │ └── .bin/ ← target of this script + * │ └── vite → ../vite/bin/vite.js + * ├── eslint@9.39.1+…/ + * │ └── node_modules/ + * │ └── .bin/ + * │ └── eslint → ../eslint/bin/eslint.js + * └── vite@6.4.1+…/ + * └── node_modules/ + * └── vite/ ← real package content + * └── bin/vite.js + * + * Real executables live in .bun/@/node_modules//…; every .bin/ + * entry is just a relative symlink reached through the peer symlinks in the + * same node_modules/. + * + * Bun's creation order for those .bin/ symlinks is not guaranteed to be stable + * across hosts or filesystems, which can break fixed-output derivation hashes. + * This script reads the .bin/ symlinks bun produced, removes them, and + * recreates them in lexicographic order while preserving the exact targets + * bun picked. + */ + +import { lstat, mkdir, readdir, readlink, rm, symlink } from "fs/promises"; +import { join } from "path"; + +type BinEntry = { + name: string; + target: string; +}; + +async function isDirectory(path: string) { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch { + return false; + } +} + +async function collectBinLinks(binRoot: string): Promise { + const entries: BinEntry[] = []; + let names: string[]; + try { + names = await readdir(binRoot); + } catch { + return entries; + } + for (const name of names) { + const full = join(binRoot, name); + const info = await lstat(full); + if (!info.isSymbolicLink()) continue; + entries.push({ name, target: await readlink(full) }); + } + return entries; +} + +export async function normalizeBunBinaries(): Promise { + const root = process.cwd(); + const bunRoot = join(root, "node_modules/.bun"); + + if (!(await isDirectory(bunRoot))) { + console.log("[normalize-bun-binaries] no .bun directory, skipping"); + return; + } + + const bunEntries = (await readdir(bunRoot)).sort(); + let rewritten = 0; + + for (const entry of bunEntries) { + const binRoot = join(bunRoot, entry, "node_modules", ".bin"); + if (!(await isDirectory(binRoot))) continue; + + const bins = await collectBinLinks(binRoot); + if (bins.length === 0) continue; + bins.sort((a, b) => a.name.localeCompare(b.name)); + + await rm(binRoot, { recursive: true, force: true }); + await mkdir(binRoot, { recursive: true }); + + for (const { name, target } of bins) { + await symlink(target, join(binRoot, name)); + rewritten++; + } + } + + console.log(`[normalize-bun-binaries] rebuilt ${rewritten} links`); +} + +if (import.meta.main) { + await normalizeBunBinaries(); +} diff --git a/.nix/scripts/normalize-install.ts b/.nix/scripts/normalize-install.ts new file mode 100644 index 000000000..ec8647ba6 --- /dev/null +++ b/.nix/scripts/normalize-install.ts @@ -0,0 +1,36 @@ +/** + * Make `bun install --linker=isolated` output bit-reproducible. + * + * Entry point for the nixpkgs FOD build — invoke this single script after + * `bun install` to get a `node_modules/` tree that is byte-identical + * across machines and runs with the same bun.lock. + * + * `bun install --linker=isolated` is not bit-reproducible out of the box, + * even with a frozen lockfile, a pinned bun version, `--ignore-scripts`, + * and a clean `BUN_INSTALL_CACHE_DIR`. Running it across a local NixOS + * sandbox, a GitHub Actions ubuntu-latest runner, and a GitHub Actions + * macos-latest runner produces subtly different `node_modules/` trees + * from identical inputs. Two independent sources of drift show up: + * + * - Missing `.bin/` entries around circular peer dependencies, + * caused by a timing race inside bun's isolated installer thread + * pool. See `heal-peer-dep-bins.ts` for the full explanation and fix. + * + * - Non-deterministic symlink creation order in `.bun/node_modules/` + * and in each per-package `.bin/` directory. NAR hashing sorts + * entries during serialization, so this is usually harmless, but we + * defensively rebuild both trees in canonical sorted order. + * See `canonicalize-node-modules.ts` and `normalize-bun-binaries.ts`. + * + * Each sub-script is also runnable standalone for debugging: + * + * bun --bun .nix/scripts/.ts + */ + +import { canonicalizeNodeModules } from "./canonicalize-node-modules.ts"; +import { healPeerDepBins } from "./heal-peer-dep-bins.ts"; +import { normalizeBunBinaries } from "./normalize-bun-binaries.ts"; + +await canonicalizeNodeModules(); +await healPeerDepBins(); +await normalizeBunBinaries(); From 4b7bb4e5c4a8af4b9201f5a08f6f6c427ebd4956 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Fri, 24 Apr 2026 04:51:40 -0500 Subject: [PATCH 5/7] docs(audio): clarify what the mic-init timing log actually measures (#1330) --- src-tauri/src/managers/audio.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 24dd04fc7..d0c92ba8d 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -321,6 +321,11 @@ impl AudioRecordingManager { } *open_flag = true; + // This timing covers through cpal's stream.play() returning — i.e. the + // point cpal surfaces as "stream running." It does NOT guarantee the + // host audio device is producing samples yet; the first input callback + // fires asynchronously one buffer period later (hardware dependent, + // typically ~10–200ms on macOS, longer on Bluetooth/USB). info!( "Microphone stream initialized in {:?}", start_time.elapsed() From 8346bc2db255364d6b99e31a9a0ac5cc05bb29b9 Mon Sep 17 00:00:00 2001 From: Evgeny Khudoba Date: Mon, 27 Apr 2026 12:27:18 +0700 Subject: [PATCH 6/7] fix(nix): Fix for macOS build for nixpkgs (#1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): pass -parse-as-library to swiftc Without this flag swiftc compiles a single-file input in script mode and emits a synthetic `_main` into the object file. Packaged into libapple_intelligence.a and linked alongside Rust's `_main`, Apple's open-source ld64 (used by nixpkgs' Darwin stdenv) picks Swift's main, leaving the app with a 5-instruction no-op that returns 0 immediately. The binary looks complete — full Rust code, Metal, Swift runtime, onnxruntime rpath — but launching it exits cleanly with code 0, no output. Production CI masks the issue because Xcode's linker happens to prefer Rust's `_main`. `-parse-as-library` keeps swiftc in library mode so no `_main` is emitted. The @_cdecl exports used by the Rust FFI are unaffected. * fix(macos): respect SDKROOT/SWIFTC env vars for non-Xcode toolchains xcrun is unavailable in non-Xcode setups (e.g. nixpkgs uses apple-sdk_* plus a standalone swift compiler). Honor SDKROOT and SWIFTC if set; fall back to xcrun otherwise so Apple-toolchain behavior is unchanged. Also invoke swiftc directly via the resolved path rather than via `xcrun swiftc`. --- src-tauri/build.rs | 59 +++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index db4eabec3..e43baf65f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -129,16 +129,20 @@ fn build_apple_intelligence_bridge() { let object_path = out_dir.join("apple_intelligence.o"); let static_lib_path = out_dir.join("libapple_intelligence.a"); - let sdk_path = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", "macosx", "--show-sdk-path"]) - .output() - .expect("Failed to locate macOS SDK") - .stdout, - ) - .expect("SDK path is not valid UTF-8") - .trim() - .to_string(); + // SDKROOT/SWIFTC env-var overrides let non-Xcode toolchains (e.g. nixpkgs + // with apple-sdk_* + standalone swift) bypass xcrun, which is Xcode-only. + let sdk_path = env::var("SDKROOT").unwrap_or_else(|_| { + String::from_utf8( + Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-path"]) + .output() + .expect("Failed to locate macOS SDK") + .stdout, + ) + .expect("SDK path is not valid UTF-8") + .trim() + .to_string() + }); // Check if the SDK supports FoundationModels (required for Apple Intelligence) let framework_path = @@ -157,16 +161,19 @@ fn build_apple_intelligence_bridge() { panic!("Source file {} is missing!", source_file); } - let swiftc_path = String::from_utf8( - Command::new("xcrun") - .args(["--find", "swiftc"]) - .output() - .expect("Failed to locate swiftc") - .stdout, - ) - .expect("swiftc path is not valid UTF-8") - .trim() - .to_string(); + // See SDKROOT note above — same env-override pattern for non-Xcode toolchains. + let swiftc_path = env::var("SWIFTC").unwrap_or_else(|_| { + String::from_utf8( + Command::new("xcrun") + .args(["--find", "swiftc"]) + .output() + .expect("Failed to locate swiftc") + .stdout, + ) + .expect("swiftc path is not valid UTF-8") + .trim() + .to_string() + }); let toolchain_swift_lib = Path::new(&swiftc_path) .parent() @@ -178,9 +185,17 @@ fn build_apple_intelligence_bridge() { // Use macOS 11.0 as deployment target for compatibility // The @available(macOS 26.0, *) checks in Swift handle runtime availability // Weak linking for FoundationModels is handled via cargo:rustc-link-arg below - let status = Command::new("xcrun") + let status = Command::new(&swiftc_path) .args([ - "swiftc", + // Without this flag swiftc treats single-file input as script + // mode and emits its own `_main` symbol into the .o, which can + // win the link against Rust's main under some linkers (e.g. + // open-source ld64 used in nixpkgs' Darwin stdenv), producing a + // binary whose main() is a 5-instruction no-op that returns 0. + // `-parse-as-library` keeps the compilation in library mode so + // no `_main` is emitted. See: + // https://forums.swift.org/t/main-in-a-single-swift-file/63079 + "-parse-as-library", "-target", "arm64-apple-macosx11.0", "-sdk", From 085cd530a30db479822125c758613c38fe0771b0 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 27 Apr 2026 22:52:35 +0800 Subject: [PATCH 7/7] release v0.8.3 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 09ea3d123..47747c9cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "handy-app", "private": true, - "version": "0.8.2", + "version": "0.8.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77c2e74ef..b2d9e2af6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2413,7 +2413,7 @@ dependencies = [ [[package]] name = "handy" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46773382a..217836986 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "handy" -version = "0.8.2" +version = "0.8.3" description = "Handy" authors = ["cjpais"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 251bd2176..274f86b40 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Handy", - "version": "0.8.2", + "version": "0.8.3", "identifier": "com.pais.handy", "build": { "beforeDevCommand": "bun run dev",