Skip to main content

rightkit_tauri_shell/
autostart.rs

1//! Launch at login. Base: CodeRight `os_shell/autostart` (Linux XDG `.desktop`, Windows HKCU Run
2//! through `reg.exe` with no console), plus the macOS LaunchAgent the donor lacked (HeardRight's
3//! `login_launch` covered it only for its own bundle). The login launch receives
4//! [`AutostartOptions::args`] (default `--background`), so it never starts a second engine.
5//!
6//! Migration. Earlier builds used `tauri-plugin-autostart`, which `auto-launch` writes under the
7//! package name: `~/Library/LaunchAgents/<name>.plist` on macOS, the HKCU Run value `<name>` on
8//! Windows, and `~/.config/autostart/<name>.desktop` elsewhere. [`migrate_plugin_entry`] enables the
9//! shell's entry and removes that one, so an upgraded user never has two login items.
10//!
11//! The exact legacy names are [`LegacyAutostartNames`]. Read from `tauri-plugin-autostart` 2.5.1
12//! (`src/lib.rs`), `auto-launch` 0.5.0 (`src/{windows,macos,linux}.rs`) and `tauri-codegen` 2.6.3
13//! (`src/context.rs`), the versions HeardRight's lockfile pins for the first two. The entry name is
14//! the plugin's `app_name` if the app set one, otherwise `PackageInfo::name`, which Tauri fills with
15//! `tauri.conf.json` `productName` when present and the Cargo package name otherwise. For HeardRight
16//! (`productName` `HeardRight`) that is `HeardRight`: the Windows Run value `HeardRight`, the macOS
17//! LaunchAgent label `HeardRight` at `~/Library/LaunchAgents/HeardRight.plist`.
18
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22#[cfg(not(windows))]
23use std::fs;
24
25use crate::login_item::{self, LoginItemStatus};
26
27pub const BACKGROUND_ARG: &str = "--background";
28
29/// macOS: how the login entry is written.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum MacosBackend {
32    /// A LaunchAgent plist under `~/Library/LaunchAgents` (the 0.3.0 behaviour; the default). Carries
33    /// [`AutostartOptions::args`] and `LowPriorityIO`, but macOS 13+ lists it as a legacy background item.
34    #[default]
35    LaunchAgent,
36    /// `SMAppService.mainApp` (macOS 13+, see [`crate::login_item`]): a named item in System Settings >
37    /// General > Login Items, with a `requiresApproval` state. It needs a signed `.app` bundle and
38    /// carries no launch arguments and no `LowPriorityIO` (the app detects a login launch itself and
39    /// calls [`crate::login_item::set_background_io`]), so [`AutostartOptions::args`] and
40    /// [`AutostartOptions::low_priority_io`] are ignored. Where `SMAppService` is not usable (macOS
41    /// 12 or older, an executable outside a bundle, or a redirected `RIGHTKIT_SHELL_HOME` /
42    /// [`AutostartOptions::config_home`]) the shell falls back to the LaunchAgent. Enabling or
43    /// disabling removes the shell's old LaunchAgent plist, and [`migrate_launch_agent`] moves an
44    /// existing one over at startup.
45    ServiceManagement,
46}
47
48/// How the login entry launches the app.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AutostartOptions {
51    /// Arguments the login launch receives. Default: `["--background"]`.
52    pub args: Vec<String>,
53    /// macOS LaunchAgent `LowPriorityIO`: yields the disk during the login storm (Spotlight,
54    /// Gatekeeper). Ignored elsewhere. Default: off.
55    pub low_priority_io: bool,
56    /// Remove the entry `tauri-plugin-autostart` wrote, whenever this shell enables or disables login
57    /// launch, so an upgraded user never has two login items. Default: off.
58    pub migrate_from_plugin_entry: bool,
59    /// Linux: written as `StartupWMClass` in the desktop entry so the shell matches the app's window
60    /// class. `None` (the default) writes no such key. A value with control characters is ignored.
61    pub startup_wm_class: Option<String>,
62    /// Where the per-user config lives, instead of the environment (`RIGHTKIT_SHELL_HOME`,
63    /// `XDG_CONFIG_HOME`, `HOME`). Linux: the XDG config directory, so the entry is
64    /// `<config_home>/autostart/<id>.desktop`. macOS: the home directory, so the entry is
65    /// `<config_home>/Library/LaunchAgents/<id>.autostart.plist`. For tests and sandboxes; `None`
66    /// (the default) keeps the environment lookup. Ignored on Windows.
67    pub config_home: Option<PathBuf>,
68    /// macOS: report login launch as unsupported and create no LaunchAgent, for an app that handles
69    /// login items itself. Ignored elsewhere. Default: off.
70    pub macos_unsupported: bool,
71    /// macOS: LaunchAgent plist (default) or `SMAppService.mainApp`. Ignored elsewhere.
72    pub macos_backend: MacosBackend,
73}
74
75/// True when `options` make login launch unsupported on the platform (`target_macos`).
76pub fn reports_unsupported(options: &AutostartOptions, target_macos: bool) -> bool {
77    target_macos && options.macos_unsupported
78}
79
80impl Default for AutostartOptions {
81    fn default() -> Self {
82        Self {
83            args: vec![BACKGROUND_ARG.to_owned()],
84            low_priority_io: false,
85            migrate_from_plugin_entry: false,
86            startup_wm_class: None,
87            config_home: None,
88            macos_unsupported: false,
89            macos_backend: MacosBackend::LaunchAgent,
90        }
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct AutostartStatus {
97    pub supported: bool,
98    pub enabled: bool,
99    /// Plist / desktop-entry path (None on Windows where the entry is a registry value).
100    pub entry: Option<String>,
101    /// macOS `SMAppService` state when that backend is in use (`None` for every other backend). When it
102    /// is `requiresApproval`, `enabled` is false and the app should offer
103    /// [`crate::login_item::open_login_items_settings`].
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub login_item: Option<LoginItemStatus>,
106}
107
108pub struct AutostartSpec {
109    /// Reverse-DNS or short id; the LaunchAgent label and the registry value name.
110    pub app_id: String,
111    pub app_name: String,
112    pub executable: PathBuf,
113    pub options: AutostartOptions,
114    /// The name `tauri-plugin-autostart` keyed its entry by. Set (by the shell, from the package name)
115    /// only when [`AutostartOptions::migrate_from_plugin_entry`] is on.
116    pub legacy_plugin_name: Option<String>,
117}
118
119/// What [`migrate_plugin_entry`] did.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum AutostartMigration {
123    /// No `tauri-plugin-autostart` entry exists.
124    NoLegacy,
125    /// The legacy entry was replaced by the shell's entry and removed.
126    Migrated,
127}
128
129/// The entries `tauri-plugin-autostart` writes for one entry name (see the module docs for where the
130/// name comes from). Pure derivation, identical on every host.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct LegacyAutostartNames {
133    /// Windows: the value name under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, verbatim.
134    /// The plugin also writes a same-named value under
135    /// `HKCU\...\Explorer\StartupApproved\Run` (Task Manager's enabled flag), which the migration
136    /// leaves in place: it is inert once the Run value is gone.
137    pub windows_run_value: String,
138    /// macOS (LaunchAgent launcher, the default): the plist `Label`, verbatim.
139    pub macos_launch_agent_label: String,
140    /// macOS: the plist path relative to the user's home, `Library/LaunchAgents/<name>.plist`.
141    pub macos_plist_relative: PathBuf,
142    /// Linux: the desktop entry relative to the user's home, `.config/autostart/<name>.desktop`
143    /// (the plugin does not honour `XDG_CONFIG_HOME`).
144    pub linux_desktop_relative: PathBuf,
145}
146
147impl LegacyAutostartNames {
148    /// Derive the entries for the plugin's entry name.
149    pub fn for_entry_name(name: &str) -> Self {
150        Self {
151            windows_run_value: name.to_owned(),
152            macos_launch_agent_label: name.to_owned(),
153            macos_plist_relative: PathBuf::from("Library/LaunchAgents")
154                .join(format!("{name}.plist")),
155            linux_desktop_relative: PathBuf::from(".config/autostart")
156                .join(format!("{name}.desktop")),
157        }
158    }
159}
160
161fn xml(s: &str) -> String {
162    s.replace('&', "&amp;")
163        .replace('<', "&lt;")
164        .replace('>', "&gt;")
165        .replace('"', "&quot;")
166}
167
168/// Program arguments for a macOS login item with the default `--background` argument.
169pub fn macos_program_arguments(exe: &Path) -> Vec<String> {
170    macos_program_arguments_with(exe, &[BACKGROUND_ARG.to_owned()])
171}
172
173/// Program arguments for a macOS login item. Inside a `.app` bundle the bundle is launched through
174/// LaunchServices (`open -g -a <bundle> --args ...`) so it behaves like a Dock launch.
175pub fn macos_program_arguments_with(exe: &Path, args: &[String]) -> Vec<String> {
176    let s = exe.to_string_lossy();
177    let mut out: Vec<String>;
178    let in_bundle = s.find(".app/Contents/MacOS/");
179    if let Some(idx) = in_bundle {
180        let bundle = &s[..idx + 4];
181        out = vec![
182            "/usr/bin/open".into(),
183            "-g".into(),
184            "-a".into(),
185            bundle.into(),
186        ];
187        if !args.is_empty() {
188            out.push("--args".into());
189        }
190    } else {
191        out = vec![s.into_owned()];
192    }
193    out.extend(args.iter().cloned());
194    out
195}
196
197pub fn macos_plist(label: &str, args: &[String]) -> String {
198    macos_plist_with(label, args, false)
199}
200
201/// A LaunchAgent plist. `low_priority_io` adds `LowPriorityIO`.
202pub fn macos_plist_with(label: &str, args: &[String], low_priority_io: bool) -> String {
203    let items: String = args
204        .iter()
205        .map(|a| format!("    <string>{}</string>\n", xml(a)))
206        .collect();
207    let low = if low_priority_io {
208        "  <key>LowPriorityIO</key>\n  <true/>\n"
209    } else {
210        ""
211    };
212    format!(
213        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key>\n  <string>{}</string>\n  <key>ProgramArguments</key>\n  <array>\n{items}  </array>\n  <key>RunAtLoad</key>\n  <true/>\n  <key>ProcessType</key>\n  <string>Interactive</string>\n{low}</dict>\n</plist>\n",
214        xml(label)
215    )
216}
217
218fn desktop_exec_quote(v: &str) -> String {
219    if !v
220        .chars()
221        .any(|c| matches!(c, ' ' | '\t' | '\n' | '"' | '$' | '`' | '\\'))
222    {
223        return v.to_owned();
224    }
225    let mut out = String::from("\"");
226    for c in v.chars() {
227        if matches!(c, '"' | '$' | '`' | '\\') {
228            out.push('\\');
229        }
230        out.push(c);
231    }
232    out.push('"');
233    out
234}
235
236pub fn linux_desktop_entry(name: &str, exe: &Path) -> String {
237    linux_desktop_entry_with(name, exe, &[BACKGROUND_ARG.to_owned()])
238}
239
240/// A desktop entry whose `Exec` line carries `args`.
241pub fn linux_desktop_entry_with(name: &str, exe: &Path, args: &[String]) -> String {
242    linux_desktop_entry_for(name, exe, args, None)
243}
244
245/// [`linux_desktop_entry_with`] plus `StartupWMClass=<wm_class>` when it is a usable single-line
246/// value (blank values and values with control characters write no key).
247pub fn linux_desktop_entry_for(
248    name: &str,
249    exe: &Path,
250    args: &[String],
251    wm_class: Option<&str>,
252) -> String {
253    // Control characters are refused on the raw value: trimming first would silently drop a
254    // trailing CR or LF and write a key the caller did not validate.
255    let class = wm_class
256        .filter(|c| !c.chars().any(char::is_control))
257        .map(str::trim)
258        .filter(|c| !c.is_empty())
259        .map(|c| format!("StartupWMClass={c}\n"))
260        .unwrap_or_default();
261    let mut exec = desktop_exec_quote(&exe.to_string_lossy());
262    for arg in args {
263        exec.push(' ');
264        exec.push_str(&desktop_exec_quote(arg));
265    }
266    format!("[Desktop Entry]\nType=Application\nName={name}\nExec={exec}\nTerminal=false\n{class}X-GNOME-Autostart-enabled=true\n")
267}
268
269/// The Windows Run command: the quoted executable, then each argument (quoted when it has spaces).
270pub fn windows_run_command(exe: &Path, args: &[String]) -> String {
271    let mut command = format!("\"{}\"", exe.display());
272    for arg in args {
273        command.push(' ');
274        if arg.contains(' ') || arg.contains('"') {
275            command.push('"');
276            command.push_str(&arg.replace('"', "\\\""));
277            command.push('"');
278        } else {
279            command.push_str(arg);
280        }
281    }
282    command
283}
284
285/// Home directory for login entries. `RIGHTKIT_SHELL_HOME` redirects them (isolated QA runs and
286/// sandboxes must never write the real user's LaunchAgents / autostart / config).
287// Used by the macOS (LaunchAgents) and other-Unix (XDG autostart) paths; Windows uses the registry.
288#[cfg_attr(windows, allow(dead_code))]
289fn home() -> Option<PathBuf> {
290    std::env::var_os("RIGHTKIT_SHELL_HOME")
291        .filter(|v| !v.is_empty())
292        .or_else(|| std::env::var_os("HOME"))
293        .or_else(|| std::env::var_os("USERPROFILE"))
294        .map(PathBuf::from)
295}
296
297/// The login-item file for `stem` on this platform (None on Windows, where entries are registry
298/// values).
299#[cfg(not(windows))]
300fn login_item_path(stem: &str, config_home: Option<&Path>) -> Option<PathBuf> {
301    #[cfg(target_os = "macos")]
302    {
303        config_home
304            .map(Path::to_path_buf)
305            .or_else(home)
306            .map(|h| h.join("Library/LaunchAgents").join(format!("{stem}.plist")))
307    }
308    #[cfg(all(unix, not(target_os = "macos")))]
309    {
310        if let Some(cfg) = config_home {
311            return Some(cfg.join("autostart").join(format!("{stem}.desktop")));
312        }
313        let cfg = std::env::var_os("XDG_CONFIG_HOME")
314            .filter(|v| !v.is_empty())
315            .map(PathBuf::from)
316            .or_else(|| home().map(|h| h.join(".config")))?;
317        Some(cfg.join("autostart").join(format!("{stem}.desktop")))
318    }
319}
320
321/// Where `tauri-plugin-autostart` put its login item for `stem` (None on Windows).
322#[cfg(not(windows))]
323fn legacy_login_item_path(stem: &str, config_home: Option<&Path>) -> Option<PathBuf> {
324    let names = LegacyAutostartNames::for_entry_name(stem);
325    #[cfg(target_os = "macos")]
326    {
327        config_home
328            .map(Path::to_path_buf)
329            .or_else(home)
330            .map(|h| h.join(names.macos_plist_relative))
331    }
332    #[cfg(not(target_os = "macos"))]
333    {
334        match config_home {
335            // The injected directory is the config directory itself: `autostart/<name>.desktop`.
336            Some(cfg) => Some(cfg.join("autostart").join(format!("{stem}.desktop"))),
337            None => home().map(|h| h.join(names.linux_desktop_relative)),
338        }
339    }
340}
341
342/// The stem of the shell's own entry: `<app_id>.autostart` on macOS (the LaunchAgent label), the
343/// app id elsewhere.
344// Not used by the Windows backend.
345#[cfg_attr(not(unix), allow(dead_code))]
346fn our_stem(spec: &AutostartSpec) -> String {
347    #[cfg(target_os = "macos")]
348    {
349        format!("{}.autostart", spec.app_id)
350    }
351    #[cfg(not(target_os = "macos"))]
352    {
353        spec.app_id.clone()
354    }
355}
356
357/// Where the shell's login entry lives on this platform (None on Windows).
358pub fn entry_path(spec: &AutostartSpec) -> Option<PathBuf> {
359    #[cfg(windows)]
360    {
361        let _ = spec;
362        None
363    }
364    #[cfg(not(windows))]
365    {
366        login_item_path(&our_stem(spec), spec.options.config_home.as_deref())
367    }
368}
369
370#[cfg(windows)]
371const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
372#[cfg(windows)]
373const STARTUP_APPROVED_KEY: &str =
374    r"HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
375
376/// The two registry keys Windows login items live under. Only [`migrate_plugin_entry_in`] takes
377/// them, so tests can run the migration against a scratch key; everything else uses the real keys.
378/// Ignored off Windows.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct RegistryKeys {
381    /// The `Run` key holding the command (`HKCU\...\CurrentVersion\Run`).
382    pub run: String,
383    /// The `StartupApproved\Run` key holding Task Manager's enabled or disabled flag per value name
384    /// (a 12-byte `REG_BINARY`; `tauri-plugin-autostart` writes it as well as the Run value).
385    pub startup_approved: String,
386}
387
388#[allow(clippy::derivable_impls)] // the Windows default is the real keys
389impl Default for RegistryKeys {
390    fn default() -> Self {
391        #[cfg(windows)]
392        {
393            Self {
394                run: RUN_KEY.to_owned(),
395                startup_approved: STARTUP_APPROVED_KEY.to_owned(),
396            }
397        }
398        #[cfg(not(windows))]
399        {
400            Self {
401                run: String::new(),
402                startup_approved: String::new(),
403            }
404        }
405    }
406}
407
408/// True when a `StartupApproved` value says the user disabled the item in Task Manager. The rule is
409/// `auto-launch` 0.5.0's own (`windows.rs`, `last_eight_bytes_all_zeros`): enabled when the last
410/// eight bytes are all zero (`02 00 00 00 ...`), disabled when they hold the disable timestamp
411/// (`03 00 00 00` plus a FILETIME). A value shorter than eight bytes is not a Task Manager record
412/// and counts as enabled.
413pub fn startup_approved_is_disabled(bytes: &[u8]) -> bool {
414    bytes.len() >= 8 && bytes.iter().rev().take(8).any(|b| *b != 0)
415}
416
417#[cfg(windows)]
418fn reg(args: &[&str]) -> Result<std::process::Output, String> {
419    use std::os::windows::process::CommandExt;
420    std::process::Command::new("reg.exe")
421        .creation_flags(0x0800_0000)
422        .args(args)
423        .output()
424        .map_err(|e| format!("run reg.exe: {e}"))
425}
426
427/// The bytes of a `REG_BINARY` value, or None when the key or value is absent or not binary.
428#[cfg(windows)]
429fn reg_read_binary(key: &str, name: &str) -> Option<Vec<u8>> {
430    let out = reg(&["QUERY", key, "/v", name]).ok()?;
431    if !out.status.success() {
432        return None;
433    }
434    let text = String::from_utf8_lossy(&out.stdout);
435    let line = text.lines().find(|l| l.contains("REG_BINARY"))?;
436    let hex = line.split("REG_BINARY").nth(1)?.trim();
437    if hex.len() % 2 != 0 {
438        return None;
439    }
440    (0..hex.len())
441        .step_by(2)
442        .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
443        .collect()
444}
445
446#[cfg(windows)]
447fn reg_write_binary(key: &str, name: &str, bytes: &[u8]) -> Result<(), String> {
448    let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
449    let out = reg(&["ADD", key, "/v", name, "/t", "REG_BINARY", "/d", &hex, "/f"])?;
450    if out.status.success() {
451        Ok(())
452    } else {
453        Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
454    }
455}
456
457/// True when this call goes through `SMAppService` (see [`MacosBackend::ServiceManagement`]).
458pub fn uses_service_management(spec: &AutostartSpec) -> bool {
459    cfg!(target_os = "macos")
460        && login_item::service_management_selected(
461            spec.options.macos_backend == MacosBackend::ServiceManagement,
462            login_item::available(),
463            login_item::executable_in_app_bundle(&spec.executable),
464            spec.options.config_home.is_some()
465                || std::env::var_os("RIGHTKIT_SHELL_HOME").is_some_and(|v| !v.is_empty()),
466        )
467}
468
469/// Enable or disable through `SMAppService`, then remove the shell's old LaunchAgent plist so the user
470/// never has two login items.
471fn set_enabled_service_management(
472    spec: &AutostartSpec,
473    enabled: bool,
474) -> Result<AutostartStatus, String> {
475    if enabled {
476        login_item::register()?;
477    } else {
478        login_item::unregister()?;
479    }
480    remove_entry(spec)?;
481    Ok(status(spec))
482}
483
484/// Move the shell's own LaunchAgent login item to `SMAppService`: when the backend is
485/// [`MacosBackend::ServiceManagement`], usable here, and the shell's plist exists, register the app and
486/// remove the plist. A `requiresApproval` result still counts as migrated (the item is registered,
487/// pending the user's approval; read it with [`status`]). The plist file is removed; a job launchd
488/// already loaded stops at the next login. Call it once at startup; the shell does when the backend
489/// is on.
490pub fn migrate_launch_agent(spec: &AutostartSpec) -> Result<AutostartMigration, String> {
491    if reports_unsupported(&spec.options, cfg!(target_os = "macos"))
492        || !uses_service_management(spec)
493    {
494        return Ok(AutostartMigration::NoLegacy);
495    }
496    let Some(path) = entry_path(spec) else {
497        return Ok(AutostartMigration::NoLegacy);
498    };
499    if !path.exists() {
500        return Ok(AutostartMigration::NoLegacy);
501    }
502    login_item::register()?;
503    remove_entry(spec)?;
504    Ok(AutostartMigration::Migrated)
505}
506
507pub fn status(spec: &AutostartSpec) -> AutostartStatus {
508    if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
509        return AutostartStatus {
510            supported: false,
511            enabled: false,
512            entry: None,
513            login_item: None,
514        };
515    }
516    if uses_service_management(spec) {
517        return match login_item::status() {
518            Ok(item) => AutostartStatus {
519                supported: true,
520                enabled: item.is_enabled(),
521                entry: None,
522                login_item: Some(item),
523            },
524            Err(_) => AutostartStatus {
525                supported: false,
526                enabled: false,
527                entry: None,
528                login_item: None,
529            },
530        };
531    }
532    #[cfg(windows)]
533    {
534        let enabled = reg(&["QUERY", RUN_KEY, "/v", &spec.app_id])
535            .map(|o| o.status.success())
536            .unwrap_or(false);
537        AutostartStatus {
538            supported: true,
539            enabled,
540            entry: None,
541            login_item: None,
542        }
543    }
544    #[cfg(not(windows))]
545    {
546        match entry_path(spec) {
547            Some(p) => AutostartStatus {
548                supported: true,
549                enabled: p.is_file(),
550                entry: Some(p.to_string_lossy().into_owned()),
551                login_item: None,
552            },
553            None => AutostartStatus {
554                supported: false,
555                enabled: false,
556                entry: None,
557                login_item: None,
558            },
559        }
560    }
561}
562
563/// Enable or disable login launch. With [`AutostartOptions::migrate_from_plugin_entry`] and a
564/// legacy name on the spec, the legacy entry is removed as well.
565pub fn set_enabled(spec: &AutostartSpec, enabled: bool) -> Result<AutostartStatus, String> {
566    if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
567        // Nothing was ever written, so turning it off has nothing to undo.
568        return if enabled {
569            Err("login launch is not supported by this app on macOS".into())
570        } else {
571            Ok(status(spec))
572        };
573    }
574    if uses_service_management(spec) {
575        set_enabled_service_management(spec, enabled)?;
576    } else if enabled {
577        write_entry(&RegistryKeys::default(), spec)?;
578    } else {
579        remove_entry(spec)?;
580    }
581    if spec.options.migrate_from_plugin_entry {
582        if let Some(legacy) = spec.legacy_plugin_name.as_deref() {
583            remove_legacy(&RegistryKeys::default(), spec, legacy)?;
584        }
585    }
586    Ok(status(spec))
587}
588
589/// One-time migration from `tauri-plugin-autostart`: when the legacy entry `legacy_name` exists,
590/// write the shell's entry with the configured options and remove the legacy one. The user's
591/// login-at-start choice is kept: a legacy entry means login launch was on.
592pub fn migrate_plugin_entry(
593    spec: &AutostartSpec,
594    legacy_name: &str,
595) -> Result<AutostartMigration, String> {
596    migrate_plugin_entry_in(&RegistryKeys::default(), spec, legacy_name)
597}
598
599/// [`migrate_plugin_entry`] against explicit registry keys.
600///
601/// On Windows the plugin wrote two values under `legacy_name`: the command under `Run`, and Task
602/// Manager's flag under `StartupApproved\Run`. Migration carries both. A legacy flag that says
603/// the user disabled the item is copied to the shell's value name, so a user who turned login launch
604/// off in Task Manager stays off; an enabled or absent flag changes nothing. The legacy flag value
605/// is then removed along with the legacy command (unless the shell's entry uses the same name, in
606/// which case the flag already applies to it).
607pub fn migrate_plugin_entry_in(
608    keys: &RegistryKeys,
609    spec: &AutostartSpec,
610    legacy_name: &str,
611) -> Result<AutostartMigration, String> {
612    if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
613        // This host declined macOS login items: write nothing, leave the legacy entry alone.
614        return Ok(AutostartMigration::NoLegacy);
615    }
616    if !legacy_present(keys, legacy_name, spec.options.config_home.as_deref()) {
617        return Ok(AutostartMigration::NoLegacy);
618    }
619    #[cfg(windows)]
620    let carried = reg_read_binary(&keys.startup_approved, legacy_name)
621        .filter(|bytes| startup_approved_is_disabled(bytes));
622    if uses_service_management(spec) {
623        login_item::register()?;
624        remove_entry(spec)?;
625    } else {
626        write_entry(keys, spec)?;
627    }
628    #[cfg(windows)]
629    if let Some(bytes) = carried {
630        reg_write_binary(&keys.startup_approved, &spec.app_id, &bytes)?;
631    }
632    remove_legacy(keys, spec, legacy_name)?;
633    Ok(AutostartMigration::Migrated)
634}
635
636fn write_entry(keys: &RegistryKeys, spec: &AutostartSpec) -> Result<(), String> {
637    #[cfg(not(windows))]
638    let _ = keys;
639    #[cfg(windows)]
640    {
641        let command = windows_run_command(&spec.executable, &spec.options.args);
642        let out = reg(&[
643            "ADD",
644            &keys.run,
645            "/v",
646            &spec.app_id,
647            "/t",
648            "REG_SZ",
649            "/d",
650            &command,
651            "/f",
652        ])?;
653        if !out.status.success() {
654            return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
655        }
656        Ok(())
657    }
658    #[cfg(not(windows))]
659    {
660        let Some(path) = entry_path(spec) else {
661            return Ok(());
662        };
663        if let Some(dir) = path.parent() {
664            fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
665        }
666        #[cfg(target_os = "macos")]
667        let body = macos_plist_with(
668            &our_stem(spec),
669            &macos_program_arguments_with(&spec.executable, &spec.options.args),
670            spec.options.low_priority_io,
671        );
672        #[cfg(not(target_os = "macos"))]
673        let body = linux_desktop_entry_for(
674            &spec.app_name,
675            &spec.executable,
676            &spec.options.args,
677            spec.options.startup_wm_class.as_deref(),
678        );
679        fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
680    }
681}
682
683fn remove_entry(spec: &AutostartSpec) -> Result<(), String> {
684    #[cfg(windows)]
685    {
686        // A missing value is already the desired state.
687        let _ = reg(&["DELETE", RUN_KEY, "/v", &spec.app_id, "/f"]);
688        Ok(())
689    }
690    #[cfg(not(windows))]
691    {
692        let Some(path) = entry_path(spec) else {
693            return Ok(());
694        };
695        match fs::remove_file(&path) {
696            Ok(()) => Ok(()),
697            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
698            Err(e) => Err(format!("remove {}: {e}", path.display())),
699        }
700    }
701}
702
703fn legacy_present(keys: &RegistryKeys, legacy_name: &str, config_home: Option<&Path>) -> bool {
704    #[cfg(not(windows))]
705    let _ = keys;
706    #[cfg(windows)]
707    let _ = config_home;
708    #[cfg(windows)]
709    {
710        reg(&["QUERY", &keys.run, "/v", legacy_name])
711            .map(|o| o.status.success())
712            .unwrap_or(false)
713    }
714    #[cfg(not(windows))]
715    {
716        legacy_login_item_path(legacy_name, config_home).is_some_and(|p| p.exists())
717    }
718}
719
720/// Remove the legacy entry, unless it is the same entry as the shell's own (then it was overwritten).
721fn remove_legacy(
722    keys: &RegistryKeys,
723    spec: &AutostartSpec,
724    legacy_name: &str,
725) -> Result<bool, String> {
726    #[cfg(not(windows))]
727    let _ = keys;
728    #[cfg(windows)]
729    {
730        if legacy_name == spec.app_id
731            || !legacy_present(keys, legacy_name, spec.options.config_home.as_deref())
732        {
733            return Ok(false);
734        }
735        let out = reg(&["DELETE", &keys.run, "/v", legacy_name, "/f"])?;
736        if !out.status.success() {
737            return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
738        }
739        // The flag is inert without the command; a missing flag is already the desired state.
740        let _ = reg(&["DELETE", &keys.startup_approved, "/v", legacy_name, "/f"]);
741        Ok(true)
742    }
743    #[cfg(not(windows))]
744    {
745        let Some(legacy) = legacy_login_item_path(legacy_name, spec.options.config_home.as_deref())
746        else {
747            return Ok(false);
748        };
749        if entry_path(spec).as_ref() == Some(&legacy) || !legacy.exists() {
750            return Ok(false);
751        }
752        fs::remove_file(&legacy).map_err(|e| format!("remove {}: {e}", legacy.display()))?;
753        Ok(true)
754    }
755}