fix(tauri): verify openhuman:// registry registration on Windows (#2699) (#2757)

This commit is contained in:
CodeGhost21
2026-05-27 23:39:12 +05:30
committed by GitHub
parent 54c1725615
commit 976e5643ea
5 changed files with 522 additions and 2 deletions
+1
View File
@@ -355,5 +355,6 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test.
- **Vendored CEF-aware `tauri-cli`**: runtime is CEF; only the vendored CLI at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` bundles Chromium into `Contents/Frameworks/`. Stock `@tauri-apps/cli` produces a broken bundle (panic in `cef::library_loader::LibraryLoader::new`). `pnpm dev:app` and all `cargo tauri` scripts call `pnpm tauri:ensure` which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh). If overwritten, reinstall with `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`.
- **macOS deep links**: often require a built `.app` bundle, not just `tauri dev`.
- **Windows deep links**: `openhuman://` is registered to `HKCU\Software\Classes\openhuman\shell\open\command` by `tauri-plugin-deep-link::register_all` at first launch (per-user, no UAC). The Tauri shell now reads that key back after `register_all` returns and emits `log::error!` with the actual state (`NotRegistered` / `MissingCommand` / `Stale` / `ReadError`) when the value is missing or doesn't point at the running exe — without it, OAuth callbacks via `openhuman://auth?…` never reach the app (issue #2699). The check lives in [`app/src-tauri/src/deep_link_registration_check.rs`](app/src-tauri/src/deep_link_registration_check.rs); a manual repair script for affected users is in [`gitbooks/overview/troubleshooting-sign-in.md`](gitbooks/overview/troubleshooting-sign-in.md).
- **Tauri environment guard**: use `isTauri()` (from `app/src/services/webviewAccountService.ts`) or wrap `invoke(...)` in `try/catch`; do not check `window.__TAURI__` directly — it is not present at module load and bypasses the established wrapper contract.
- **Core is in-process** (no sidecar): `core_rpc` reaches the embedded server at `http://127.0.0.1:<port>/rpc` with bearer auth via `OPENHUMAN_CORE_TOKEN`. `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo. To run the core standalone for debugging, use `./target/debug/openhuman-core serve` (token at `{workspace}/core.token`, default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`).
+6
View File
@@ -172,6 +172,12 @@ windows-sys = { version = "0.59", features = [
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Pipes",
# RegOpenKeyExW / RegQueryValueExW / RegCloseKey — used by
# deep_link_registration_check::verify_protocol_registration to read
# back HKCU\Software\Classes\openhuman\shell\open\command after
# `tauri-plugin-deep-link::register_all` so a silently-failed write
# surfaces in the Sentry / user logs (issue #2699).
"Win32_System_Registry",
] }
[features]
@@ -0,0 +1,459 @@
//! Read-back verification for the `openhuman://` URL-scheme registration.
//!
//! `tauri-plugin-deep-link::register_all` writes
//! `HKCU\Software\Classes\openhuman\shell\open\command` on Windows so the
//! browser can hand `openhuman://auth?...` OAuth callbacks back to the running
//! desktop instance. When that write silently fails — or when the value
//! becomes stale because the install was moved out from under itself — the
//! Tauri plugin only surfaces a `warn` and the user is left with an OAuth
//! flow that never returns to the app (issue #2699).
//!
//! This module verifies the registration after `register_all` returns so the
//! actual state is logged loudly enough to be picked up by Sentry and end-user
//! support logs. We do **not** auto-repair the registry — writing the wrong
//! exe path can brick a working install — but the diagnostic surface is now
//! sufficient to point users at the documented manual repair in
//! `gitbooks/overview/troubleshooting-sign-in.md`.
//!
//! The string-parsing helpers are cross-platform so the developer host (macOS
//! / Linux) can run their unit tests; the actual registry read sits behind
//! `#[cfg(target_os = "windows")]`. The whole module is dead code on
//! non-Windows targets outside of tests, so the dead-code lint is suppressed
//! there only.
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
use std::path::Path;
/// Subkey under `HKEY_CURRENT_USER` that holds the `openhuman://` URL-scheme
/// handler command. Matches what `tauri-plugin-deep-link::register_all`
/// writes on Windows (HKCU, not HKLM, so no UAC elevation is involved).
pub(crate) const HKCU_OPEN_COMMAND_SUBKEY: &str = r"Software\Classes\openhuman\shell\open\command";
/// Outcome of inspecting the `openhuman://` protocol handler.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RegistrationStatus {
/// HKCU key exists and references the running executable.
Valid { command: String },
/// HKCU key exists but its command points at a different exe than the one
/// that's running. Typical after the install was moved/copied to a new
/// path without re-running the installer.
Stale {
registered_command: String,
expected_exe: String,
},
/// HKCU command subkey exists with no `(Default)` value or an empty one.
MissingCommand,
/// `HKCU\Software\Classes\openhuman` doesn't exist — the scheme has never
/// been registered for this user.
NotRegistered,
/// Couldn't read the registry at all (permissions, ACL on a locked-down
/// Windows image, transient failure).
ReadError(String),
}
impl RegistrationStatus {
pub(crate) fn is_healthy(&self) -> bool {
matches!(self, Self::Valid { .. })
}
/// Render the status as a single-line string with full filesystem paths
/// reduced to just their final component. The `Stale` and `Valid`
/// variants carry registry / current-exe paths that on Windows include
/// `C:\Users\<username>\AppData\Local\...` for per-user installs — that
/// username lands in Sentry / user log lines unless we strip it. We keep
/// the basename so the diagnostic still tells the reader *what* exe is
/// registered, just not *where*. Used in place of `Debug` at the log
/// call site.
pub(crate) fn redacted(&self) -> String {
match self {
Self::Valid { command } => {
format!("Valid {{ exe: {} }}", basename_of_first_token(command))
}
Self::Stale {
registered_command,
expected_exe,
} => format!(
"Stale {{ registered_exe: {}, expected_exe: {} }}",
basename_of_first_token(registered_command),
basename_of_first_token(expected_exe),
),
Self::MissingCommand => "MissingCommand".into(),
Self::NotRegistered => "NotRegistered".into(),
// `ReadError` carries a win32-error string like
// "RegOpenKeyExW failed: win32 error 5" — no user paths.
Self::ReadError(msg) => format!("ReadError({msg})"),
}
}
}
/// Take the first whitespace-delimited token out of `s` (handling quoted
/// command strings via [`extract_first_token`]) and return only its file
/// name. Drops the directory component so the log line doesn't leak the
/// running user's install path.
///
/// We scan for `\\` and `/` manually rather than using [`Path::file_name`]
/// because `std::path::Path` uses **host-OS** separator semantics, so on a
/// macOS / Linux dev host a Windows-style `"C:\\foo\\bar.exe"` would come
/// back as a single component and defeat the redaction.
fn basename_of_first_token(s: &str) -> String {
let token = extract_first_token(s);
token
.rsplit(['\\', '/'])
.next()
.filter(|seg| !seg.is_empty())
.unwrap_or("<redacted>")
.to_string()
}
/// Pull the first whitespace-delimited token out of a Windows-style command
/// string, honouring double-quoted paths. The registry stores values like
/// `"C:\Program Files\OpenHuman\OpenHuman.exe" "%1"` — we want the exe path.
pub(crate) fn extract_first_token(command: &str) -> &str {
let trimmed = command.trim_start();
if let Some(rest) = trimmed.strip_prefix('"') {
match rest.find('"') {
Some(end) => &rest[..end],
None => rest,
}
} else {
match trimmed.find(char::is_whitespace) {
Some(end) => &trimmed[..end],
None => trimmed,
}
}
}
/// Compare two Windows path strings case-insensitively after normalizing
/// directory separators. Windows treats `/` and `\` interchangeably and is
/// case-insensitive on path comparisons, so this matches OS behavior closely
/// enough to detect "registry points at the right exe."
pub(crate) fn paths_equal_loose(a: &str, b: &str) -> bool {
fn norm(s: &str) -> String {
s.replace('/', "\\").to_lowercase()
}
norm(a) == norm(b)
}
/// True iff `command` (as found in the registry) references `exe`.
pub(crate) fn command_references_exe(command: &str, exe: &Path) -> bool {
let token = extract_first_token(command);
paths_equal_loose(token, &exe.to_string_lossy())
}
/// Read `HKCU\Software\Classes\openhuman\shell\open\command\(Default)` and
/// classify the registration. Windows-only — all other targets get a stub
/// that returns [`RegistrationStatus::NotRegistered`] (the verification is a
/// no-op outside Windows since macOS / Linux use different mechanisms).
#[cfg(target_os = "windows")]
pub(crate) fn verify_protocol_registration() -> RegistrationStatus {
use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_SZ,
};
// RAII wrapper for the open HKEY. Mirrors the `OwnedMutex` pattern used
// in `lib.rs::run()` for the pre-CEF mutex handle: any early return after
// the first successful `RegOpenKeyExW` falls through Drop instead of
// having to remember to call `RegCloseKey` on every branch.
struct OwnedHkey(HKEY);
impl Drop for OwnedHkey {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: self.0 is only set via a successful `RegOpenKeyExW`
// and is not aliased elsewhere — this Drop is the sole closer.
unsafe { RegCloseKey(self.0) };
}
}
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(err) => {
return RegistrationStatus::ReadError(format!("current_exe failed: {err}"));
}
};
let subkey_wide: Vec<u16> = HKCU_OPEN_COMMAND_SUBKEY
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// Start null so Drop is a no-op if `RegOpenKeyExW` never succeeds.
let mut hkey = OwnedHkey(std::ptr::null_mut());
// SAFETY: subkey_wide is NUL-terminated UTF-16; hkey.0 is written iff result == 0.
let open_result = unsafe {
RegOpenKeyExW(
HKEY_CURRENT_USER,
subkey_wide.as_ptr(),
0,
KEY_READ,
&mut hkey.0,
)
};
if open_result as u32 == ERROR_FILE_NOT_FOUND {
return RegistrationStatus::NotRegistered;
} else if open_result as u32 != ERROR_SUCCESS {
return RegistrationStatus::ReadError(format!(
"RegOpenKeyExW failed: win32 error {open_result}"
));
}
// Probe size first. value_name = NUL pointer would select the (Default) value,
// but RegQueryValueExW also accepts an empty NUL-terminated string for the same.
let value_name: [u16; 1] = [0];
let mut value_type: u32 = 0;
let mut needed: u32 = 0;
// SAFETY: hkey.0 is a valid open HKEY; we pass null for data to get the size only.
let size_probe = unsafe {
RegQueryValueExW(
hkey.0,
value_name.as_ptr(),
std::ptr::null_mut(),
&mut value_type,
std::ptr::null_mut(),
&mut needed,
)
};
if size_probe as u32 != ERROR_SUCCESS {
if size_probe as u32 == ERROR_FILE_NOT_FOUND {
return RegistrationStatus::MissingCommand;
}
return RegistrationStatus::ReadError(format!(
"RegQueryValueExW (size probe) failed: win32 error {size_probe}"
));
}
if value_type != REG_SZ || needed == 0 {
return RegistrationStatus::MissingCommand;
}
// `needed` is in bytes; REG_SZ uses UTF-16 so each code unit is 2 bytes. Round
// up to accommodate values that aren't NUL-terminated on disk.
let units = needed.div_ceil(2) as usize;
let mut buf: Vec<u16> = vec![0u16; units];
let mut buf_bytes: u32 = needed;
// SAFETY: buf is sized for `needed` bytes; buf_bytes is updated by the call.
let query_result = unsafe {
RegQueryValueExW(
hkey.0,
value_name.as_ptr(),
std::ptr::null_mut(),
&mut value_type,
buf.as_mut_ptr().cast::<u8>(),
&mut buf_bytes,
)
};
// From here on we no longer need the handle; Drop closes it at function exit.
if query_result as u32 != ERROR_SUCCESS {
return RegistrationStatus::ReadError(format!(
"RegQueryValueExW failed: win32 error {query_result}"
));
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let command = String::from_utf16_lossy(&buf[..end]);
let command_trimmed = command.trim();
if command_trimmed.is_empty() {
return RegistrationStatus::MissingCommand;
}
if command_references_exe(command_trimmed, &exe) {
RegistrationStatus::Valid {
command: command_trimmed.to_string(),
}
} else {
RegistrationStatus::Stale {
registered_command: command_trimmed.to_string(),
expected_exe: exe.to_string_lossy().to_string(),
}
}
}
/// Non-Windows stub so the setup-time wiring can call this unconditionally
/// behind `cfg(windows)` without polluting the call site with cfg gates.
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn verify_protocol_registration() -> RegistrationStatus {
RegistrationStatus::NotRegistered
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn extract_first_token_quoted_exe_with_args() {
assert_eq!(
extract_first_token("\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\""),
"C:\\Program Files\\OpenHuman\\OpenHuman.exe"
);
}
#[test]
fn extract_first_token_unquoted_exe_with_args() {
assert_eq!(
extract_first_token("C:\\OpenHuman\\OpenHuman.exe %1"),
"C:\\OpenHuman\\OpenHuman.exe"
);
}
#[test]
fn extract_first_token_handles_leading_whitespace() {
assert_eq!(
extract_first_token(" C:\\OpenHuman\\OpenHuman.exe %1"),
"C:\\OpenHuman\\OpenHuman.exe"
);
}
#[test]
fn extract_first_token_single_value_no_args() {
assert_eq!(extract_first_token("OpenHuman.exe"), "OpenHuman.exe");
}
#[test]
fn extract_first_token_empty_string() {
// Defensive guard: an empty REG_SZ value must not panic. The caller
// (`verify_protocol_registration`) classifies this as `MissingCommand`
// before reaching the parser, but the parser itself stays total.
assert_eq!(extract_first_token(""), "");
}
#[test]
fn extract_first_token_quoted_exe_with_no_trailing_args() {
// Some installers register the command without the `"%1"` argv
// placeholder. The first token is still the quoted exe path.
assert_eq!(
extract_first_token("\"C:\\OpenHuman\\OpenHuman.exe\""),
"C:\\OpenHuman\\OpenHuman.exe"
);
}
#[test]
fn extract_first_token_unterminated_quote_falls_through() {
// Defensive: malformed REG_SZ should not panic. We return the rest of
// the string instead of slicing past a missing terminator.
assert_eq!(
extract_first_token("\"C:\\OpenHuman\\OpenHuman.exe %1"),
"C:\\OpenHuman\\OpenHuman.exe %1"
);
}
#[test]
fn paths_equal_loose_is_case_insensitive_and_slash_agnostic() {
assert!(paths_equal_loose(
"C:\\Program Files\\OpenHuman\\OpenHuman.exe",
"c:/program files/openhuman/openhuman.exe"
));
}
#[test]
fn paths_equal_loose_distinguishes_different_paths() {
assert!(!paths_equal_loose(
"C:\\OldPath\\OpenHuman.exe",
"C:\\NewPath\\OpenHuman.exe"
));
}
#[test]
fn command_references_exe_matches_quoted_command_with_percent_one() {
let exe = PathBuf::from("C:\\Program Files\\OpenHuman\\OpenHuman.exe");
assert!(command_references_exe(
"\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\"",
&exe
));
}
#[test]
fn command_references_exe_matches_unquoted_command() {
// Some HKCU writers omit the quotes when the path has no spaces. The
// matcher must still resolve to the exe via the unquoted code path in
// `extract_first_token` rather than relying only on the quoted path.
let exe = PathBuf::from("C:\\OpenHuman\\OpenHuman.exe");
assert!(command_references_exe(
"C:\\OpenHuman\\OpenHuman.exe %1",
&exe
));
}
#[test]
fn command_references_exe_detects_stale_install_path() {
// Repro of the "user moved the install" failure mode: registry still
// points at the old location.
let exe = PathBuf::from("C:\\NewLocation\\OpenHuman.exe");
assert!(!command_references_exe(
"\"C:\\OldLocation\\OpenHuman.exe\" \"%1\"",
&exe
));
}
#[test]
fn redacted_drops_directory_components_for_stale_paths() {
// Reproduce the Sentry-leak case: a Stale status carrying the running
// user's home directory must produce a log line that contains the
// exe basenames but neither the username nor the parent dirs.
let status = RegistrationStatus::Stale {
registered_command:
"\"C:\\Users\\joe\\AppData\\Local\\OpenHuman\\OpenHuman.exe\" \"%1\"".into(),
expected_exe: "C:\\Users\\joe\\AppData\\Local\\OpenHuman_new\\OpenHuman.exe".into(),
};
let rendered = status.redacted();
assert!(
rendered.contains("OpenHuman.exe"),
"basename should survive redaction: {rendered}"
);
assert!(
!rendered.contains("joe"),
"username must not leak: {rendered}"
);
assert!(
!rendered.contains("AppData"),
"directory path must not leak: {rendered}"
);
}
#[test]
fn redacted_preserves_valid_variant_label_and_basename() {
let status = RegistrationStatus::Valid {
command: "\"C:\\Program Files\\OpenHuman\\OpenHuman.exe\" \"%1\"".into(),
};
assert_eq!(status.redacted(), "Valid { exe: OpenHuman.exe }");
}
#[test]
fn redacted_passes_through_pathless_variants() {
assert_eq!(
RegistrationStatus::MissingCommand.redacted(),
"MissingCommand"
);
assert_eq!(
RegistrationStatus::NotRegistered.redacted(),
"NotRegistered"
);
assert_eq!(
RegistrationStatus::ReadError("win32 error 5".into()).redacted(),
"ReadError(win32 error 5)"
);
}
#[test]
fn is_healthy_only_for_valid_variant() {
assert!(RegistrationStatus::Valid {
command: "x".into()
}
.is_healthy());
assert!(!RegistrationStatus::MissingCommand.is_healthy());
assert!(!RegistrationStatus::NotRegistered.is_healthy());
assert!(!RegistrationStatus::Stale {
registered_command: "x".into(),
expected_exe: "y".into()
}
.is_healthy());
assert!(!RegistrationStatus::ReadError("foo".into()).is_healthy());
}
}
+32 -2
View File
@@ -14,6 +14,10 @@ mod core_rpc;
mod deep_link_ipc;
#[cfg(target_os = "windows")]
mod deep_link_ipc_windows;
// Cross-platform module: the registry-reading function is windows-only, but
// the parsing helpers compile (and test) everywhere so `cargo test` on the
// developer host covers them.
mod deep_link_registration_check;
mod dictation_hotkeys;
mod discord_scanner;
mod fake_camera;
@@ -2587,8 +2591,34 @@ pub fn run() {
.setup(move |app| {
#[cfg(windows)]
{
if let Err(err) = app.deep_link().register_all() {
log::warn!("[deep-link] register_all failed (non-fatal): {err}");
// `register_all` writes HKCU\Software\Classes\openhuman so the
// browser can hand `openhuman://auth?...` callbacks back to
// the running instance. The plugin only returns an Err — and
// it only logs at `warn` — when its single internal write
// fails outright; it does not verify what's on disk. Issue
// #2699 reports OAuth callbacks silently disappearing on
// some Windows installs, which traced back to a missing or
// stale `command` value here. Read it back and log loudly
// (Sentry-level `error`) so the failure mode is observable
// in support logs; we deliberately do NOT auto-repair —
// writing the wrong exe path can brick a working install.
let register_err = app.deep_link().register_all().err();
let status = deep_link_registration_check::verify_protocol_registration();
let status_log = status.redacted();
if register_err.is_none() && status.is_healthy() {
log::info!("[deep-link] openhuman:// scheme registered ({status_log})");
} else {
// Use the redacted form so per-user install paths
// (`C:\Users\<username>\...`) do not land in Sentry / user
// logs — basenames are kept so the diagnostic still
// identifies the registered exe.
log::error!(
"[deep-link] openhuman:// scheme registration unhealthy — \
OAuth callbacks may never reach the app. \
register_all_error={register_err:?}, hkcu_status={status_log}. \
See gitbooks/overview/troubleshooting-sign-in.md \
(\"Windows: openhuman:// handler not registered\") for the manual repair."
);
}
deep_link_ipc_windows::drain_pending_urls(app.app_handle());
}
@@ -41,6 +41,30 @@ Successful desktop OAuth ends with an `openhuman://auth?...` callback. If the br
2. Restart the app, keep the same remote-core settings, and retry sign-in.
3. If using a remote core, check whether the core receives `openhuman.auth_store_session`.
## Windows: `openhuman://` handler not registered
On Windows the `openhuman://` URL scheme is registered to the running executable via `HKEY_CURRENT_USER\Software\Classes\openhuman\shell\open\command` at first launch. If that registration silently failed — or if the install was moved/copied after first launch — the browser cannot hand the OAuth callback back to the app, and sign-in stalls after the provider step (issue #2699).
The Tauri shell now emits a `log::error!` line at startup when this happens. Look for it in your log file (default `%USERPROFILE%\.openhuman\logs\openhuman.*.log`):
```
[deep-link] openhuman:// scheme registration unhealthy — OAuth callbacks may never reach the app.
register_all_error=…, hkcu_status=NotRegistered|MissingCommand|Stale { … }|ReadError(…)
```
To repair manually, open PowerShell **as the same user that runs OpenHuman** (no admin required — HKCU is per-user) and replace the path with your actual install location:
```powershell
$exe = 'C:\Path\To\OpenHuman.exe' # update this
New-Item -Path 'HKCU:\Software\Classes\openhuman' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\Classes\openhuman' -Name '(Default)' -Value 'URL:OpenHuman Protocol'
New-ItemProperty -Path 'HKCU:\Software\Classes\openhuman' -Name 'URL Protocol' -Value '' -Force | Out-Null
New-Item -Path 'HKCU:\Software\Classes\openhuman\shell\open\command' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\Classes\openhuman\shell\open\command' -Name '(Default)' -Value ('"' + $exe + '" "%1"')
```
Restart OpenHuman afterwards and retry sign-in. If `register_all_error` is non-`None` in the log — for example because antivirus or a locked-down image is blocking writes to `HKCU\Software\Classes` — fixing the underlying policy is required; the manual script above will hit the same block.
For a remote core, a temporary manual injection can confirm the core is otherwise healthy:
```bash