Skip to main content

smriti/services/
install_method.rs

1//! Detect *how* Smriti was installed, so the update UI can take
2//! the right action when the user clicks "Download".
3//!
4//! We deliberately avoid self-replacing package-manager-managed
5//! binaries (apt, Homebrew, winget, Flatpak, Snap) — that path is
6//! the package manager's job, and stomping on it would confuse the
7//! system. Instead, for those users the update banner shows the
8//! matching upgrade command.
9//!
10//! Detection is path-based because it's cheap and robust. False
11//! negatives map to `Unknown`, which the update handler turns into
12//! an "open release page in browser" fallback, so a wrong answer
13//! here fails safely rather than corrupting an install.
14
15use std::path::Path;
16#[cfg(target_os = "linux")]
17use std::path::PathBuf;
18
19/// How Smriti was installed on this machine.
20///
21/// Several variants are only constructed on specific target OSes
22/// (`MsiInstaller` on Windows, `MacOsApp` on macOS); allow dead-code
23/// at the type level so non-target-OS builds don't fail `-D warnings`.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[allow(dead_code)]
26pub enum InstallMethod {
27    /// Self-contained binary — AppImage on Linux, portable zip on
28    /// Windows, or the tar.gz unpacked anywhere. `self_replace` can
29    /// atomically swap the running binary with a newly-downloaded one.
30    Portable,
31
32    /// Installed via a Windows MSI. The self-replace path invokes
33    /// msiexec on the newly-downloaded MSI with a UAC prompt.
34    MsiInstaller,
35
36    /// macOS `.app` bundle in /Applications. The self-replace path
37    /// downloads the new .dmg and opens it so the user can drag the
38    /// new bundle in. Automating the copy is fragile under Gatekeeper
39    /// + SIP, especially before notarization is set up.
40    MacOsApp,
41
42    /// System package manager owns the binary. We show the matching
43    /// upgrade command in the banner instead of downloading.
44    PackageManager { name: String, upgrade_cmd: String },
45
46    /// Running a `cargo build` binary from the source tree. Show a
47    /// `git pull && cargo build --release` hint.
48    SourceBuild,
49
50    /// Couldn't tell. Falls back to opening the release page in the
51    /// user's browser.
52    Unknown,
53}
54
55/// Detect the install method for the currently-running executable.
56/// Uses `std::env::current_exe` internally.
57pub fn detect() -> InstallMethod {
58    let exe = match std::env::current_exe() {
59        Ok(p) => p,
60        Err(_) => return InstallMethod::Unknown,
61    };
62    classify_path(&exe)
63}
64
65/// Pure, testable version of the detection heuristic.
66#[allow(clippy::needless_return)]
67pub(crate) fn classify_path(exe: &Path) -> InstallMethod {
68    let path_str = exe.to_string_lossy();
69    let lower = path_str.to_lowercase();
70
71    // --- source build is checked first so `target/release/smriti`
72    //     on a Windows box doesn't get mis-bucketed as portable.
73    if lower.contains("/target/debug/") || lower.contains("/target/release/") {
74        return InstallMethod::SourceBuild;
75    }
76    if lower.contains("\\target\\debug\\") || lower.contains("\\target\\release\\") {
77        return InstallMethod::SourceBuild;
78    }
79
80    // --- Linux ----------------------------------------------------
81    #[cfg(target_os = "linux")]
82    {
83        if lower.ends_with(".appimage") || lower.contains("/squashfs-root/") {
84            return InstallMethod::Portable;
85        }
86        if lower.contains("/var/lib/flatpak/") || lower.starts_with("/app/bin/") {
87            return InstallMethod::PackageManager {
88                name: "flatpak".into(),
89                upgrade_cmd: "flatpak update in.smriti.app".into(),
90            };
91        }
92        if lower.starts_with("/snap/") {
93            return InstallMethod::PackageManager {
94                name: "snap".into(),
95                upgrade_cmd: "snap refresh smriti".into(),
96            };
97        }
98        // Default apt/.deb layout: binary at /usr/bin, resources under /usr/lib/smriti.
99        if path_str == "/usr/bin/smriti" && PathBuf::from("/usr/lib/smriti").exists() {
100            return InstallMethod::PackageManager {
101                name: "apt".into(),
102                upgrade_cmd: "sudo apt update && sudo apt install --only-upgrade smriti".into(),
103            };
104        }
105    }
106
107    // --- macOS ----------------------------------------------------
108    #[cfg(target_os = "macos")]
109    {
110        if lower.contains("/opt/homebrew/") || lower.contains("/usr/local/cellar/") {
111            return InstallMethod::PackageManager {
112                name: "brew".into(),
113                upgrade_cmd: "brew update && brew upgrade --cask smriti".into(),
114            };
115        }
116        if lower.contains(".app/contents/macos/")
117            && (lower.contains("/applications/") || lower.contains("/users/"))
118        {
119            return InstallMethod::MacOsApp;
120        }
121    }
122
123    // --- Windows --------------------------------------------------
124    #[cfg(target_os = "windows")]
125    {
126        // MSI-installed binaries typically land under Program Files.
127        if lower.contains("\\program files\\") || lower.contains("\\program files (x86)\\") {
128            return InstallMethod::MsiInstaller;
129        }
130        // Microsoft Store (MSIX) installs live under WindowsApps.
131        if lower.contains("\\windowsapps\\") {
132            return InstallMethod::PackageManager {
133                name: "msstore".into(),
134                upgrade_cmd: "Microsoft Store -> Updates".into(),
135            };
136        }
137        // Scoop drops binaries under the user's scoop root.
138        if lower.contains("\\scoop\\apps\\") {
139            return InstallMethod::PackageManager {
140                name: "scoop".into(),
141                upgrade_cmd: "scoop update smriti".into(),
142            };
143        }
144        // Everything else on Windows: portable zip extracted somewhere.
145        return InstallMethod::Portable;
146    }
147
148    // Cross-platform fallback: any .exe that wasn't caught above, or
149    // an unrecognized Linux layout, or an unrecognized macOS bundle.
150    #[cfg(not(target_os = "windows"))]
151    {
152        InstallMethod::Unknown
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::path::PathBuf;
160
161    #[test]
162    fn source_build_detected_unix() {
163        let p = PathBuf::from("/home/user/code/smriti/target/release/smriti");
164        assert_eq!(classify_path(&p), InstallMethod::SourceBuild);
165    }
166
167    #[test]
168    fn source_build_detected_debug() {
169        let p = PathBuf::from("/home/user/code/smriti/target/debug/smriti");
170        assert_eq!(classify_path(&p), InstallMethod::SourceBuild);
171    }
172
173    #[test]
174    fn source_build_detected_windows() {
175        let p = PathBuf::from("C:\\Users\\foo\\smriti\\target\\release\\smriti.exe");
176        assert_eq!(classify_path(&p), InstallMethod::SourceBuild);
177    }
178
179    #[cfg(target_os = "linux")]
180    #[test]
181    fn appimage_detected() {
182        let p = PathBuf::from("/home/user/Applications/Smriti-x86_64.AppImage");
183        assert_eq!(classify_path(&p), InstallMethod::Portable);
184    }
185
186    #[cfg(target_os = "linux")]
187    #[test]
188    fn flatpak_detected() {
189        let p = PathBuf::from(
190            "/var/lib/flatpak/app/in.smriti.app/x86_64/stable/current/files/bin/smriti",
191        );
192        match classify_path(&p) {
193            InstallMethod::PackageManager { name, .. } => assert_eq!(name, "flatpak"),
194            other => panic!("expected flatpak, got {:?}", other),
195        }
196    }
197
198    #[cfg(target_os = "linux")]
199    #[test]
200    fn snap_detected() {
201        let p = PathBuf::from("/snap/smriti/current/usr/bin/smriti");
202        match classify_path(&p) {
203            InstallMethod::PackageManager { name, .. } => assert_eq!(name, "snap"),
204            other => panic!("expected snap, got {:?}", other),
205        }
206    }
207
208    #[cfg(target_os = "windows")]
209    #[test]
210    fn msi_installer_detected() {
211        let p = PathBuf::from("C:\\Program Files\\Smriti\\smriti.exe");
212        assert_eq!(classify_path(&p), InstallMethod::MsiInstaller);
213    }
214
215    #[cfg(target_os = "windows")]
216    #[test]
217    fn scoop_detected() {
218        let p = PathBuf::from("C:\\Users\\user\\scoop\\apps\\smriti\\current\\smriti.exe");
219        match classify_path(&p) {
220            InstallMethod::PackageManager { name, .. } => assert_eq!(name, "scoop"),
221            other => panic!("expected scoop, got {:?}", other),
222        }
223    }
224
225    #[cfg(target_os = "macos")]
226    #[test]
227    fn macos_app_bundle_detected() {
228        let p = PathBuf::from("/Applications/Smriti.app/Contents/MacOS/smriti");
229        assert_eq!(classify_path(&p), InstallMethod::MacOsApp);
230    }
231
232    #[cfg(target_os = "macos")]
233    #[test]
234    fn homebrew_cask_detected() {
235        let p = PathBuf::from("/opt/homebrew/Caskroom/smriti/1.0.0/smriti");
236        match classify_path(&p) {
237            InstallMethod::PackageManager { name, .. } => assert_eq!(name, "brew"),
238            other => panic!("expected brew, got {:?}", other),
239        }
240    }
241}