diff --git a/Cargo.lock b/Cargo.lock index 980c160..1010be1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,6 +2990,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "windows 0.62.2", + "windows-future 0.3.2", "windows-sys 0.59.0", "winit", "winresource", diff --git a/Cargo.toml b/Cargo.toml index 6420d52..b63b2db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,12 +83,15 @@ once_cell = "1" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ + "ApplicationModel", + "Foundation", "Win32_Foundation", "Win32_System_Com", "Win32_System_Console", "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_Registry", + "Win32_Storage_Packaging_Appx", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_Graphics_Dwm", @@ -96,6 +99,7 @@ windows = { version = "0.62", features = [ "Win32_UI_Shell", "Win32_Media_Audio", ] } +windows-future = "0.3" windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Security", diff --git a/README.md b/README.md index e241dcb..4bacc84 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,16 @@ System → About. Everything else is x64. `openwritr-windows--vX.Y.Z-setup.exe` from [Releases](https://github.com/trsdn/openwritr-windows/releases/latest) and run it. Sets up the Start Menu shortcut, an optional autostart-at-logon -entry, and a proper uninstaller you'll find under Settings → Apps. +entry, and a proper uninstaller you'll find under Settings → Apps. You can +turn autostart on or off any time later under Settings → Startup. **Portable zip.** Download `openwritr-windows--vX.Y.Z.zip` and unzip it into **any folder you like** (e.g. `C:\Tools\OpenWritr\`), then run `openwritr.exe`. The app finds its DLLs next to the exe — the install -location doesn't matter. Same binaries as the installer, just no shortcuts -and no autostart. +location doesn't matter. Same binaries as the installer, just no shortcuts. +Autostart is available here too: enable "Run at startup" under Settings → +Startup, and OpenWritr repairs the entry automatically if you later move the +folder. > **Note:** user data (settings, downloaded models, logs) always lives under > `%LOCALAPPDATA%\OpenWritr\` — the app creates that folder automatically on diff --git a/installer/openwritr-x64.iss b/installer/openwritr-x64.iss index 76f3691..a880a6a 100644 --- a/installer/openwritr-x64.iss +++ b/installer/openwritr-x64.iss @@ -65,14 +65,52 @@ Source: "{#SrcDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs cre Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: startmenuicon Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"; Tasks: startmenuicon Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon -Name: "{userstartup}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: autostart + +[Registry] +; Autostart is a HKCU\...\Run value so the app can read, toggle, and verify the +; exact same mechanism at runtime (Settings → Startup). uninsdeletevalue removes +; it on uninstall even if the task was unselected but later enabled in the app. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "OpenWritr"; ValueData: """{app}\{#AppExeName}"""; Flags: uninsdeletevalue; Tasks: autostart + +[InstallDelete] +; Migrate away from the legacy startup-folder shortcut used by older installers, +; so autostart state never lives in two mechanisms at once. Gated on the autostart +; task so we only remove the shortcut when we actually write the Run value in its +; place. If the user deselects autostart on upgrade, the shortcut is left untouched +; and the app-side migration in autostart.rs remains able to pick it up later — +; a previously active autostart is never silently dropped. +Type: files; Name: "{userstartup}\{#AppName}.lnk"; Tasks: autostart [Run] Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent [UninstallRun] Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExeName} /F"; Flags: runhidden; RunOnceId: "KillOpenWritr" +; Always remove the autostart Run value, even if the user enabled it from inside +; the app after installing without the autostart task (so uninsdeletevalue never +; recorded it). reg delete is a no-op with exit 1 if it is already gone. +Filename: "{cmd}"; Parameters: "/C reg delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v OpenWritr /f"; Flags: runhidden; RunOnceId: "RemoveOpenWritrRun" [UninstallDelete] ; Leave user data (settings, models, logs) under %LOCALAPPDATA%\OpenWritr\ alone. +Type: files; Name: "{userstartup}\{#AppName}.lnk" Type: filesandordirs; Name: "{app}" + +[Code] +// Preselect the autostart task on upgrade when this user already had autostart +// enabled by an older install — either the legacy startup-folder shortcut or the +// HKCU Run value. Inno remembers the previous task selection across upgrades, so +// a user who once deselected the task would otherwise get it deselected again by +// default; this restores it to checked when prior autostart state is detected. +function LegacyAutostartEnabled(): Boolean; +begin + Result := FileExists(ExpandConstant('{userstartup}\{#AppName}.lnk')) or + RegValueExists(HKEY_CURRENT_USER, + 'Software\Microsoft\Windows\CurrentVersion\Run', 'OpenWritr'); +end; + +procedure InitializeWizard(); +begin + if LegacyAutostartEnabled() then + WizardSelectTasks('autostart'); +end; diff --git a/installer/openwritr.iss b/installer/openwritr.iss index c545a12..30c2c79 100644 --- a/installer/openwritr.iss +++ b/installer/openwritr.iss @@ -72,7 +72,24 @@ Source: "{#SrcDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs cre Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: startmenuicon Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"; Tasks: startmenuicon Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon -Name: "{userstartup}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: autostart + +[Registry] +; Autostart is a HKCU\...\Run value so the app can read, toggle, and verify the +; exact same mechanism at runtime (Settings → Startup). uninsdeletevalue removes +; it on uninstall even if the task was unselected but later enabled in the app. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: string; ValueName: "OpenWritr"; \ + ValueData: """{app}\{#AppExeName}"""; \ + Flags: uninsdeletevalue; Tasks: autostart + +[InstallDelete] +; Migrate away from the legacy startup-folder shortcut used by older installers, +; so autostart state never lives in two mechanisms at once. Gated on the autostart +; task so we only remove the shortcut when we actually write the Run value in its +; place. If the user deselects autostart on upgrade, the shortcut is left untouched +; and the app-side migration in autostart.rs remains able to pick it up later — +; a previously active autostart is never silently dropped. +Type: files; Name: "{userstartup}\{#AppName}.lnk"; Tasks: autostart [Run] ; Optional: launch right after install. SkipIfSilent so headless installs don't pop a window. @@ -81,8 +98,32 @@ Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait [UninstallRun] ; Stop any running instance before removing files. Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExeName} /F"; Flags: runhidden; RunOnceId: "KillOpenWritr" +; Always remove the autostart Run value, even if the user enabled it from inside +; the app after installing without the autostart task (so uninsdeletevalue never +; recorded it). reg delete is a no-op with exit 1 if it is already gone. +Filename: "{cmd}"; Parameters: "/C reg delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v OpenWritr /f"; Flags: runhidden; RunOnceId: "RemoveOpenWritrRun" [UninstallDelete] ; Leave user data (settings, models, logs) under %LOCALAPPDATA%\OpenWritr\ alone. ; Only the app/ subfolder is uninstalled. Add a custom message in the wizard. +Type: files; Name: "{userstartup}\{#AppName}.lnk" Type: filesandordirs; Name: "{app}" + +[Code] +// Preselect the autostart task on upgrade when this user already had autostart +// enabled by an older install — either the legacy startup-folder shortcut or the +// HKCU Run value. Inno remembers the previous task selection across upgrades, so +// a user who once deselected the task would otherwise get it deselected again by +// default; this restores it to checked when prior autostart state is detected. +function LegacyAutostartEnabled(): Boolean; +begin + Result := FileExists(ExpandConstant('{userstartup}\{#AppName}.lnk')) or + RegValueExists(HKEY_CURRENT_USER, + 'Software\Microsoft\Windows\CurrentVersion\Run', 'OpenWritr'); +end; + +procedure InitializeWizard(); +begin + if LegacyAutostartEnabled() then + WizardSelectTasks('autostart'); +end; diff --git a/src/autostart.rs b/src/autostart.rs new file mode 100644 index 0000000..3ad8919 --- /dev/null +++ b/src/autostart.rs @@ -0,0 +1,772 @@ +//! "Run at startup" backend — manages the OS autostart entry for OpenWritr. +//! +//! This deliberately mirrors the `CredentialStore` trait shape in +//! `src/credentials.rs`: a small backend trait, a concrete Windows +//! implementation, and an injectable fake used by the unit tests. The rest of +//! the app (settings UI, diagnostics, self-check) only ever sees +//! [`AutostartBackend`]. +//! +//! Two mutually exclusive backends exist, picked at runtime by [`backend`]: +//! +//! * **Unpackaged** (installer + portable): a value named `OpenWritr` under +//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` pointing at the +//! current executable. Task Manager / Settings → Apps → Startup can disable +//! it; that disabled state is recorded under `...\Explorer\StartupApproved\Run` +//! and reported as [`AutostartState::DisabledByOs`]. +//! * **Packaged** (MSIX / Store): the `Windows.ApplicationModel.StartupTask` +//! WinRT API against the `OpenWritrStartup` task declared in the manifest. +//! +//! A login start passes no arguments, and an argless launch already lands in +//! tray mode (see `src/main.rs`), so autostart is silent by construction — no +//! extra mode flag is needed. + +use std::path::PathBuf; +use thiserror::Error; +use tracing::{info, warn}; + +/// Registry value / manifest task id. Both backends use the same name so the +/// installer, the in-app toggle, and the MSIX manifest all agree. +pub const AUTOSTART_ENTRY_NAME: &str = "OpenWritr"; + +#[derive(Debug, Error)] +pub enum AutostartError { + #[error("resolve current executable failed: {0}")] + CurrentExe(String), + #[error("autostart {operation} failed: {message}")] + Backend { + operation: &'static str, + message: String, + }, + #[error("autostart verification failed after {0}")] + VerificationFailed(&'static str), +} + +impl AutostartError { + fn backend(operation: &'static str, message: impl Into) -> Self { + Self::Backend { + operation, + message: message.into(), + } + } +} + +/// The autostart state as reported by the OS. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AutostartState { + Enabled, + Disabled, + /// The entry exists but Windows overrides it (user turned it off in Task + /// Manager, or policy forbids it). The app must not fight this by + /// re-enabling on launch. + DisabledByOs { + reason: String, + }, +} + +impl AutostartState { + pub fn is_enabled(&self) -> bool { + matches!(self, AutostartState::Enabled) + } + + /// Short, log/diagnostics-friendly label. + pub fn label(&self) -> &'static str { + match self { + AutostartState::Enabled => "enabled", + AutostartState::Disabled => "disabled", + AutostartState::DisabledByOs { .. } => "disabled_by_os", + } + } +} + +pub trait AutostartBackend { + /// A stable identifier for the resolved backend, for diagnostics. + fn kind(&self) -> &'static str; + fn state(&self) -> Result; + fn enable(&self) -> Result<(), AutostartError>; + fn disable(&self) -> Result<(), AutostartError>; +} + +/// Enable then read back, so a UI toggle never claims success on a silent +/// no-op. `DisabledByOs` after an enable is *not* success — Windows overrode us. +pub fn enable_verified(backend: &dyn AutostartBackend) -> Result<(), AutostartError> { + backend.enable()?; + match backend.state()? { + AutostartState::Enabled => Ok(()), + _ => Err(AutostartError::VerificationFailed("enable")), + } +} + +/// Disable then read back and confirm it is no longer enabled. +pub fn disable_verified(backend: &dyn AutostartBackend) -> Result<(), AutostartError> { + backend.disable()?; + match backend.state()? { + AutostartState::Enabled => Err(AutostartError::VerificationFailed("disable")), + _ => Ok(()), + } +} + +/// Resolve the backend for the current process: packaged builds use the WinRT +/// StartupTask, everything else uses the registry Run value. +pub fn backend() -> Box { + #[cfg(windows)] + { + if packaged::is_packaged() { + Box::new(packaged::StartupTaskBackend::new()) + } else { + Box::new(RegistryRunBackend::for_current_exe()) + } + } + #[cfg(not(windows))] + { + Box::new(NoopBackend) + } +} + +/// Non-Windows placeholder so the crate still builds off-Windows (tests/CI). +#[cfg(not(windows))] +struct NoopBackend; + +#[cfg(not(windows))] +impl AutostartBackend for NoopBackend { + fn kind(&self) -> &'static str { + "noop" + } + fn state(&self) -> Result { + Ok(AutostartState::Disabled) + } + fn enable(&self) -> Result<(), AutostartError> { + Ok(()) + } + fn disable(&self) -> Result<(), AutostartError> { + Ok(()) + } +} + +fn quoted_exe(path: &std::path::Path) -> String { + format!("\"{}\"", path.display()) +} + +/// The path Windows Explorer would launch is the exe wrapped in quotes, no +/// args. Used for stale-path comparison and for writing the value. +fn current_exe_command() -> Result { + let exe = + std::env::current_exe().map_err(|error| AutostartError::CurrentExe(error.to_string()))?; + Ok(quoted_exe(&exe)) +} + +// --------------------------------------------------------------------------- +// Unpackaged backend: HKCU Run value. +// --------------------------------------------------------------------------- + +/// Abstraction over the two registry locations we touch, so the disabled-blob +/// parsing, stale-path repair, and legacy migration logic can be unit-tested +/// against an in-memory fake instead of the live registry. +pub trait RunRegistry { + /// The `Run` value data, or `None` if the value is absent. + fn run_value(&self) -> Result, AutostartError>; + fn set_run_value(&self, data: &str) -> Result<(), AutostartError>; + fn delete_run_value(&self) -> Result<(), AutostartError>; + /// The raw `StartupApproved\Run` blob for our entry, if present. + fn startup_approved(&self) -> Result>, AutostartError>; +} + +/// Task Manager records a disabled startup entry as a 12-byte blob whose first +/// byte is 2 or 3. Enabled entries start with a low even value (commonly 2 for +/// the header but with a distinct layout); empirically the disabled flag lives +/// in the first byte being 3 (disabled) vs 2 (enabled). We treat first byte +/// odd / >= 3 as disabled to be robust. +pub fn startup_approved_is_disabled(blob: &[u8]) -> bool { + match blob.first() { + Some(&first) => first != 2, + None => false, + } +} + +pub struct RegistryRunBackend { + registry: R, + desired_command: Result, + legacy_shortcut: Option, +} + +impl RegistryRunBackend { + pub fn for_current_exe() -> Self { + Self { + registry: WindowsRunRegistry, + desired_command: current_exe_command(), + legacy_shortcut: legacy_startup_shortcut(), + } + } +} + +impl RegistryRunBackend { + #[cfg(test)] + fn with_parts(registry: R, command: String, legacy_shortcut: Option) -> Self { + Self { + registry, + desired_command: Ok(command), + legacy_shortcut, + } + } + + fn desired(&self) -> Result<&str, AutostartError> { + match &self.desired_command { + Ok(command) => Ok(command.as_str()), + Err(error) => Err(AutostartError::backend("resolve exe", error.to_string())), + } + } + + /// Migrate a legacy `{userstartup}\OpenWritr.lnk` to the registry once: if + /// the shortcut exists, treat autostart as enabled, write the Run value, + /// delete the shortcut, and log it. Returns true if a migration happened. + fn migrate_legacy_shortcut(&self) -> Result { + let Some(shortcut) = self.legacy_shortcut.as_ref() else { + return Ok(false); + }; + if !shortcut.exists() { + return Ok(false); + } + let desired = self.desired()?.to_string(); + self.registry.set_run_value(&desired)?; + match std::fs::remove_file(shortcut) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(AutostartError::backend( + "remove legacy shortcut", + error.to_string(), + )); + } + } + info!( + shortcut = %shortcut.display(), + "migrated legacy startup-folder shortcut to the registry Run value" + ); + Ok(true) + } +} + +impl AutostartBackend for RegistryRunBackend { + fn kind(&self) -> &'static str { + "registry_run" + } + + fn state(&self) -> Result { + // Legacy migration happens first so a fresh install that used the old + // shortcut mechanism reports enabled and converges on the registry. + self.migrate_legacy_shortcut()?; + + let Some(current) = self.registry.run_value()? else { + return Ok(AutostartState::Disabled); + }; + + // Stale-path repair: portable users move the folder, so the stored + // path drifts from the current exe. Rewrite it on read so the entry + // keeps working. Only attempt when we could resolve our own exe. + if let Ok(desired) = self.desired() { + if current != desired { + if let Err(error) = self.registry.set_run_value(desired) { + warn!(%error, "failed to repair stale autostart path"); + } else { + info!( + old = %current, + new = %desired, + "repaired stale autostart Run value path" + ); + } + } + } + + if let Some(blob) = self.registry.startup_approved()? { + if startup_approved_is_disabled(&blob) { + return Ok(AutostartState::DisabledByOs { + reason: "Turned off in Windows (Task Manager or Settings → Apps → Startup)." + .to_string(), + }); + } + } + + Ok(AutostartState::Enabled) + } + + fn enable(&self) -> Result<(), AutostartError> { + let desired = self.desired()?.to_string(); + self.registry.set_run_value(&desired) + } + + fn disable(&self) -> Result<(), AutostartError> { + self.registry.delete_run_value() + } +} + +/// `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\OpenWritr.lnk`. +fn legacy_startup_shortcut() -> Option { + let appdata = std::env::var_os("APPDATA")?; + Some( + PathBuf::from(appdata) + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs") + .join("Startup") + .join("OpenWritr.lnk"), + ) +} + +// --------------------------------------------------------------------------- +// Live Windows registry implementation of RunRegistry. +// --------------------------------------------------------------------------- + +#[cfg(windows)] +pub struct WindowsRunRegistry; + +#[cfg(not(windows))] +pub struct WindowsRunRegistry; + +#[cfg(windows)] +mod win_registry { + use super::{AutostartError, RunRegistry, WindowsRunRegistry, AUTOSTART_ENTRY_NAME}; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, WIN32_ERROR}; + use windows::Win32::System::Registry::{ + RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY, + HKEY_CURRENT_USER, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_BINARY, REG_SZ, REG_VALUE_TYPE, + }; + + const RUN_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; + const STARTUP_APPROVED_SUBKEY: &str = + r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + struct OpenKey(HKEY); + + impl Drop for OpenKey { + fn drop(&mut self) { + unsafe { + let _ = RegCloseKey(self.0); + } + } + } + + fn open( + subkey: &str, + access: windows::Win32::System::Registry::REG_SAM_FLAGS, + ) -> Result, AutostartError> { + let subkey_wide = wide(subkey); + let mut handle = HKEY::default(); + let status = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR(subkey_wide.as_ptr()), + None, + access, + &mut handle, + ) + }; + if status == ERROR_SUCCESS { + Ok(Some(OpenKey(handle))) + } else if status == ERROR_FILE_NOT_FOUND { + Ok(None) + } else { + Err(AutostartError::backend( + "open registry key", + format!("{subkey}: {}", status.0), + )) + } + } + + fn open_run_for_write() -> Result { + // The Run key always exists on a normal profile, but be defensive. + match open(RUN_SUBKEY, KEY_SET_VALUE | KEY_QUERY_VALUE)? { + Some(key) => Ok(key), + None => Err(AutostartError::backend( + "open registry key", + format!("{RUN_SUBKEY}: missing"), + )), + } + } + + fn ok(status: WIN32_ERROR, operation: &'static str) -> Result<(), AutostartError> { + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(AutostartError::backend(operation, status.0.to_string())) + } + } + + impl RunRegistry for WindowsRunRegistry { + fn run_value(&self) -> Result, AutostartError> { + let Some(key) = open(RUN_SUBKEY, KEY_QUERY_VALUE)? else { + return Ok(None); + }; + let name = wide(AUTOSTART_ENTRY_NAME); + let mut kind = REG_VALUE_TYPE::default(); + let mut size = 0u32; + let status = unsafe { + RegQueryValueExW( + key.0, + PCWSTR(name.as_ptr()), + None, + Some(&mut kind), + None, + Some(&mut size), + ) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + ok(status, "query Run value size")?; + if size == 0 { + return Ok(Some(String::new())); + } + let mut buffer = vec![0u8; size as usize]; + let mut size_out = size; + let status = unsafe { + RegQueryValueExW( + key.0, + PCWSTR(name.as_ptr()), + None, + Some(&mut kind), + Some(buffer.as_mut_ptr()), + Some(&mut size_out), + ) + }; + ok(status, "query Run value")?; + buffer.truncate(size_out as usize); + Ok(Some(decode_wide_sz(&buffer))) + } + + fn set_run_value(&self, data: &str) -> Result<(), AutostartError> { + let key = open_run_for_write()?; + let name = wide(AUTOSTART_ENTRY_NAME); + let value = wide(data); + let bytes = + unsafe { std::slice::from_raw_parts(value.as_ptr().cast::(), value.len() * 2) }; + let status = + unsafe { RegSetValueExW(key.0, PCWSTR(name.as_ptr()), None, REG_SZ, Some(bytes)) }; + ok(status, "set Run value") + } + + fn delete_run_value(&self) -> Result<(), AutostartError> { + let Some(key) = open(RUN_SUBKEY, KEY_SET_VALUE)? else { + return Ok(()); + }; + let name = wide(AUTOSTART_ENTRY_NAME); + let status = unsafe { RegDeleteValueW(key.0, PCWSTR(name.as_ptr())) }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(()); + } + ok(status, "delete Run value") + } + + fn startup_approved(&self) -> Result>, AutostartError> { + let Some(key) = open(STARTUP_APPROVED_SUBKEY, KEY_QUERY_VALUE)? else { + return Ok(None); + }; + let name = wide(AUTOSTART_ENTRY_NAME); + let mut kind = REG_VALUE_TYPE::default(); + let mut size = 0u32; + let status = unsafe { + RegQueryValueExW( + key.0, + PCWSTR(name.as_ptr()), + None, + Some(&mut kind), + None, + Some(&mut size), + ) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + ok(status, "query StartupApproved size")?; + if kind != REG_BINARY || size == 0 { + return Ok(None); + } + let mut buffer = vec![0u8; size as usize]; + let mut size_out = size; + let status = unsafe { + RegQueryValueExW( + key.0, + PCWSTR(name.as_ptr()), + None, + Some(&mut kind), + Some(buffer.as_mut_ptr()), + Some(&mut size_out), + ) + }; + ok(status, "query StartupApproved")?; + buffer.truncate(size_out as usize); + Ok(Some(buffer)) + } + } + + fn decode_wide_sz(bytes: &[u8]) -> String { + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .take_while(|unit| *unit != 0) + .collect(); + String::from_utf16_lossy(&units) + } +} + +// --------------------------------------------------------------------------- +// Packaged (MSIX) backend: WinRT StartupTask. +// --------------------------------------------------------------------------- + +#[cfg(windows)] +mod packaged { + use super::{AutostartBackend, AutostartError, AutostartState, AUTOSTART_ENTRY_NAME}; + use windows::core::HSTRING; + use windows::ApplicationModel::{StartupTask, StartupTaskState}; + use windows::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; + use windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName; + use windows_future::{AsyncStatus, IAsyncOperation}; + + /// True when running from an MSIX package (has a package identity). + pub fn is_packaged() -> bool { + let mut length: u32 = 0; + let status = unsafe { GetCurrentPackageFullName(&mut length, None) }; + // With a null buffer, a packaged process returns ERROR_INSUFFICIENT_BUFFER; + // an unpackaged one returns APPMODEL_ERROR_NO_PACKAGE. + status == ERROR_INSUFFICIENT_BUFFER + } + + /// Block on a WinRT async operation without pulling in an async runtime. + /// StartupTask operations complete effectively immediately, so a short + /// bounded spin is sufficient and avoids the `windows-future` blocking + /// helper (not exposed by this crate version). + fn block_on( + operation: IAsyncOperation, + context: &'static str, + ) -> Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let status = operation + .Status() + .map_err(|error| AutostartError::backend(context, error.message()))?; + match status { + AsyncStatus::Completed => { + return operation + .GetResults() + .map_err(|error| AutostartError::backend(context, error.message())); + } + AsyncStatus::Started => { + if std::time::Instant::now() >= deadline { + return Err(AutostartError::backend(context, "timed out")); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + AsyncStatus::Canceled => { + return Err(AutostartError::backend(context, "operation canceled")); + } + _ => { + // Error status: surface the underlying failure via GetResults. + return operation + .GetResults() + .map_err(|error| AutostartError::backend(context, error.message())); + } + } + } + } + + pub struct StartupTaskBackend; + + impl StartupTaskBackend { + pub fn new() -> Self { + Self + } + + fn task(&self) -> Result { + let operation = + StartupTask::GetAsync(&HSTRING::from(AUTOSTART_ENTRY_NAME)).map_err(|error| { + AutostartError::backend("StartupTask.GetAsync", error.message()) + })?; + block_on(operation, "StartupTask.GetAsync") + } + } + + impl AutostartBackend for StartupTaskBackend { + fn kind(&self) -> &'static str { + "startup_task" + } + + fn state(&self) -> Result { + let task = self.task()?; + let state = task + .State() + .map_err(|error| AutostartError::backend("StartupTask.State", error.message()))?; + Ok(map_state(state)) + } + + fn enable(&self) -> Result<(), AutostartError> { + let task = self.task()?; + // RequestEnableAsync may still return a DisabledBy* state if the OS + // or user overrides us; the verified wrapper re-reads and surfaces it. + let operation = task.RequestEnableAsync().map_err(|error| { + AutostartError::backend("StartupTask.RequestEnableAsync", error.message()) + })?; + block_on(operation, "StartupTask.RequestEnableAsync")?; + Ok(()) + } + + fn disable(&self) -> Result<(), AutostartError> { + let task = self.task()?; + task.Disable() + .map_err(|error| AutostartError::backend("StartupTask.Disable", error.message())) + } + } + + fn map_state(state: StartupTaskState) -> AutostartState { + if state == StartupTaskState::Enabled || state == StartupTaskState::EnabledByPolicy { + AutostartState::Enabled + } else if state == StartupTaskState::DisabledByUser { + AutostartState::DisabledByOs { + reason: "Turned off in Windows (Task Manager or Settings → Apps → Startup)." + .to_string(), + } + } else if state == StartupTaskState::DisabledByPolicy { + AutostartState::DisabledByOs { + reason: "Blocked by Windows policy on this device.".to_string(), + } + } else { + // StartupTaskState::Disabled and any future variant. + AutostartState::Disabled + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parking_lot::Mutex; + + #[derive(Default)] + struct FakeRegistry { + run: Mutex>, + approved: Mutex>>, + } + + impl RunRegistry for FakeRegistry { + fn run_value(&self) -> Result, AutostartError> { + Ok(self.run.lock().clone()) + } + fn set_run_value(&self, data: &str) -> Result<(), AutostartError> { + *self.run.lock() = Some(data.to_string()); + Ok(()) + } + fn delete_run_value(&self) -> Result<(), AutostartError> { + *self.run.lock() = None; + Ok(()) + } + fn startup_approved(&self) -> Result>, AutostartError> { + Ok(self.approved.lock().clone()) + } + } + + fn backend_with(registry: FakeRegistry, command: &str) -> RegistryRunBackend { + RegistryRunBackend::with_parts(registry, command.to_string(), None) + } + + #[test] + fn enable_disable_read_round_trip() { + let backend = backend_with(FakeRegistry::default(), "\"C:\\app\\openwritr.exe\""); + assert_eq!(backend.state().unwrap(), AutostartState::Disabled); + + enable_verified(&backend).unwrap(); + assert_eq!(backend.state().unwrap(), AutostartState::Enabled); + assert_eq!( + backend.registry.run.lock().as_deref(), + Some("\"C:\\app\\openwritr.exe\"") + ); + + disable_verified(&backend).unwrap(); + assert_eq!(backend.state().unwrap(), AutostartState::Disabled); + assert!(backend.registry.run.lock().is_none()); + } + + #[test] + fn stale_path_is_repaired_on_read() { + let registry = FakeRegistry::default(); + *registry.run.lock() = Some("\"C:\\old\\location\\openwritr.exe\"".to_string()); + let backend = backend_with(registry, "\"C:\\new\\location\\openwritr.exe\""); + + // Reading reports enabled and rewrites the drifted path in place. + assert_eq!(backend.state().unwrap(), AutostartState::Enabled); + assert_eq!( + backend.registry.run.lock().as_deref(), + Some("\"C:\\new\\location\\openwritr.exe\"") + ); + } + + #[test] + fn startup_approved_disabled_blob_is_reported() { + let registry = FakeRegistry::default(); + *registry.run.lock() = Some("\"C:\\app\\openwritr.exe\"".to_string()); + // Task Manager "disabled" blob: first byte 3. + *registry.approved.lock() = Some(vec![3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + let backend = backend_with(registry, "\"C:\\app\\openwritr.exe\""); + + match backend.state().unwrap() { + AutostartState::DisabledByOs { reason } => assert!(!reason.is_empty()), + other => panic!("expected DisabledByOs, got {other:?}"), + } + } + + #[test] + fn startup_approved_enabled_blob_reports_enabled() { + let registry = FakeRegistry::default(); + *registry.run.lock() = Some("\"C:\\app\\openwritr.exe\"".to_string()); + // Enabled blob: first byte 2. + *registry.approved.lock() = Some(vec![2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + let backend = backend_with(registry, "\"C:\\app\\openwritr.exe\""); + + assert_eq!(backend.state().unwrap(), AutostartState::Enabled); + } + + #[test] + fn disabled_blob_first_byte_parsing() { + assert!(!startup_approved_is_disabled(&[2, 0, 0, 0])); + assert!(startup_approved_is_disabled(&[3, 0, 0, 0])); + assert!(startup_approved_is_disabled(&[6, 0, 0, 0])); + assert!(!startup_approved_is_disabled(&[])); + } + + #[test] + fn legacy_shortcut_migration_writes_registry_and_deletes_shortcut() { + let temp = tempfile::tempdir().unwrap(); + let shortcut = temp.path().join("OpenWritr.lnk"); + std::fs::write(&shortcut, b"legacy").unwrap(); + + let backend = RegistryRunBackend::with_parts( + FakeRegistry::default(), + "\"C:\\app\\openwritr.exe\"".to_string(), + Some(shortcut.clone()), + ); + + // First read migrates: enabled, value written, shortcut gone. + assert_eq!(backend.state().unwrap(), AutostartState::Enabled); + assert_eq!( + backend.registry.run.lock().as_deref(), + Some("\"C:\\app\\openwritr.exe\"") + ); + assert!(!shortcut.exists()); + } + + #[test] + fn no_legacy_shortcut_leaves_state_disabled() { + let temp = tempfile::tempdir().unwrap(); + let shortcut = temp.path().join("OpenWritr.lnk"); + // Do not create the file. + let backend = RegistryRunBackend::with_parts( + FakeRegistry::default(), + "\"C:\\app\\openwritr.exe\"".to_string(), + Some(shortcut), + ); + assert_eq!(backend.state().unwrap(), AutostartState::Disabled); + assert!(backend.registry.run.lock().is_none()); + } +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs index e1cdc8a..a7c1785 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -110,6 +110,12 @@ fn export_bundle_from( let runtime_status = collect_runtime_status(runtime_dir)?; write_json(&mut zip, "runtime-status.json", &runtime_status, options)?; + write_json( + &mut zip, + "autostart-status.json", + &autostart_status(), + options, + )?; let runtime_receipt = runtime_dir.join("runtime-versions.json"); if runtime_receipt.is_file() { let bytes = fs::read(&runtime_receipt) @@ -162,6 +168,21 @@ fn write_bytes( Ok(()) } +fn autostart_status() -> Value { + let backend = crate::autostart::backend(); + match backend.state() { + Ok(state) => json!({ + "backend": backend.kind(), + "state": state.label(), + }), + Err(error) => json!({ + "backend": backend.kind(), + "state": "error", + "error": error.to_string(), + }), + } +} + fn collect_runtime_status(runtime_dir: &Path) -> Result { let receipt_path = runtime_dir.join("runtime-versions.json"); if receipt_path.is_file() { diff --git a/src/main.rs b/src/main.rs index 514dd7b..48735d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod about; mod app; mod asr; mod audio; +mod autostart; mod cleanup; mod credentials; mod diagnostics; diff --git a/src/self_check.rs b/src/self_check.rs index 2af00f4..4b421d8 100644 --- a/src/self_check.rs +++ b/src/self_check.rs @@ -9,6 +9,16 @@ pub fn run() -> Result<()> { let data_dir = verify_writable_data_directory()?; let hardware = asr::whisper_hardware_status().context("inspect Whisper NPU hardware")?; + let autostart_backend = crate::autostart::backend(); + let autostart = match autostart_backend.state() { + Ok(state) => json!({ "backend": autostart_backend.kind(), "state": state.label() }), + Err(error) => json!({ + "backend": autostart_backend.kind(), + "state": "error", + "error": error.to_string(), + }), + }; + let report = json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION"), @@ -17,6 +27,7 @@ pub fn run() -> Result<()> { "model_manifest": "ok", "data_directory": data_dir, "whisper_npu_hardware": hardware, + "autostart": autostart, }); println!("{}", serde_json::to_string_pretty(&report)?); Ok(()) diff --git a/src/settings_ui.rs b/src/settings_ui.rs index bf82a63..075e55c 100644 --- a/src/settings_ui.rs +++ b/src/settings_ui.rs @@ -1,6 +1,7 @@ //! egui settings dialog launched as a subprocess. use crate::about; +use crate::autostart::{self, AutostartBackend, AutostartState}; use crate::cleanup::{catalog, PromptSource, PromptTarget}; use crate::credentials::{store_verified, CredentialStore, WindowsCredentialStore}; use crate::diagnostics; @@ -80,6 +81,7 @@ pub fn run_dialog(show_about: bool) -> Result<()> { prompt_editor: PromptEditorState::default(), show_about, about_error: None, + autostart: AutostartUi::new(), })) }), ) @@ -135,6 +137,60 @@ struct SettingsApp { prompt_editor: PromptEditorState, show_about: bool, about_error: Option, + autostart: AutostartUi, +} + +/// OS autostart state, kept separate from `Settings`: it is machine/OS state, +/// not settings-file state. It never participates in `persist()`, the dirty +/// flag, or `SettingsRevision`; it writes through to the OS immediately on +/// click and is verified by a read-back. This also stops a copied +/// `settings.json` from silently arming autostart on another machine. +struct AutostartUi { + backend: Box, + state: Result, + error: Option, +} + +impl AutostartUi { + fn new() -> Self { + let backend = autostart::backend(); + let state = backend.state().map_err(|error| error.to_string()); + Self { + backend, + state, + error: None, + } + } + + /// Re-read the real OS state so external changes (Task Manager, Settings → + /// Apps → Startup) are shown truthfully. + fn refresh(&mut self) { + self.state = self.backend.state().map_err(|error| error.to_string()); + } + + fn set_enabled(&mut self, enabled: bool) { + let result = if enabled { + autostart::enable_verified(self.backend.as_ref()) + } else { + autostart::disable_verified(self.backend.as_ref()) + }; + match result { + Ok(()) => self.error = None, + Err(error) => self.error = Some(error.to_string()), + } + // Always re-read the truth after a write, success or failure. + self.refresh(); + } + + #[cfg(test)] + fn from_backend(backend: Box) -> Self { + let state = backend.state().map_err(|error| error.to_string()); + Self { + backend, + state, + error: None, + } + } } /// Ephemeral editor state. Draft text is never written until the outer @@ -1209,6 +1265,42 @@ impl SettingsApp { } } + fn render_startup(&mut self, ui: &mut egui::Ui) { + const LABEL: &str = "Start OpenWritr automatically when I sign in to Windows"; + match self.autostart.state.clone() { + Ok(AutostartState::DisabledByOs { reason }) => { + let mut checked = false; + ui.add_enabled_ui(false, |ui| { + ui.checkbox(&mut checked, LABEL); + }); + inline_error(ui, format!("Windows overrides this setting. {reason}")); + } + Ok(state) => { + let mut enabled = state.is_enabled(); + ui.horizontal_wrapped(|ui| { + let changed = ui.checkbox(&mut enabled, LABEL).changed(); + info_button( + ui, + "OpenWritr starts silently in the tray at sign-in — no window opens. This is OS state, not part of your settings file, so it is applied immediately.", + ); + if changed { + self.autostart.set_enabled(enabled); + } + }); + } + Err(error) => { + let mut checked = false; + ui.add_enabled_ui(false, |ui| { + ui.checkbox(&mut checked, LABEL); + }); + inline_error(ui, format!("Could not read the startup state: {error}")); + } + } + if let Some(error) = &self.autostart.error { + inline_error(ui, format!("Startup change failed: {error}")); + } + } + fn render_advanced(&mut self, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { ui.label("Stop recording automatically after"); @@ -1470,6 +1562,12 @@ impl eframe::App for SettingsApp { "Optionally send transcript text for punctuation and cleanup.", |ui| self.render_enhancement(ui), ); + section( + ui, + "Startup", + "Control whether OpenWritr runs automatically when you sign in.", + |ui| self.render_startup(ui), + ); section(ui, "Advanced", "Recording safety limits.", |ui| { self.render_advanced(ui) }); @@ -2011,4 +2109,66 @@ mod tests { assert!(insecure_remote_http("http://example.com/v1")); assert!(!insecure_remote_http("https://example.com/v1")); } + + struct FakeAutostart { + state: Mutex, + fail_writes: bool, + } + + impl AutostartBackend for FakeAutostart { + fn kind(&self) -> &'static str { + "fake" + } + fn state(&self) -> std::result::Result { + Ok(self.state.lock().clone()) + } + fn enable(&self) -> std::result::Result<(), autostart::AutostartError> { + if self.fail_writes { + return Err(autostart::AutostartError::VerificationFailed("enable")); + } + *self.state.lock() = AutostartState::Enabled; + Ok(()) + } + fn disable(&self) -> std::result::Result<(), autostart::AutostartError> { + if self.fail_writes { + return Err(autostart::AutostartError::VerificationFailed("disable")); + } + *self.state.lock() = AutostartState::Disabled; + Ok(()) + } + } + + #[test] + fn autostart_write_failure_surfaces_an_error_and_does_not_flip_state() { + let backend = Box::new(FakeAutostart { + state: Mutex::new(AutostartState::Disabled), + fail_writes: true, + }); + let mut ui = AutostartUi::from_backend(backend); + + ui.set_enabled(true); + + assert!( + ui.error.is_some(), + "a failed write must surface an error, not silently succeed" + ); + assert_eq!(ui.state.as_ref().unwrap(), &AutostartState::Disabled); + } + + #[test] + fn autostart_successful_toggle_updates_state_without_error() { + let backend = Box::new(FakeAutostart { + state: Mutex::new(AutostartState::Disabled), + fail_writes: false, + }); + let mut ui = AutostartUi::from_backend(backend); + + ui.set_enabled(true); + assert!(ui.error.is_none()); + assert_eq!(ui.state.as_ref().unwrap(), &AutostartState::Enabled); + + ui.set_enabled(false); + assert!(ui.error.is_none()); + assert_eq!(ui.state.as_ref().unwrap(), &AutostartState::Disabled); + } }