From b7e08c7e113adaf7f8a1ae14e6fd179f5c4ec9ca Mon Sep 17 00:00:00 2001 From: ARNO Date: Mon, 3 Aug 2026 15:23:59 +0800 Subject: [PATCH] feat(windows): add optional windows explorer context menus (#310) * add CLI support for opening directories in new tabs f * feat(windows): add optional windows explorer context menus f * fix(gui): restore missing windows and reject lossy paths * fix(windows): harden explorer menu registration and native path handling * fix(cli): preserve native GUI paths on Windows --------- Co-authored-by: thomas Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .github/scripts/windows-installer.iss | 19 + Cargo.toml | 2 + crates/tty7-cli/src/cli.rs | 21 +- crates/tty7-cli/src/commands.rs | 65 +-- src/core/explorer_context_menu.rs | 559 ++++++++++++++++++++++++++ src/core/mod.rs | 1 + src/main.rs | 112 ++++-- src/ui/app.rs | 38 ++ src/ui/settings.rs | 113 +++++- 9 files changed, 864 insertions(+), 66 deletions(-) create mode 100644 src/core/explorer_context_menu.rs diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index 1cb9047c..9b7f7525 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -176,8 +176,27 @@ begin RegWriteStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', Rebuilt); end; +(* Remove the optional Explorer verbs if this user enabled them in Settings. + + The application owns only the final `tty7` subkeys. Deleting those trees + removes their command children without touching another application's verb + or a shared `shell` parent. Missing keys are the normal default and make both + calls harmless no-ops. This cleanup is uninstall-only: upgrades keep the + user's explicit registration, and the next app launch reports "Needs update" + if an install path ever changes. *) +procedure RemoveExplorerContextMenu(); +begin + RegDeleteKeyIncludingSubkeys( + HKEY_CURRENT_USER, 'Software\Classes\Directory\shell\tty7'); + RegDeleteKeyIncludingSubkeys( + HKEY_CURRENT_USER, 'Software\Classes\Directory\Background\shell\tty7'); +end; + procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); begin if CurUninstallStep = usUninstall then + begin RemoveAppDirFromUserPath(); + RemoveExplorerContextMenu(); + end; end; diff --git a/Cargo.toml b/Cargo.toml index 2cdeb5e7..41f0b246 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,7 +138,9 @@ libc = "0.2" # this pins no new code. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ + "Win32_Foundation", "Win32_System_Registry", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index b658888f..fb31eaff 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -40,7 +40,7 @@ pub struct Cli { value_name = "PATH", help = "Launch or activate the GUI, opening a new tab at PATH if given" )] - pub path: Option, + pub path: Option, #[command(subcommand)] pub command: Option, @@ -470,7 +470,24 @@ mod tests { fn a_path_that_is_not_a_verb_still_launches_the_gui() { let cli = parse(&["tty7", "C:\\Users\\me\\proj"]); assert!(cli.command.is_none()); - assert_eq!(cli.path.as_deref(), Some("C:\\Users\\me\\proj")); + assert_eq!( + cli.path.as_deref(), + Some(std::path::Path::new("C:\\Users\\me\\proj")) + ); + } + + #[cfg(windows)] + #[test] + fn a_gui_path_preserves_native_windows_arguments() { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt as _; + + let native_path = + OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]); + let cli = Cli::try_parse_from([OsString::from("tty7"), native_path.clone()]) + .expect("PathBuf arguments accept native Windows strings"); + + assert_eq!(cli.path, Some(std::path::PathBuf::from(native_path))); } #[test] diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index da553db4..ad66c74e 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -48,9 +48,11 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result bail!( - "unknown subcommand '{word}' — run `tty7 --help` for the list. \ + "unknown subcommand '{}' — run `tty7 --help` for the list. \ (A path in this position would open the GUI there, but \ - '{word}' does not name one.)" + '{}' does not name one.)", + word.display(), + word.display(), ), path => launch_gui(path, machine.as_deref(), backend), }, @@ -130,17 +132,18 @@ fn local_server( /// Anything with a separator, a leading `.`/`~`, or that actually exists on /// disk counts. A plain word like `tree` or `statu` does not — it is a /// mistyped subcommand, and saying so beats offering to open the GUI there. -fn looks_like_a_path(s: &str) -> bool { - s.starts_with('/') - || s.starts_with('.') - || s.starts_with('~') - || s.contains('/') - || s.contains('\\') - || std::path::Path::new(s).exists() +fn looks_like_a_path(path: &std::path::Path) -> bool { + path.to_str().is_some_and(|s| { + s.starts_with('/') + || s.starts_with('.') + || s.starts_with('~') + || s.contains('/') + || s.contains('\\') + }) || path.exists() } fn launch_gui( - path: Option, + path: Option, machine: Option<&str>, backend: &mut dyn Backend, ) -> Result { @@ -151,16 +154,24 @@ fn launch_gui( } let path = path.map(resolve_gui_path).transpose()?; - let wire_path = path.as_deref().map(gui_wire_path).transpose()?; + let wire_path = path.as_deref().and_then(gui_wire_path); // A live GUI receives the request through the daemon. If the daemon itself // is absent, the same fallback as "no GUI registered" starts the app, which // will start its daemon during normal initialization. - let delivered = match backend.control(ControlRequest::GuiOpen { - path: wire_path.clone(), - }) { - Ok(ReplyOk::Bool(delivered)) => delivered, - Ok(other) => bail!("the server answered GuiOpen with {other:?}"), - Err(_) => false, + let request_path = path + .is_none() + .then_some(None) + .or_else(|| wire_path.clone().map(Some)); + let delivered = match request_path { + Some(path) => match backend.control(ControlRequest::GuiOpen { path }) { + Ok(ReplyOk::Bool(delivered)) => delivered, + Ok(other) => bail!("the server answered GuiOpen with {other:?}"), + Err(_) => false, + }, + // The JSON control protocol cannot preserve a native non-Unicode path. + // Launching the app does: Command passes the Path as an OsStr, and the + // app keeps it locally when it cannot forward it to another process. + None => false, }; if !delivered { @@ -176,18 +187,12 @@ fn launch_gui( ) } -fn gui_wire_path(path: &std::path::Path) -> Result { - let Some(path_text) = path.to_str() else { - bail!( - "cannot open {} through the GUI protocol because the path is not valid UTF-8", - path.display() - ); - }; - Ok(path_text.to_owned()) +fn gui_wire_path(path: &std::path::Path) -> Option { + path.to_str().map(str::to_owned) } -fn resolve_gui_path(raw: String) -> Result { - let expanded = expand_home(&raw).unwrap_or_else(|| std::path::PathBuf::from(&raw)); +fn resolve_gui_path(raw: std::path::PathBuf) -> Result { + let expanded = raw.to_str().and_then(expand_home).unwrap_or(raw); // Do not canonicalize here: preserving the caller's junction or symlink // spelling keeps shell cwd reporting and tab labels consistent. let path = if expanded.is_absolute() { @@ -1764,14 +1769,12 @@ mod tests { #[cfg(unix)] #[test] - fn a_non_utf8_gui_path_is_rejected_instead_of_changed() { + fn a_non_utf8_gui_path_stays_off_the_string_protocol() { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt as _; let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec())); - let error = gui_wire_path(&path).expect_err("the string protocol cannot preserve bytes"); - - assert!(error.to_string().contains("not valid UTF-8"), "{error:#}"); + assert_eq!(gui_wire_path(&path), None); } #[test] diff --git a/src/core/explorer_context_menu.rs b/src/core/explorer_context_menu.rs new file mode 100644 index 00000000..becd4765 --- /dev/null +++ b/src/core/explorer_context_menu.rs @@ -0,0 +1,559 @@ +//! Optional Windows Explorer context-menu integration. +//! +//! tty7 deliberately does not register shell verbs during installation or +//! startup. The registry is user-visible system state, so only the explicit +//! buttons in Settings call [`register`] or [`unregister`]. Both verbs invoke +//! the GUI-subsystem `tty7-app.exe` directly so Explorer never allocates a +//! transient console. The app first offers the path to an already running GUI +//! through `GuiOpen`, then continues normal startup when no GUI receives it. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; + +const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7"; +const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7"; + +/// The state shown in Settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + /// Neither tty7 verb exists for this user. + NotRegistered, + /// Both verbs exactly describe the currently running tty7 installation. + Registered, + /// At least one verb exists, but the pair is incomplete or points elsewhere. + NeedsUpdate, + /// Explorer shell verbs are unavailable on this operating system. + Unsupported, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Location { + Directory, + Background, +} + +impl Location { + fn key(self) -> &'static str { + match self { + Self::Directory => DIRECTORY_KEY, + Self::Background => BACKGROUND_KEY, + } + } + + fn label(self) -> &'static str { + match self { + Self::Directory => "Open in tty7", + Self::Background => "Open tty7 here", + } + } + + fn placeholder(self) -> &'static str { + match self { + // `%1` is the selected directory passed to a normal static verb. + Self::Directory => "%1", + // Explorer expands `%V` to the folder whose background was clicked. + Self::Background => "%V", + } + } +} + +#[derive(Debug)] +struct Registration { + location: Location, + icon: OsString, + command: OsString, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RegistryShape { + values: u32, + subkeys: u32, +} + +fn registration_tree_is_exact(root: RegistryShape, command: RegistryShape) -> bool { + root == (RegistryShape { + values: 2, + subkeys: 1, + }) && command + == (RegistryShape { + values: 1, + subkeys: 0, + }) +} + +fn replace_entry_with( + registration: &Registration, + delete: impl FnOnce(&str) -> Result<()>, + write: impl FnOnce(&Registration) -> Result<()>, +) -> Result<()> { + // The verb root belongs exclusively to tty7. Replacing it at the operation + // boundary prevents stale shell values or handler subkeys from surviving + // an update and changing visibility or execution semantics. + delete(registration.location.key())?; + write(registration) +} + +impl Registration { + fn new(location: Location, app: &Path) -> Self { + Self { + location, + icon: app.as_os_str().to_os_string(), + command: quoted_open_command(app, location.placeholder()), + } + } +} + +/// Return the live per-user registration state. +pub fn status() -> Result { + platform_status() +} + +/// Register both Explorer verbs for the current user. +pub fn register() -> Result<()> { + platform_register() +} + +/// Remove only the two Explorer verb trees owned by tty7. +pub fn unregister() -> Result<()> { + platform_unregister() +} + +/// Build a command line without converting the executable path through UTF-8. +/// +/// Quotes are unconditional: both the executable and the Explorer-substituted +/// folder can contain spaces. Windows file names cannot contain a quote, so the +/// resulting command is unambiguous without an additional escape layer. +fn quoted_open_command(executable: &Path, placeholder: &str) -> OsString { + let mut command = OsString::from("\""); + command.push(executable.as_os_str()); + command.push("\" --open-path \""); + command.push(placeholder); + command.push("\""); + command +} + +fn application_path() -> Result { + let app = std::env::current_exe().context("locating the running tty7 application")?; + app.parent() + .context("the running tty7 application has no parent directory")?; + Ok(app) +} + +fn registrations(app: &Path) -> [Registration; 2] { + [ + Registration::new(Location::Directory, app), + Registration::new(Location::Background, app), + ] +} + +#[cfg(windows)] +mod windows { + use super::*; + use std::os::windows::ffi::OsStrExt as _; + + use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, + }; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey, + RegCreateKeyExW, RegDeleteTreeW, RegOpenKeyExW, RegQueryInfoKeyW, RegQueryValueExW, + RegSetValueExW, + }; + use windows_sys::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify}; + + struct RegistryKey(HKEY); + + impl Drop for RegistryKey { + fn drop(&mut self) { + // SAFETY: `RegistryKey` is created only from a successful Win32 + // open/create call and owns exactly one handle. + unsafe { + RegCloseKey(self.0); + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum EntryState { + Missing, + Matching, + Different, + } + + fn wide(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() + } + + fn units(value: &OsStr) -> Vec { + value.encode_wide().collect() + } + + fn io_error(action: &str, code: u32) -> anyhow::Error { + anyhow::anyhow!( + "{action}: {}", + std::io::Error::from_raw_os_error(code as i32) + ) + } + + fn open_key(path: &str) -> Result> { + let path = wide(OsStr::new(path)); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is NUL-terminated and alive for the call; `key` is a + // valid out-parameter and is wrapped only when the call succeeds. + let code = + unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut key) }; + match code { + ERROR_SUCCESS => Ok(Some(RegistryKey(key))), + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None), + other => Err(io_error("opening the tty7 Explorer registry key", other)), + } + } + + fn create_key(path: &str) -> Result { + let path = wide(OsStr::new(path)); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: all input pointers reference live locals; the optional class, + // security and disposition pointers are null as permitted by the API. + let code = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + path.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_READ | KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ) + }; + if code != ERROR_SUCCESS { + return Err(io_error("creating the tty7 Explorer registry key", code)); + } + Ok(RegistryKey(key)) + } + + fn query_string(key: &RegistryKey, name: Option<&OsStr>) -> Result>> { + let name = name.map(wide); + let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr()); + let mut kind = 0u32; + let mut bytes = 0u32; + // SAFETY: the key is live, the optional value-name pointer is either + // null or NUL-terminated, and the size/type out-parameters are valid. + let code = unsafe { + RegQueryValueExW( + key.0, + name_ptr, + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut bytes, + ) + }; + if matches!(code, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { + return Ok(None); + } + if code != ERROR_SUCCESS { + return Err(io_error("reading a tty7 Explorer registry value", code)); + } + if kind != REG_SZ || !bytes.is_multiple_of(2) { + return Ok(None); + } + + let mut value = vec![0u16; (bytes as usize).div_ceil(2)]; + // SAFETY: `value` is sized from the preceding query and remains live; + // Win32 receives its capacity in bytes through `bytes`. + let code = unsafe { + RegQueryValueExW( + key.0, + name_ptr, + std::ptr::null(), + &mut kind, + value.as_mut_ptr().cast(), + &mut bytes, + ) + }; + if code != ERROR_SUCCESS { + return Err(io_error("reading a tty7 Explorer registry value", code)); + } + value.truncate(bytes as usize / 2); + while value.last() == Some(&0) { + value.pop(); + } + Ok(Some(value)) + } + + fn set_string(key: &RegistryKey, name: Option<&OsStr>, value: &OsStr) -> Result<()> { + let name = name.map(wide); + let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr()); + let value = wide(value); + let byte_len = u32::try_from(value.len() * 2) + .context("the tty7 Explorer registry value is too long")?; + // SAFETY: the key is writable, and both optional name and value point + // to live NUL-terminated UTF-16 buffers for the duration of the call. + let code = + unsafe { RegSetValueExW(key.0, name_ptr, 0, REG_SZ, value.as_ptr().cast(), byte_len) }; + if code == ERROR_SUCCESS { + Ok(()) + } else { + Err(io_error("writing a tty7 Explorer registry value", code)) + } + } + + fn key_shape(key: &RegistryKey) -> Result { + let mut subkeys = 0u32; + let mut values = 0u32; + // SAFETY: `key` is live and the two count pointers reference writable + // locals. Every optional output that is not needed is passed as null, + // which `RegQueryInfoKeyW` explicitly permits. + let code = unsafe { + RegQueryInfoKeyW( + key.0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null(), + &mut subkeys, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut values, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if code == ERROR_SUCCESS { + Ok(RegistryShape { values, subkeys }) + } else { + Err(io_error("inspecting the tty7 Explorer registry key", code)) + } + } + + fn entry_state(registration: &Registration) -> Result { + let Some(root) = open_key(registration.location.key())? else { + return Ok(EntryState::Missing); + }; + let label = query_string(&root, None)?; + let icon = query_string(&root, Some(OsStr::new("Icon")))?; + + let command_path = format!(r"{}\command", registration.location.key()); + let command_key = match open_key(&command_path)? { + Some(key) => key, + None => return Ok(EntryState::Different), + }; + let command = query_string(&command_key, None)?; + let matches = label.as_deref() == Some(&units(OsStr::new(registration.location.label()))) + && icon.as_deref() == Some(&units(®istration.icon)) + && command.as_deref() == Some(&units(®istration.command)) + && registration_tree_is_exact(key_shape(&root)?, key_shape(&command_key)?); + Ok(if matches { + EntryState::Matching + } else { + EntryState::Different + }) + } + + fn write_entry_contents(registration: &Registration) -> Result<()> { + let root = create_key(registration.location.key())?; + set_string(&root, None, OsStr::new(registration.location.label()))?; + set_string(&root, Some(OsStr::new("Icon")), ®istration.icon)?; + + let command_path = format!(r"{}\command", registration.location.key()); + let command = create_key(&command_path)?; + set_string(&command, None, ®istration.command) + } + + fn delete_tree(path: &str) -> Result<()> { + let path = wide(OsStr::new(path)); + // SAFETY: `path` is a live, NUL-terminated UTF-16 string. Only tty7's + // own verb key is named, never a shared parent such as `shell`. + let code = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, path.as_ptr()) }; + match code { + ERROR_SUCCESS | ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(()), + other => Err(io_error("removing the tty7 Explorer registry key", other)), + } + } + + fn notify_explorer() { + // SAFETY: `SHCNE_ASSOCCHANGED` with `SHCNF_IDLIST` carries no item + // pointers. This is a cache invalidation hint after the registry write. + unsafe { + SHChangeNotify( + SHCNE_ASSOCCHANGED as i32, + SHCNF_IDLIST, + std::ptr::null(), + std::ptr::null(), + ); + } + } + + pub(super) fn status() -> Result { + let app = application_path()?; + let states = registrations(&app).map(|entry| entry_state(&entry)); + let [directory, background] = states; + let (directory, background) = (directory?, background?); + Ok(match (directory, background) { + (EntryState::Missing, EntryState::Missing) => Status::NotRegistered, + (EntryState::Matching, EntryState::Matching) => Status::Registered, + _ => Status::NeedsUpdate, + }) + } + + pub(super) fn register() -> Result<()> { + let app = application_path()?; + for registration in registrations(&app) { + replace_entry_with(®istration, delete_tree, write_entry_contents)?; + } + notify_explorer(); + Ok(()) + } + + pub(super) fn unregister() -> Result<()> { + // Try both removals even when one fails, so a damaged first key cannot + // strand the independent second menu entry forever. + let directory = delete_tree(DIRECTORY_KEY); + let background = delete_tree(BACKGROUND_KEY); + directory?; + background?; + notify_explorer(); + Ok(()) + } +} + +#[cfg(windows)] +fn platform_status() -> Result { + windows::status() +} + +#[cfg(windows)] +fn platform_register() -> Result<()> { + windows::register() +} + +#[cfg(windows)] +fn platform_unregister() -> Result<()> { + windows::unregister() +} + +#[cfg(not(windows))] +fn platform_status() -> Result { + Ok(Status::Unsupported) +} + +#[cfg(not(windows))] +fn platform_register() -> Result<()> { + anyhow::bail!("Windows Explorer integration is only available on Windows") +} + +#[cfg(not(windows))] +fn platform_unregister() -> Result<()> { + anyhow::bail!("Windows Explorer integration is only available on Windows") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registration_targets_both_directory_surfaces() { + let app = Path::new(r"C:\Program Files\tty7\tty7-app.exe"); + let [directory, background] = registrations(app); + + assert_eq!(directory.location.key(), DIRECTORY_KEY); + assert_eq!(directory.location.label(), "Open in tty7"); + assert_eq!(directory.icon, app.as_os_str()); + assert_eq!( + directory.command, + r#""C:\Program Files\tty7\tty7-app.exe" --open-path "%1""# + ); + + assert_eq!(background.location.key(), BACKGROUND_KEY); + assert_eq!(background.location.label(), "Open tty7 here"); + assert_eq!(background.icon, app.as_os_str()); + assert_eq!( + background.command, + r#""C:\Program Files\tty7\tty7-app.exe" --open-path "%V""# + ); + } + + #[test] + fn commands_quote_even_paths_without_spaces() { + assert_eq!( + quoted_open_command(Path::new(r"C:\tty7\tty7-app.exe"), "%1"), + r#""C:\tty7\tty7-app.exe" --open-path "%1""# + ); + } + + #[test] + fn registration_shape_rejects_every_extra_value_or_subkey() { + assert!(registration_tree_is_exact( + RegistryShape { + values: 2, + subkeys: 1, + }, + RegistryShape { + values: 1, + subkeys: 0, + }, + )); + assert!(!registration_tree_is_exact( + RegistryShape { + values: 3, + subkeys: 1, + }, + RegistryShape { + values: 1, + subkeys: 0, + }, + )); + assert!(!registration_tree_is_exact( + RegistryShape { + values: 2, + subkeys: 1, + }, + RegistryShape { + values: 2, + subkeys: 0, + }, + )); + assert!(!registration_tree_is_exact( + RegistryShape { + values: 2, + subkeys: 2, + }, + RegistryShape { + values: 1, + subkeys: 0, + }, + )); + } + + #[test] + fn registration_replaces_the_owned_tree_before_writing() { + let registration = Registration::new( + Location::Directory, + Path::new(r"C:\Program Files\tty7\tty7-app.exe"), + ); + let operations = std::cell::RefCell::new(Vec::new()); + + replace_entry_with( + ®istration, + |key| { + operations.borrow_mut().push(format!("delete:{key}")); + Ok(()) + }, + |_| { + operations.borrow_mut().push("write".to_string()); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!( + operations.into_inner(), + vec![format!("delete:{DIRECTORY_KEY}"), "write".to_string()] + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 22fb526f..b001814b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod actions; pub mod agent_prompt; pub mod cli_install; pub mod config; +pub mod explorer_context_menu; pub mod keychain; pub mod session; pub mod ssh_config; diff --git a/src/main.rs b/src/main.rs index 56222a2d..78ef59bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,32 +94,42 @@ fn is_theme_file(p: &std::path::Path) -> bool { }) } -fn apply_config_dir_arg() { - let mut args = std::env::args().skip(1); +fn strip_os_arg_prefix(arg: &std::ffi::OsStr, prefix: &str) -> Option { + let suffix = arg.as_encoded_bytes().strip_prefix(prefix.as_bytes())?; + // SAFETY: `prefix` is ASCII and is removed only from the beginning of an + // existing platform-encoded OsStr. ASCII bytes are self-synchronizing in + // Windows WTF-8 and Unix byte strings, so the suffix keeps valid encoding. + Some(unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(suffix.to_vec()) }) +} + +fn config_dir_from( + mut args: impl Iterator, +) -> Option { while let Some(arg) = args.next() { - if let Some(path) = arg.strip_prefix("--config-dir=") { - crate::core::config::set_config_dir(path.into()); - return; + if let Some(path) = strip_os_arg_prefix(&arg, "--config-dir=") { + return Some(path.into()); } - if arg == "--config-dir" { - if let Some(path) = args.next() { - crate::core::config::set_config_dir(path.into()); - } - return; + if arg == std::ffi::OsStr::new("--config-dir") { + return args.next().map(Into::into); } } + None +} + +fn apply_config_dir_arg(args: &[std::ffi::OsString]) { + if let Some(path) = config_dir_from(args.iter().cloned()) { + crate::core::config::set_config_dir(path); + } } -fn open_path_arg() -> Option { - open_path_from(std::env::args().skip(1)) -} - -fn open_path_from(mut args: impl Iterator) -> Option { +fn open_path_from( + mut args: impl Iterator, +) -> Option { while let Some(arg) = args.next() { - if let Some(path) = arg.strip_prefix("--open-path=") { + if let Some(path) = strip_os_arg_prefix(&arg, "--open-path=") { return Some(path.into()); } - if arg == "--open-path" { + if arg == std::ffi::OsStr::new("--open-path") { return args.next().map(Into::into); } } @@ -262,34 +272,41 @@ fn set_dock_icon_for_bare_binary() { } fn main() { + let args: Vec = std::env::args_os().skip(1).collect(); { - let args: Vec = std::env::args().skip(1).take(3).collect(); - if args.first().map(String::as_str) == Some("agent-hook") { - if let [_, agent, event] = args.as_slice() { + if args.first().map(std::ffi::OsString::as_os_str) + == Some(std::ffi::OsStr::new("agent-hook")) + { + if let (Some(agent), Some(event)) = ( + args.get(1).and_then(|arg| arg.to_str()), + args.get(2).and_then(|arg| arg.to_str()), + ) { crate::core::agent_hooks::run_agent_hook(agent, event); } return; } } - apply_config_dir_arg(); + apply_config_dir_arg(&args); - let role = if std::env::args().any(|a| a == "--daemon") { - "daemon" - } else { - "gui" - }; + let daemon = args + .iter() + .any(|arg| arg == std::ffi::OsStr::new("--daemon")); + let role = if daemon { "daemon" } else { "gui" }; crate::core::crash::install(role); crate::core::logfile::install(role); - if std::env::args().any(|a| a == "--daemon") { + if daemon { if let Err(e) = crate::daemon::server::run_daemon() { log::error!("daemon exited with error: {e}"); } return; } - if std::env::args().any(|a| a == "--stop-daemon") { + if args + .iter() + .any(|arg| arg == std::ffi::OsStr::new("--stop-daemon")) + { crate::daemon::spawn::stop(); return; } @@ -297,7 +314,7 @@ fn main() { #[cfg(unix)] enrich_path_from_login_shell(); - let open_path = open_path_arg(); + let open_path = open_path_from(args.into_iter()); if forward_open_path(open_path.as_deref()) { return; } @@ -374,7 +391,8 @@ mod tests { #[cfg(test)] mod argument_tests { - use super::{forward_open_path_with, open_path_from}; + use super::{config_dir_from, forward_open_path_with, open_path_from}; + use std::ffi::OsString; use std::path::PathBuf; use tty7_core::daemon::control::ReplyOk; @@ -383,14 +401,44 @@ mod argument_tests { let separate = open_path_from( ["--config-dir", "/cfg", "--open-path", "/work"] .into_iter() - .map(str::to_string), + .map(OsString::from), ); assert_eq!(separate, Some(PathBuf::from("/work"))); - let equals = open_path_from(["--open-path=C:\\work".to_string()].into_iter()); + let equals = open_path_from([OsString::from("--open-path=C:\\work")].into_iter()); assert_eq!(equals, Some(PathBuf::from("C:\\work"))); } + #[test] + fn config_dir_accepts_native_separate_and_equals_forms() { + let separate = config_dir_from( + [OsString::from("--config-dir"), OsString::from("C:\\cfg")].into_iter(), + ); + assert_eq!(separate, Some(PathBuf::from("C:\\cfg"))); + + let equals = config_dir_from([OsString::from("--config-dir=C:\\cfg")].into_iter()); + assert_eq!(equals, Some(PathBuf::from("C:\\cfg"))); + } + + #[cfg(windows)] + #[test] + fn open_path_preserves_unpaired_utf16_from_windows_arguments() { + use std::os::windows::ffi::OsStringExt as _; + + let native_path = + OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]); + let parsed = + open_path_from([OsString::from("--open-path"), native_path.clone()].into_iter()); + assert_eq!(parsed, Some(PathBuf::from(native_path.clone()))); + + let mut equals_arg = OsString::from("--open-path="); + equals_arg.push(&native_path); + assert_eq!( + open_path_from([equals_arg].into_iter()), + Some(PathBuf::from(native_path)) + ); + } + #[test] fn no_open_path_never_attempts_early_dispatch() { let delivered = forward_open_path_with(None, |_| { diff --git a/src/ui/app.rs b/src/ui/app.rs index f4f6a6cd..23a90b18 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1938,6 +1938,41 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.install_cli_on_path = on); } + pub(crate) fn register_explorer_context_menu(&mut self, cx: &mut Context) { + self.run_explorer_context_menu_action(true, cx); + } + + pub(crate) fn unregister_explorer_context_menu(&mut self, cx: &mut Context) { + self.run_explorer_context_menu_action(false, cx); + } + + fn run_explorer_context_menu_action(&mut self, register: bool, cx: &mut Context) { + // Registry operations are tiny and synchronous. Keeping the action on + // the UI thread also makes the displayed status correspond to the + // completed write, without a task racing a closed Settings window. + let result = if register { + crate::core::explorer_context_menu::register() + } else { + crate::core::explorer_context_menu::unregister() + }; + let note = match result { + Ok(()) if register => { + "Registered. Right-click a folder or folder background in Explorer to open it in tty7." + .to_string() + } + Ok(()) => "Unregistered from Windows Explorer.".to_string(), + Err(error) if register => format!("Could not register: {error}"), + Err(error) => format!("Could not unregister: {error}"), + }; + let status = + crate::core::explorer_context_menu::status().map_err(|error| error.to_string()); + if let Some(settings) = self.settings.as_mut() { + settings.explorer_context_menu_status = status; + settings.explorer_context_menu_note = Some(note); + } + cx.notify(); + } + pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -3547,6 +3582,9 @@ impl Tty7App { theme_search, recording: None, rebinding_note: None, + explorer_context_menu_status: crate::core::explorer_context_menu::status() + .map_err(|error| error.to_string()), + explorer_context_menu_note: None, ssh_form: None, ssh_detail: crate::ui::settings::SshDetail::None, ssh_filter, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index c1954de4..8f37de00 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -378,6 +378,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Command line tool", keywords: "cli tty7 path shell command install symlink terminal iterm agent script", }, + SearchEntry { + section: About, + title: "Windows Explorer context menu", + keywords: "windows explorer right click folder directory background shell menu register unregister open here", + }, ] } @@ -429,6 +434,9 @@ pub(crate) struct SettingsState { pub(crate) theme_search: Entity, pub(crate) recording: Option, pub(crate) rebinding_note: Option, + pub(crate) explorer_context_menu_status: + Result, + pub(crate) explorer_context_menu_note: Option, pub(crate) ssh_form: Option, pub(crate) ssh_detail: SshDetail, pub(crate) ssh_filter: Entity, @@ -4497,7 +4505,12 @@ impl Tty7App { fn render_settings_about(&self, cx: &mut Context) -> AnyElement { let theme = cx.theme(); - let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); + let (foreground, muted_fg, success, warning) = ( + theme.foreground, + theme.muted_foreground, + theme.success, + theme.warning, + ); let update_status = cx .try_global::() @@ -4526,6 +4539,40 @@ impl Tty7App { }; let check_for_updates = cx.global::().check_for_updates; let install_cli_on_path = cx.global::().install_cli_on_path; + let (explorer_status, explorer_note) = self + .active_settings() + .map(|settings| { + ( + settings.explorer_context_menu_status.clone(), + settings.explorer_context_menu_note.clone(), + ) + }) + .unwrap_or(( + Ok(crate::core::explorer_context_menu::Status::Unsupported), + None, + )); + let ( + explorer_status_text, + explorer_status_color, + register_label, + register_disabled, + unregister_disabled, + ) = match explorer_status.as_ref() { + Ok(crate::core::explorer_context_menu::Status::NotRegistered) => { + ("Not registered", muted_fg, "Register", false, true) + } + Ok(crate::core::explorer_context_menu::Status::Registered) => { + ("Registered", success, "Register", true, false) + } + Ok(crate::core::explorer_context_menu::Status::NeedsUpdate) => { + ("Needs update", warning, "Update", false, false) + } + Ok(crate::core::explorer_context_menu::Status::Unsupported) => { + ("Unavailable", muted_fg, "Register", true, true) + } + Err(_) => ("Status unavailable", warning, "Register", false, false), + }; + let explorer_feedback = explorer_note.or_else(|| explorer_status.err()); let logo = Arc::new(Image::from_bytes( ImageFormat::Png, @@ -4667,6 +4714,69 @@ impl Tty7App { ), ), ) + .when(cfg!(windows), |page| { + page.child( + v_flex() + .mt_6() + .gap_2() + .child(self.section_rule(cx)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Windows Explorer"), + ) + .child(div().text_sm().text_color(muted_fg).child( + "Add “Open in tty7” when you right-click a folder and “Open tty7 here” when you right-click a folder background. This is off by default and is registered only for your Windows account.", + )) + .child( + h_flex() + .gap_2() + .items_center() + .child(div().size_2().rounded_full().bg(explorer_status_color)) + .child( + div() + .text_sm() + .text_color(foreground) + .child(explorer_status_text), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("explorer-menu-register") + .label(register_label) + .small() + .disabled(register_disabled) + .on_click(cx.listener(|this, _, _window, cx| { + this.register_explorer_context_menu(cx) + })), + ) + .child( + Button::new("explorer-menu-unregister") + .label("Unregister") + .small() + .disabled(unregister_disabled) + .on_click(cx.listener(|this, _, _window, cx| { + this.unregister_explorer_context_menu(cx) + })), + ), + ) + .when_some(explorer_feedback, |section, message| { + section.child( + div() + .text_xs() + .text_color(muted_fg) + .child(message), + ) + }) + .child(div().text_xs().text_color(muted_fg).child( + "On Windows 11, classic shell entries may appear under “Show more options”.", + )), + ) + }) .child( v_flex() .mt_6() @@ -4795,6 +4905,7 @@ mod tests { ("bell", Terminal), ("known_hosts", Ssh), ("claude", Agents), + ("right click", About), ]; for (query, expected) in cases { assert_eq!(