super-stt-install/src/post_install.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Post-install steps run back in the unprivileged user process after the |
3 | | //! root phase has placed files: systemd service (re)start, applet panel |
4 | | //! restart, launcher-cache nudge, legacy `~/.local` cleanup, and the COSMIC |
5 | | //! keyboard-shortcut migrate/add. Every step is best-effort (`log::warn!` on |
6 | | //! failure) except the daemon restart, the one failure the app must hear |
7 | | //! about. |
8 | | //! |
9 | | //! `run`'s WHAT-to-do is a pure decision, separated from the HOW: [`plan`] |
10 | | //! takes the coarse facts already known (or cheap to check) before any file |
11 | | //! I/O or process spawn and returns an ordered [`Step`] list with no I/O of |
12 | | //! its own; `run` computes those inputs, calls `plan`, and executes the |
13 | | //! result through a thin per-step match. That seam is the whole decision |
14 | | //! tree's test coverage — see `plan`'s unit tests below — without ever |
15 | | //! needing a command-runner trait or a mocked OS. |
16 | | |
17 | | use std::path::Path; |
18 | | |
19 | | use crate::errors::InstallError; |
20 | | use crate::stage::Components; |
21 | | |
22 | | /// Run `bin` with `args`, discarding output, returning whether it exited 0. |
23 | | /// A spawn failure (binary missing, etc.) also counts as "not ok". |
24 | 0 | async fn cmd_ok(bin: &str, args: &[&str]) -> bool { |
25 | 0 | tokio::process::Command::new(bin) |
26 | 0 | .args(args) |
27 | 0 | .status() |
28 | 0 | .await |
29 | 0 | .is_ok_and(|s| s.success()) |
30 | 0 | } |
31 | | |
32 | | /// Whether `bin` resolves on the current `$PATH`. |
33 | 0 | fn on_path(bin: &str) -> bool { |
34 | 0 | let path_env = std::env::var("PATH").unwrap_or_default(); |
35 | 0 | crate::escalate::which(bin, &path_env).is_some() |
36 | 0 | } |
37 | | |
38 | | /// One post-install action, in the order [`plan`] returns them. Each variant |
39 | | /// is a single, independent shell-out or filesystem tweak — see `run`'s |
40 | | /// executor `match` for what each one actually does. |
41 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
42 | | pub enum Step { |
43 | | /// `systemctl --user daemon-reload`. |
44 | | DaemonReload, |
45 | | /// Remove a legacy `~/.config/systemd/user/super-stt.service` unit that |
46 | | /// would otherwise shadow the packaged one and keep launching the (now |
47 | | /// deleted) legacy `~/.local/bin` binary. |
48 | | RemoveLegacyUnit, |
49 | | /// `systemctl --user enable super-stt`. |
50 | | Enable, |
51 | | /// `systemctl --user restart|start super-stt` (verb picked at execution |
52 | | /// time, via `is-active`) — the one step whose failure is a hard error. |
53 | | RestartOrStart, |
54 | | /// Restart `cosmic-panel` so it picks up the just-updated applet binary. |
55 | | RestartPanel, |
56 | | /// Nudge COSMIC's launcher caches (app grid + search backend) to rescan |
57 | | /// desktop entries. |
58 | | NudgeLaunchers, |
59 | | /// Remove pre-`/usr/local` per-user leftovers (bins/desktop files/icons). |
60 | | CleanupLegacy, |
61 | | /// Rewrite a legacy `~/.local/bin/stt` shortcut reference, if present. |
62 | | MigrateShortcut, |
63 | | /// Interactively offer to add the `Super+Space` shortcut. |
64 | | PromptShortcut, |
65 | | } |
66 | | |
67 | | /// Decide which [`Step`]s to run, and in what order, from facts already known |
68 | | /// before any post-install I/O: `components` (what was just installed or |
69 | | /// updated), `applet_was_installed` (captured *before* the root phase ran — |
70 | | /// the script's `is_update` check), `interactive`, and three environment |
71 | | /// probes the caller (`run`) is expected to have already made: |
72 | | /// `systemctl_available`/`cosmic_available` (`$PATH` lookups) and |
73 | | /// `panel_running` (a `pgrep` check). `panel_running` only matters when |
74 | | /// `components.applet && applet_was_installed` — a caller may cheaply pass |
75 | | /// `true` unconditionally for any other combination and it's simply ignored. |
76 | | /// |
77 | | /// Order: systemd install/enable/restart, then the applet's panel restart, |
78 | | /// then the launcher-cache nudge, then legacy cleanup, then the COSMIC |
79 | | /// shortcut. Every file is already placed by the time this runs — that all |
80 | | /// happens earlier, in the root phase — so [`Step::CleanupLegacy`] is a |
81 | | /// single unconditional sweep rather than one pass per component; removing |
82 | | /// files that were never there is a no-op, so the sweep is idempotent |
83 | | /// whatever the selection. |
84 | | #[must_use] |
85 | | #[allow(clippy::fn_params_excessive_bools)] // interface fixed by the design doc: the five booleans are the planner's whole point |
86 | 30 | pub fn plan( |
87 | 30 | components: Components, |
88 | 30 | applet_was_installed: bool, |
89 | 30 | interactive: bool, |
90 | 30 | systemctl_available: bool, |
91 | 30 | cosmic_available: bool, |
92 | 30 | panel_running: bool, |
93 | 30 | ) -> Vec<Step> { |
94 | 30 | let mut steps = Vec::new(); |
95 | | |
96 | 30 | if components.daemon && systemctl_available16 { |
97 | 12 | steps.push(Step::DaemonReload); |
98 | 12 | steps.push(Step::RemoveLegacyUnit); |
99 | 12 | steps.push(Step::Enable); |
100 | 12 | steps.push(Step::RestartOrStart); |
101 | 18 | } |
102 | | |
103 | 30 | if components.applet && applet_was_installed12 && panel_running10 { |
104 | 8 | steps.push(Step::RestartPanel); |
105 | 22 | } |
106 | | |
107 | | // The launcher nudge is skipped only for a daemon-only install: an app |
108 | | // or applet install both add launcher-visible entries that benefit from |
109 | | // the rescan. |
110 | 30 | let daemon_only = components.daemon && !components.app16 && !components.applet10 ; |
111 | 30 | if !daemon_only { |
112 | 20 | steps.push(Step::NudgeLaunchers); |
113 | 20 | }10 |
114 | | |
115 | 30 | steps.push(Step::CleanupLegacy); |
116 | | |
117 | 30 | if components.daemon && cosmic_available16 { |
118 | 12 | steps.push(Step::MigrateShortcut); |
119 | 12 | if interactive { |
120 | 6 | steps.push(Step::PromptShortcut); |
121 | 6 | } |
122 | 18 | } |
123 | | |
124 | 30 | steps |
125 | 30 | } |
126 | | |
127 | 0 | async fn step_daemon_reload() { |
128 | 0 | if !cmd_ok("systemctl", &["--user", "daemon-reload"]).await { |
129 | 0 | log::warn!("systemctl --user daemon-reload failed"); |
130 | 0 | } |
131 | 0 | } |
132 | | |
133 | | /// A unit left in `~/.config/systemd/user` by an older install takes |
134 | | /// precedence over the packaged one — remove it or systemd keeps launching |
135 | | /// the (now deleted) legacy `~/.local/bin` binary. |
136 | 0 | fn step_remove_legacy_unit() { |
137 | 0 | if let Some(home) = dirs::home_dir() { |
138 | 0 | let legacy_unit = home.join(".config/systemd/user/super-stt.service"); |
139 | 0 | if legacy_unit.exists() { |
140 | 0 | let _ = std::fs::remove_file(&legacy_unit); |
141 | 0 | } |
142 | 0 | } |
143 | 0 | } |
144 | | |
145 | 0 | async fn step_enable() { |
146 | 0 | if !cmd_ok("systemctl", &["--user", "enable", "super-stt"]).await { |
147 | 0 | log::warn!("systemctl --user enable super-stt failed"); |
148 | 0 | } |
149 | 0 | } |
150 | | |
151 | | /// # Errors |
152 | | /// [`InstallError::PostInstallFailed`] when the final `restart`/`start` |
153 | | /// exits nonzero — the only step in the whole post-install sequence whose |
154 | | /// failure is a hard error. |
155 | 0 | async fn step_restart_or_start() -> Result<(), InstallError> { |
156 | 0 | let is_active = cmd_ok( |
157 | 0 | "systemctl", |
158 | 0 | &["--user", "is-active", "--quiet", "super-stt"], |
159 | 0 | ) |
160 | 0 | .await; |
161 | 0 | let verb = if is_active { "restart" } else { "start" }; |
162 | 0 | if cmd_ok("systemctl", &["--user", verb, "super-stt"]).await { |
163 | 0 | Ok(()) |
164 | | } else { |
165 | 0 | Err(InstallError::PostInstallFailed(format!( |
166 | 0 | "daemon {verb} failed: run `systemctl --user {verb} super-stt` manually" |
167 | 0 | ))) |
168 | | } |
169 | 0 | } |
170 | | |
171 | 0 | async fn step_restart_panel() { |
172 | 0 | if !cmd_ok("pkill", &["-f", "cosmic-panel"]).await { |
173 | 0 | log::warn!("failed to restart cosmic-panel to load the updated applet"); |
174 | 0 | } |
175 | 0 | } |
176 | | |
177 | | /// Nudge COSMIC's launcher caches (app grid + search backend): both scan |
178 | | /// desktop entries at session start and miss entries added to a directory |
179 | | /// they weren't watching. They respawn on demand and rescan. |
180 | 0 | async fn step_nudge_launchers() { |
181 | 0 | let _ = tokio::process::Command::new("pkill") |
182 | 0 | .args(["-f", "^cosmic-app-library$"]) |
183 | 0 | .status() |
184 | 0 | .await; |
185 | 0 | let _ = tokio::process::Command::new("pkill") |
186 | 0 | .args(["-f", "^cosmic-launcher$"]) |
187 | 0 | .status() |
188 | 0 | .await; |
189 | 0 | let _ = tokio::process::Command::new("pkill") |
190 | 0 | .args(["-f", "^pop-launcher( |$)"]) |
191 | 0 | .status() |
192 | 0 | .await; |
193 | 0 | } |
194 | | |
195 | | /// Bins, desktop files, and icons the pre-`/usr/local` per-user install left |
196 | | /// behind — cleared so they don't shadow (or duplicate in launchers) the |
197 | | /// fresh install. |
198 | 0 | fn cleanup_legacy() { |
199 | 0 | let Some(home) = dirs::home_dir() else { |
200 | 0 | return; |
201 | | }; |
202 | | |
203 | 0 | let bin_dir = home.join(".local/bin"); |
204 | 0 | for name in [ |
205 | 0 | "super-stt", |
206 | 0 | "super-stt-daemon", |
207 | 0 | "super-stt-cli", |
208 | 0 | "super-stt-consent", |
209 | 0 | "stt", |
210 | 0 | "super-stt-app", |
211 | 0 | "super-stt-cosmic-applet", |
212 | 0 | "super-stt-applet-full", |
213 | 0 | "super-stt-applet-left", |
214 | 0 | "super-stt-applet-right", |
215 | 0 | ] { |
216 | 0 | let _ = std::fs::remove_file(bin_dir.join(name)); |
217 | 0 | } |
218 | | |
219 | 0 | let apps_dir = home.join(".local/share/applications"); |
220 | 0 | for name in [ |
221 | 0 | "super-stt-app.desktop", |
222 | 0 | "super-stt-cosmic-applet-full.desktop", |
223 | 0 | "super-stt-cosmic-applet-left.desktop", |
224 | 0 | "super-stt-cosmic-applet-right.desktop", |
225 | 0 | ] { |
226 | 0 | let _ = std::fs::remove_file(apps_dir.join(name)); |
227 | 0 | } |
228 | | |
229 | 0 | let icons_dir = home.join(".local/share/icons"); |
230 | 0 | let _ = std::fs::remove_file(icons_dir.join("super-stt-app.svg")); |
231 | 0 | let _ = std::fs::remove_file(icons_dir.join("hicolor/scalable/apps/super-stt-app.svg")); |
232 | | let _ = |
233 | 0 | std::fs::remove_file(icons_dir.join("hicolor/scalable/apps/super-stt-cosmic-applet.svg")); |
234 | | |
235 | 0 | let _ = std::fs::remove_file(home.join(".local/share/metainfo/super-stt-app.metainfo.xml")); |
236 | 0 | } |
237 | | |
238 | | /// Rewrite a legacy `<home>/.local/bin/stt ` shortcut command to |
239 | | /// `<prefix>/bin/stt `, since the wrapper no longer lives in the removed |
240 | | /// per-user layout. Returns `None` when `content` doesn't reference the |
241 | | /// legacy path (nothing to migrate). |
242 | | #[must_use] |
243 | 4 | pub fn migrate_shortcut_content(content: &str, home: &str, prefix: &str) -> Option<String> { |
244 | 4 | let legacy = format!("{home}/.local/bin/stt "); |
245 | 4 | if !content.contains(&legacy) { |
246 | 2 | return None; |
247 | 2 | } |
248 | 2 | let replacement = format!("{prefix}/bin/stt "); |
249 | 2 | Some(content.replace(&legacy, &replacement)) |
250 | 4 | } |
251 | | |
252 | | /// Build the `Spawn(...)` shortcut entry block for `stt_command`, RON-shaped |
253 | | /// like the rest of the COSMIC shortcuts file. |
254 | 6 | fn shortcut_entry(stt_command: &str) -> String { |
255 | 6 | format!( |
256 | | " (\n modifiers: [\n Super,\n ],\n key: \"space\",\n description: Some(\"Super STT\"),\n ): Spawn(\"{stt_command}\"),\n" |
257 | | ) |
258 | 6 | } |
259 | | |
260 | | /// Add a `Super+Space` → `stt_command` shortcut entry to `content` (the |
261 | | /// COSMIC custom-shortcuts file's current text), subject to two coarse |
262 | | /// checks: skip (return `None`) if a "Super STT" entry already exists, or if |
263 | | /// `key: "space"` is already bound to a `Super`-modified shortcut |
264 | | /// (approximated as both substrings appearing anywhere in `content`). Empty or |
265 | | /// `{}`-only content gets the full-file template; otherwise the entry is |
266 | | /// inserted before the final closing brace. |
267 | | #[must_use] |
268 | 10 | pub fn shortcut_with_super_stt(content: &str, stt_command: &str) -> Option<String> { |
269 | 10 | if content.contains("Super STT") { |
270 | 2 | return None; |
271 | 8 | } |
272 | 8 | if content.contains("key: \"space\"") && content2 .contains2 ("Super") { |
273 | 2 | return None; |
274 | 6 | } |
275 | | |
276 | 6 | let entry = shortcut_entry(stt_command); |
277 | 6 | let trimmed = content.trim(); |
278 | 6 | if trimmed.is_empty() || trimmed == "{}"4 { |
279 | 4 | return Some(format!("{{\n{entry}}}\n")); |
280 | 2 | } |
281 | | |
282 | | // File has content: drop everything from (and including) the final `}` |
283 | | // and append our entry plus a fresh close — mirrors the script's |
284 | | // `head -n -1` + heredoc. |
285 | 2 | let last_brace = content.rfind('}')?0 ; |
286 | 2 | let head = &content[..last_brace]; |
287 | 2 | Some(format!("{head}{entry}}}\n")) |
288 | 10 | } |
289 | | |
290 | | /// Read `/dev/tty` for a `[Y/n]`-style answer to `prompt` (echoed to |
291 | | /// stderr first). Defaults to yes on anything but an exact `n`/`N` — same |
292 | | /// as the script's `[[ "$add_shortcut" =~ ^[Nn]$ ]]` check. A `/dev/tty` |
293 | | /// open/read failure also defaults to "no" (never silently proceeds without |
294 | | /// having actually asked). |
295 | 0 | fn prompt_yes_no(prompt: &str) -> bool { |
296 | | use std::io::{BufRead, Write}; |
297 | 0 | eprint!("{prompt}"); |
298 | 0 | let _ = std::io::stderr().flush(); |
299 | 0 | let Ok(tty) = std::fs::File::open("/dev/tty") else { |
300 | 0 | return false; |
301 | | }; |
302 | 0 | let mut line = String::new(); |
303 | 0 | if std::io::BufReader::new(tty).read_line(&mut line).is_err() { |
304 | 0 | return false; |
305 | 0 | } |
306 | 0 | !line.trim().eq_ignore_ascii_case("n") |
307 | 0 | } |
308 | | |
309 | | /// Rewrite a legacy `~/.local/bin/stt` shortcut reference in the COSMIC |
310 | | /// custom-shortcuts file, if present. |
311 | 0 | fn migrate_shortcut(prefix: &Path) { |
312 | 0 | let Some(home) = dirs::home_dir() else { |
313 | 0 | return; |
314 | | }; |
315 | 0 | let shortcuts_file = |
316 | 0 | home.join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom"); |
317 | 0 | if let Ok(content) = std::fs::read_to_string(&shortcuts_file) |
318 | 0 | && let Some(migrated) = |
319 | 0 | migrate_shortcut_content(&content, &home.to_string_lossy(), &prefix.to_string_lossy()) |
320 | 0 | && let Err(e) = std::fs::write(&shortcuts_file, migrated) |
321 | | { |
322 | 0 | log::warn!("failed to migrate COSMIC shortcut: {e}"); |
323 | 0 | } |
324 | 0 | } |
325 | | |
326 | | /// Interactively offer to add the `Super+Space` shortcut. |
327 | 0 | fn prompt_shortcut(prefix: &Path) { |
328 | 0 | let Some(home) = dirs::home_dir() else { |
329 | 0 | return; |
330 | | }; |
331 | 0 | let shortcuts_dir = home.join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1"); |
332 | 0 | let shortcuts_file = shortcuts_dir.join("custom"); |
333 | | |
334 | 0 | if !prompt_yes_no("Add COSMIC keyboard shortcut (Super+Space)? [Y/n]: ") { |
335 | 0 | return; |
336 | 0 | } |
337 | | |
338 | 0 | if let Err(e) = std::fs::create_dir_all(&shortcuts_dir) { |
339 | 0 | log::warn!("failed to create COSMIC shortcuts dir: {e}"); |
340 | 0 | return; |
341 | 0 | } |
342 | 0 | let stt_command = format!("{}/bin/stt record --write", prefix.display()); |
343 | 0 | let existing = std::fs::read_to_string(&shortcuts_file).unwrap_or_default(); |
344 | 0 | if let Some(updated) = shortcut_with_super_stt(&existing, &stt_command) |
345 | 0 | && let Err(e) = std::fs::write(&shortcuts_file, updated) |
346 | | { |
347 | 0 | log::warn!("failed to write COSMIC shortcut: {e}"); |
348 | 0 | } |
349 | 0 | } |
350 | | |
351 | | /// Everything that happens back in the user process after the root phase has |
352 | | /// placed files. Computes the environment probes [`plan`] needs, gets back |
353 | | /// an ordered [`Step`] list, and executes it through a thin per-step |
354 | | /// `match` — that seam is what makes the whole decision tree ([`plan`]'s |
355 | | /// unit tests) testable without ever touching the filesystem or a real |
356 | | /// shell-out. |
357 | | /// |
358 | | /// `applet_was_installed` must be captured *before* the root phase ran (the |
359 | | /// script's `is_update` check) — it decides whether the panel needs |
360 | | /// restarting to pick up a *changed* applet binary, not whether the applet |
361 | | /// is present now. |
362 | | /// |
363 | | /// # Errors |
364 | | /// [`InstallError::PostInstallFailed`] only when the daemon restart/start |
365 | | /// itself fails ([`Step::RestartOrStart`]) — every other step is best-effort |
366 | | /// and only logs a warning. |
367 | 0 | pub async fn run( |
368 | 0 | components: &Components, |
369 | 0 | applet_was_installed: bool, |
370 | 0 | interactive: bool, |
371 | 0 | prefix: &Path, |
372 | 0 | ) -> Result<(), InstallError> { |
373 | 0 | let systemctl_available = on_path("systemctl"); |
374 | 0 | if components.daemon && !systemctl_available { |
375 | 0 | log::warn!("systemctl not found on PATH; skipping daemon service setup"); |
376 | 0 | } |
377 | 0 | let cosmic_available = on_path("cosmic-panel"); |
378 | | // Only worth the `pgrep` shell-out when it could actually matter — |
379 | | // `plan` re-checks the same `components.applet && applet_was_installed` |
380 | | // guard itself, so passing `false` when it doesn't apply is equivalent. |
381 | 0 | let panel_running = |
382 | 0 | components.applet && applet_was_installed && cmd_ok("pgrep", &["-f", "cosmic-panel"]).await; |
383 | | |
384 | 0 | for step in plan( |
385 | 0 | *components, |
386 | 0 | applet_was_installed, |
387 | 0 | interactive, |
388 | 0 | systemctl_available, |
389 | 0 | cosmic_available, |
390 | 0 | panel_running, |
391 | | ) { |
392 | 0 | match step { |
393 | 0 | Step::DaemonReload => step_daemon_reload().await, |
394 | 0 | Step::RemoveLegacyUnit => step_remove_legacy_unit(), |
395 | 0 | Step::Enable => step_enable().await, |
396 | | // C8 INVARIANT: this is the ONLY `?` in this match. Every other |
397 | | // step function returns `()`, not `Result` — best-effort by |
398 | | // construction, not merely by convention here — so adding a `?` |
399 | | // to another arm first requires changing that step's own |
400 | | // function signature. If you're doing that deliberately, |
401 | | // update `only_restart_or_start_step_is_a_hard_error` below (it |
402 | | // pins this match having exactly one `?`, on this arm) and this |
403 | | // module's doc comment, which documents `RestartOrStart` as the |
404 | | // sole hard error. |
405 | 0 | Step::RestartOrStart => step_restart_or_start().await?, |
406 | 0 | Step::RestartPanel => step_restart_panel().await, |
407 | 0 | Step::NudgeLaunchers => step_nudge_launchers().await, |
408 | 0 | Step::CleanupLegacy => cleanup_legacy(), |
409 | 0 | Step::MigrateShortcut => migrate_shortcut(prefix), |
410 | 0 | Step::PromptShortcut => prompt_shortcut(prefix), |
411 | | } |
412 | | } |
413 | | |
414 | 0 | Ok(()) |
415 | 0 | } |
416 | | |
417 | | #[cfg(test)] |
418 | | mod tests { |
419 | | use super::*; |
420 | | |
421 | | const STT_CMD: &str = "/usr/local/bin/stt record --write"; |
422 | | |
423 | | /// C8: pins that `Step::RestartOrStart` is the ONLY hard-error arm in |
424 | | /// `run`'s per-step executor match. `run` itself can't be driven |
425 | | /// directly without shelling out to real systemd/COSMIC commands — and |
426 | | /// several steps (`cleanup_legacy`, `migrate_shortcut`, ...) touch the |
427 | | /// real `$HOME`, so calling them for real from a test would risk |
428 | | /// mutating the test-runner's actual machine, not a real seam to test |
429 | | /// through. Absent an executable seam, this pins the invariant |
430 | | /// structurally instead: it re-reads this file's own source and counts |
431 | | /// `?` inside the per-step match, so a future edit that adds a second |
432 | | /// `?` arm (silently promoting a best-effort step to a hard error) |
433 | | /// fails this test rather than passing unnoticed. Also scans for |
434 | | /// `return Err` — the other obvious spelling of a hard error, one a |
435 | | /// future arm could use without ever touching the `?` count above. |
436 | | #[test] |
437 | 2 | fn only_restart_or_start_step_is_a_hard_error() { |
438 | 2 | let src = include_str!("post_install.rs"); |
439 | 2 | let match_start = src |
440 | 2 | .find("match step {") |
441 | 2 | .expect("run's per-step executor match must exist"); |
442 | 2 | let match_end = src[match_start..] |
443 | 2 | .find("\n }\n") |
444 | 2 | .expect("the per-step match must close"); |
445 | 2 | let match_block = &src[match_start..match_start + match_end]; |
446 | | // Strip `//`-comment lines before counting: this very match block |
447 | | // carries a doc comment that itself mentions the character being |
448 | | // counted, which would otherwise inflate the count. |
449 | 2 | let code_only: String = match_block |
450 | 2 | .lines() |
451 | 40 | .filter2 (|line| !line.trim_start().starts_with("//")) |
452 | 2 | .collect::<Vec<_>>() |
453 | 2 | .join("\n"); |
454 | | |
455 | 2 | assert_eq!( |
456 | 2 | code_only.matches('?').count(), |
457 | | 1, |
458 | | "expected exactly one `?` in run's executor match (Step::RestartOrStart); \ |
459 | | found a different count — every other step must stay best-effort:\n{code_only}" |
460 | | ); |
461 | 2 | assert!( |
462 | 2 | code_only.contains("Step::RestartOrStart => step_restart_or_start().await?"), |
463 | | "the one `?` must be on the RestartOrStart arm specifically:\n{code_only}" |
464 | | ); |
465 | | // `?` isn't the only way an arm could turn hard-error: an arm could |
466 | | // instead be a block that does `return Err(..)` directly, which |
467 | | // wouldn't move the `?` count above and would slip past the assert |
468 | | // just made. Catch that spelling too. |
469 | 2 | assert_eq!( |
470 | 2 | code_only.matches("return Err").count(), |
471 | | 0, |
472 | | "found a `return Err` in run's executor match outside the `?` \ |
473 | | this test already pins — every other step must stay best-effort:\n{code_only}" |
474 | | ); |
475 | 2 | } |
476 | | |
477 | | #[test] |
478 | 2 | fn migrate_rewrites_legacy_path_only_when_present() { |
479 | 2 | let c = r#"{ (key: "space"): Spawn("/home/u/.local/bin/stt record --write"), }"#; |
480 | 2 | let out = migrate_shortcut_content(c, "/home/u", "/usr/local").unwrap(); |
481 | 2 | assert!(out.contains("/usr/local/bin/stt record --write")); |
482 | 2 | assert!(!out.contains(".local/bin/stt ")); |
483 | 2 | assert!(migrate_shortcut_content("{}", "/home/u", "/usr/local").is_none()); |
484 | 2 | } |
485 | | |
486 | | #[test] |
487 | 2 | fn add_skips_when_super_stt_or_super_space_exists() { |
488 | 2 | assert!( |
489 | 2 | shortcut_with_super_stt(r#"{ description: Some("Super STT") }"#, STT_CMD).is_none() |
490 | | ); |
491 | 2 | let taken = "{\n (\n modifiers: [\n Super,\n ],\n key: \"space\",\n description: Some(\"Other\"),\n ): Spawn(\"x\"),\n}"; |
492 | 2 | assert!(shortcut_with_super_stt(taken, STT_CMD).is_none()); |
493 | 2 | } |
494 | | |
495 | | #[test] |
496 | 2 | fn add_writes_full_file_when_empty_and_inserts_before_close_otherwise() { |
497 | 2 | let fresh = shortcut_with_super_stt("", STT_CMD).unwrap(); |
498 | 2 | assert!(fresh.starts_with("{")); |
499 | 2 | assert!(fresh.contains("Super STT")); |
500 | 2 | assert!(fresh.trim_end().ends_with("}")); |
501 | | |
502 | | // The literal `{}`-only case (not just a truly empty file) also |
503 | | // gets the full-file template, per the doc comment's "Empty or |
504 | | // `{}`-only content" — not just whitespace-empty. |
505 | 2 | let fresh_braces = shortcut_with_super_stt("{}", STT_CMD).unwrap(); |
506 | 2 | assert!(fresh_braces.contains("Super STT")); |
507 | 2 | assert_eq!( |
508 | 2 | fresh_braces.matches('}').count(), |
509 | 2 | fresh_braces.matches('{').count() |
510 | | ); |
511 | | |
512 | 2 | let existing = "{\n (\n modifiers: [\n Ctrl,\n ],\n key: \"t\",\n description: Some(\"Terminal\"),\n ): Spawn(\"term\"),\n}"; |
513 | 2 | let merged = shortcut_with_super_stt(existing, STT_CMD).unwrap(); |
514 | 2 | assert!(merged.contains("Terminal")); |
515 | 2 | assert!(merged.contains("Super STT")); |
516 | 2 | assert_eq!(merged.matches('}').count(), merged.matches('{').count()); |
517 | | // Super STT entry comes after the existing one, before the final close. |
518 | 2 | assert!(merged.rfind("Super STT").unwrap() > merged.find("Terminal").unwrap()); |
519 | 2 | } |
520 | | |
521 | | // --- plan(): the post-install decision tree, tested exhaustively so the |
522 | | // real shell-outs in `run`'s executor never need to be invoked to prove |
523 | | // the WHAT-to-do logic is right. --- |
524 | | |
525 | 6 | fn all_three() -> Components { |
526 | 6 | Components { |
527 | 6 | daemon: true, |
528 | 6 | app: true, |
529 | 6 | applet: true, |
530 | 6 | } |
531 | 6 | } |
532 | | |
533 | 6 | fn daemon_only() -> Components { |
534 | 6 | Components { |
535 | 6 | daemon: true, |
536 | 6 | app: false, |
537 | 6 | applet: false, |
538 | 6 | } |
539 | 6 | } |
540 | | |
541 | | #[test] |
542 | 2 | fn plan_daemon_only_skips_launcher_nudge_and_applet_restart() { |
543 | | // Even with every environment probe favorable (systemctl+cosmic |
544 | | // available, applet "already installed", panel "running") — none of |
545 | | // that matters when the applet isn't part of THIS run's components. |
546 | 2 | let steps = plan(daemon_only(), true, false, true, true, true); |
547 | 2 | assert!(!steps.contains(&Step::NudgeLaunchers)); |
548 | 2 | assert!(!steps.contains(&Step::RestartPanel)); |
549 | 2 | } |
550 | | |
551 | | #[test] |
552 | 2 | fn plan_applet_restart_requires_selected_installed_and_running_all_three() { |
553 | 2 | let applet_only = Components { |
554 | 2 | daemon: false, |
555 | 2 | app: false, |
556 | 2 | applet: true, |
557 | 2 | }; |
558 | 2 | assert!(plan(applet_only, true, false, false, false, true).contains(&Step::RestartPanel)); |
559 | | // Not previously installed (a fresh applet install, not an update): |
560 | | // no restart even if the panel happens to be running. |
561 | 2 | assert!(!plan(applet_only, false, false, false, false, true).contains(&Step::RestartPanel)); |
562 | | // Previously installed, but the panel isn't currently running: |
563 | | // nothing to restart. |
564 | 2 | assert!(!plan(applet_only, true, false, false, false, false).contains(&Step::RestartPanel)); |
565 | | // Applet not selected THIS run (e.g. a daemon-only update), even |
566 | | // though it was installed before and the panel is running. |
567 | 2 | assert!( |
568 | 2 | !plan(daemon_only(), true, false, false, false, true).contains(&Step::RestartPanel) |
569 | | ); |
570 | 2 | } |
571 | | |
572 | | #[test] |
573 | 2 | fn plan_shortcut_steps_require_daemon_component_and_cosmic_available() { |
574 | 2 | let daemon = daemon_only(); |
575 | | |
576 | | // No cosmic-panel on PATH: no shortcut steps at all, interactive or not. |
577 | 2 | let steps = plan(daemon, false, true, true, false, false); |
578 | 2 | assert!(!steps.contains(&Step::MigrateShortcut)); |
579 | 2 | assert!(!steps.contains(&Step::PromptShortcut)); |
580 | | |
581 | | // Cosmic available, non-interactive: migrate only (the prompt is |
582 | | // interactive-only, but migration isn't gated on it at all). |
583 | 2 | let steps = plan(daemon, false, false, true, true, false); |
584 | 2 | assert!(steps.contains(&Step::MigrateShortcut)); |
585 | 2 | assert!(!steps.contains(&Step::PromptShortcut)); |
586 | | |
587 | | // Cosmic available AND interactive: both steps, prompt after migrate. |
588 | 2 | let steps = plan(daemon, false, true, true, true, false); |
589 | 12 | let migrate_at2 = steps.iter()2 .position2 (|s| *s == Step::MigrateShortcut); |
590 | 14 | let prompt_at2 = steps.iter()2 .position2 (|s| *s == Step::PromptShortcut); |
591 | 2 | assert!(migrate_at.is_some() && prompt_at.is_some()); |
592 | 2 | assert!(migrate_at < prompt_at); |
593 | | |
594 | | // App-only (no daemon component at all): no shortcut steps even with |
595 | | // cosmic available and an interactive session. |
596 | 2 | let app_only = Components { |
597 | 2 | daemon: false, |
598 | 2 | app: true, |
599 | 2 | applet: false, |
600 | 2 | }; |
601 | 2 | let steps = plan(app_only, false, true, true, true, false); |
602 | 2 | assert!(!steps.contains(&Step::MigrateShortcut)); |
603 | 2 | assert!(!steps.contains(&Step::PromptShortcut)); |
604 | 2 | } |
605 | | |
606 | | #[test] |
607 | 2 | fn plan_no_systemctl_means_no_systemd_steps() { |
608 | 2 | let steps = plan(all_three(), true, false, false, true, true); |
609 | 2 | assert!(!steps.contains(&Step::DaemonReload)); |
610 | 2 | assert!(!steps.contains(&Step::RemoveLegacyUnit)); |
611 | 2 | assert!(!steps.contains(&Step::Enable)); |
612 | 2 | assert!(!steps.contains(&Step::RestartOrStart)); |
613 | 2 | } |
614 | | |
615 | | #[test] |
616 | 2 | fn plan_no_daemon_component_means_no_systemd_steps_even_if_available() { |
617 | 2 | let app_only = Components { |
618 | 2 | daemon: false, |
619 | 2 | app: true, |
620 | 2 | applet: false, |
621 | 2 | }; |
622 | 2 | let steps = plan(app_only, false, false, true, false, false); |
623 | 2 | assert!(!steps.iter().any(|s| matches!( |
624 | 4 | s, |
625 | | Step::DaemonReload | Step::RemoveLegacyUnit | Step::Enable | Step::RestartOrStart |
626 | | ))); |
627 | 2 | } |
628 | | |
629 | | #[test] |
630 | 2 | fn plan_cleanup_legacy_always_runs_regardless_of_every_other_gate() { |
631 | 2 | assert!( |
632 | 2 | plan(Components::default(), false, false, false, false, false) |
633 | 2 | .contains(&Step::CleanupLegacy) |
634 | | ); |
635 | 2 | assert!(plan(all_three(), true, true, true, true, true).contains(&Step::CleanupLegacy)); |
636 | 2 | } |
637 | | |
638 | | #[test] |
639 | 2 | fn plan_app_only_nudges_launchers_with_no_systemd_or_shortcut_steps() { |
640 | 2 | let app_only = Components { |
641 | 2 | daemon: false, |
642 | 2 | app: true, |
643 | 2 | applet: false, |
644 | 2 | }; |
645 | 2 | let steps = plan(app_only, false, false, true, true, false); |
646 | 2 | assert!(steps.contains(&Step::NudgeLaunchers)); |
647 | 2 | assert!(!steps.iter().any(|s| matches!( |
648 | 4 | s, |
649 | | Step::DaemonReload |
650 | | | Step::RestartOrStart |
651 | | | Step::MigrateShortcut |
652 | | | Step::PromptShortcut |
653 | | ))); |
654 | 2 | } |
655 | | |
656 | | #[test] |
657 | 2 | fn plan_full_update_interactive_uses_the_documented_step_order() { |
658 | | // Everything on: daemon+app+applet all selected (an "all" update), |
659 | | // the applet was already installed and its panel is currently |
660 | | // running, systemctl and cosmic-panel both available, interactive |
661 | | // session. The documented order for the "all" case: systemd |
662 | | // install/enable/(re)start, applet panel restart, launcher nudge, |
663 | | // legacy cleanup, COSMIC shortcut migrate-then-prompt. |
664 | 2 | let steps = plan(all_three(), true, true, true, true, true); |
665 | 2 | assert_eq!( |
666 | | steps, |
667 | 2 | vec![ |
668 | 2 | Step::DaemonReload, |
669 | 2 | Step::RemoveLegacyUnit, |
670 | 2 | Step::Enable, |
671 | 2 | Step::RestartOrStart, |
672 | 2 | Step::RestartPanel, |
673 | 2 | Step::NudgeLaunchers, |
674 | 2 | Step::CleanupLegacy, |
675 | 2 | Step::MigrateShortcut, |
676 | 2 | Step::PromptShortcut, |
677 | | ] |
678 | | ); |
679 | 2 | } |
680 | | } |