Browse / github.com/storytold/craft-launcher / patch
feat: per-app launch options passed when an app is opened
feat: per-app launch options passed when an app is opened ## Per-app launch options Adds a **Launch options…** item to each installed app's menu, in both the card/table view and the details drawer. Flags typed there are saved per app and added to the app's command line every time **Open** is clicked. - **Storage:** `Settings.launch_flags` (catalog id → the text as typed), saved in `app.ron` with the other settings. Older settings files load unchanged. - **Parsing:** `core::args::split` splits on whitespace and treats `"…"` and `'…'` as one argument. Backslashes are kept as typed, so Windows paths come through. An unclosed quote is refused: the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- bryanbrunetti (imported from pull request #830, open)
- Base
4ab652afd834081ff76a47ffb752fbbf9424f9c1- Size
- +303 −21 in 22 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e01917cc01ea45cb10e9c18b
How it went
No other operator has reported using it yet.
Diff
raw · apps/artcraft-launcher/src/settings_file.rs crates/core/src/args.rs crates/core/src/lib.rs crates/core/src/protocol.rs crates/native/src/backend.rs crates/native/src/install.rs crates/native/src/process.rs crates/ui-egui/src/app.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-BR.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/zh-CN.tsv crates/ui-egui/src/settings.rs crates/ui-egui/src/views.rs crates/ui-egui/tests/ui.rs
@@ -110,7 +110,8 @@ mod tests { #[test] fn settings_saved_with_no_file_read_back() { let dir = Scratch::new("settings-round-trip");- let settings = Settings { last_check: Some(1_791_500_000), notify: false, ..Settings::default() };+ let mut settings = Settings { last_check: Some(1_791_500_000), notify: false, ..Settings::default() };+ settings.launch_flags.insert("photocraft".into(), r#"--dir "My Photos""#.into()); assert_eq!(save(&dir.file(), "settings", &settings), Ok(())); assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings)); }new file mode 100644@@ -0,0 +1,78 @@+//! The extra command-line flags a user gives an app, from the text they typed.++/// The flags open a quote they never close.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct UnclosedQuote;++/// Splits `text` into arguments: on whitespace, with `"…"` or `'…'` keeping spaces inside one+/// argument (`--name "My Projects"`). A backslash is an ordinary character, so Windows paths+/// (`C:\Users\me`) come through as typed. An unclosed quote is an error, not a guess.+pub fn split(text: &str) -> Result<Vec<String>, UnclosedQuote> {+ let mut args = Vec::new();+ let mut current = String::new();+ // Whether `current` is an argument, even an empty one (`""`).+ let mut started = false;+ let mut quote: Option<char> = None;+ for c in text.chars() {+ match quote {+ Some(q) if c == q => quote = None,+ Some(_) => current.push(c),+ None if c == '"' || c == '\'' => {+ quote = Some(c);+ started = true;+ }+ None if c.is_whitespace() => {+ if started {+ args.push(std::mem::take(&mut current));+ started = false;+ }+ }+ None => {+ current.push(c);+ started = true;+ }+ }+ }+ if quote.is_some() {+ return Err(UnclosedQuote);+ }+ if started {+ args.push(current);+ }+ Ok(args)+}++#[cfg(test)]+mod tests {+ use super::*;++ fn ok(text: &str) -> Vec<String> {+ split(text).unwrap()+ }++ #[test]+ fn whitespace_separates_arguments() {+ assert_eq!(ok(" --verbose -x\t1 "), ["--verbose", "-x", "1"]);+ assert!(ok("").is_empty());+ assert!(ok(" ").is_empty());+ }++ #[test]+ fn quotes_keep_spaces_and_join_with_what_touches_them() {+ assert_eq!(ok(r#"--dir "My Projects" 'a b'"#), ["--dir", "My Projects", "a b"]);+ assert_eq!(ok(r#"--name="A B""#), ["--name=A B"]);+ assert_eq!(ok(r#""it's" 'say "hi"'"#), ["it's", r#"say "hi""#]);+ assert_eq!(ok(r#""" x"#), ["", "x"]);+ }++ #[test]+ fn backslashes_are_kept_for_windows_paths() {+ assert_eq!(ok(r"--open C:\Users\me\file.txt"), ["--open", r"C:\Users\me\file.txt"]);+ }++ #[test]+ fn an_unclosed_quote_is_an_error() {+ assert_eq!(split(r#"--dir "My Projects"#), Err(UnclosedQuote));+ assert_eq!(split("'"), Err(UnclosedQuote));+ }+}@@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod args; pub mod catalog; pub mod details; pub mod distro;@@ -32,8 +32,10 @@ pub enum Command { Uninstall { app: String, },+ /// Start an installed app, passing `args` (the user's launch flags) on its command line. Launch { app: String,+ args: Vec<String>, }, /// Show the installed app in the file manager. Reveal {@@ -1063,10 +1063,10 @@ impl Backend for NativeBackend { } } }- Command::Launch { app } => {+ Command::Launch { app, args } => { let Some(def) = CraftApp::by_id(&app) else { return }; let result = match self.installed(&app) {- Some(inst) => install::launch(def, &inst, &self.paths),+ Some(inst) => install::launch(def, &inst, &self.paths, &args), None => Err(format!("{} isn't installed", def.name)), }; if let Err(e) = result {@@ -537,17 +537,18 @@ fn remove_appimage(app: &CraftApp, path: &Path, record: Option<&Record>) -> Resu Ok(()) } -/// Starts an installed app.-pub fn launch(app: &CraftApp, installed: &Installed, paths: &Paths) -> Result<(), String> {+/// Starts an installed app with `args` (the user's launch flags) on its command line.+pub fn launch(app: &CraftApp, installed: &Installed, paths: &Paths, args: &[String]) -> Result<(), String> { let _ = paths; let path = Path::new(&installed.location); match installed.source { InstallSource::Flatpak => { let mut cmd = process::flatpak_command();- cmd.args(["run", &app.bundle_id()]);+ // After the app's id: flags before it would go to `flatpak run` itself.+ cmd.args(["run", &app.bundle_id()]).args(args); process::spawn_detached(cmd) }- InstallSource::MacApp => process::open(path.as_os_str()),+ InstallSource::MacApp => process::open_app(path, args), // A tarball the launcher unpacked into its own folder: run it here, against its own // home, rather than on the host through `flatpak-spawn` (which is what `launch_binary` // would do inside the sandbox). Its settings then land beside it instead of in the@@ -557,6 +558,7 @@ pub fn launch(app: &CraftApp, installed: &Installed, paths: &Paths) -> Result<() let home = paths.app_home(app.id); prepare_home(&home); let mut cmd = process::command(path);+ cmd.args(args); for (name, value) in home_env(&home) { cmd.env(name, value); }@@ -565,12 +567,12 @@ pub fn launch(app: &CraftApp, installed: &Installed, paths: &Paths) -> Result<() } process::spawn_detached(cmd) }- _ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open(path.as_os_str()),+ _ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open_app(path, args), _ => { if !path.exists() { return Err(format!("{} isn't where it was installed ({})", app.name, path.display())); }- process::launch_binary(path, &[])+ process::launch_binary(path, args) } } .map_err(|e| format!("Couldn't open {}: {e}", app.name))@@ -296,7 +296,7 @@ fn which_on_path(name: &str) -> Option<std::path::PathBuf> { } /// Starts an app binary from its own folder.-pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> {+pub fn launch_binary(exe: &Path, args: &[String]) -> Result<(), String> { let mut cmd = if in_sandbox() { let mut c = command("flatpak-spawn"); c.arg("--host").arg(exe);@@ -316,6 +316,15 @@ pub fn open(target: &std::ffi::OsStr) -> Result<(), String> { spawn_detached(opener(target)?) } +/// macOS: opens an `.app` bundle with `open`, handing `args` to the app (`open … --args`).+pub fn open_app(bundle: &Path, args: &[String]) -> Result<(), String> {+ let mut cmd = opener(bundle.as_os_str())?;+ if !args.is_empty() {+ cmd.arg("--args").args(args);+ }+ spawn_detached(cmd)+}+ /// The system's default opener for `target`: `explorer` on Windows, `open` on macOS, and /// `xdg-open` elsewhere (on the host, through `flatpak-spawn --host`, inside the sandbox). fn opener(target: &std::ffi::OsStr) -> Result<Command, String> {@@ -90,7 +90,10 @@ pub(crate) struct Toast { pub(crate) enum UiAction { Command(Command), /// [`Command::Launch`] plus one launch-count increment.- Launch(String),+ Launch {+ app: String,+ args: Vec<String>,+ }, /// Re-run the manual update check, clearing the auto-update back-off. CheckNow, /// A message a UI interaction asked to show.@@ -108,6 +111,7 @@ pub(crate) enum Dialog { Replace { app: String, path: String }, Settings, LauncherUpdate(artcraft_launcher_core::Release),+ LaunchFlags { app: String, flags: String }, } impl Dialog {@@ -121,6 +125,7 @@ impl Dialog { Dialog::Replace { app, .. } => ("replace", app), Dialog::Settings => ("settings", ""), Dialog::LauncherUpdate(r) => ("launcher-update", &r.tag),+ Dialog::LaunchFlags { app, .. } => ("launch-flags", app), } } }@@ -367,10 +372,10 @@ impl LauncherUi { } match action { UiAction::Command(command) => self.backend.send(command.clone()),- UiAction::Launch(id) => {- let opened = self.settings.launches.entry(id.clone()).or_insert(0);+ UiAction::Launch { app, args } => {+ let opened = self.settings.launches.entry(app.clone()).or_insert(0); *opened = opened.saturating_add(1);- self.backend.send(Command::Launch { app: id.clone() });+ self.backend.send(Command::Launch { app: app.clone(), args: args.clone() }); } UiAction::CheckNow => { self.auto_update_attempted.clear();@@ -883,9 +888,16 @@ impl LauncherUi { list.into_iter().map(|(_, e)| e.app.id).collect() } - /// Opens an app, and counts it for "Frequently used".+ /// Opens an app with its launch flags, and counts it for "Frequently used". pub(crate) fn launch_app(&mut self, id: &str) {- self.act(UiAction::Launch(id.to_string()));+ let flags = self.settings.launch_flags.get(id).map_or("", String::as_str);+ // The dialog only saves flags that split, but the settings file can be edited by hand.+ let Ok(args) = artcraft_launcher_core::args::split(flags) else {+ let name = self.entry(id).map_or(id, |e| e.app.name).to_string();+ self.toast_from_ui(crate::i18n::fmt(tl!("{name} wasn't opened: a quote in its launch options isn't closed."), &[("name", &name)]), true);+ return;+ };+ self.act(UiAction::Launch { app: id.to_string(), args }); } /// Whether the app on screen under `id` is installed on this computer.@@ -1129,9 +1141,9 @@ mod tests { fn a_launch_count_is_incremented_once_per_visible_frame() { let (mut launcher, sent, _ctx) = launcher(); launcher.frame = Some(1);- launcher.act(UiAction::Launch("photocraft".into()));+ launcher.act(UiAction::Launch { app: "photocraft".into(), args: vec![] }); launcher.flush_actions();- launcher.act(UiAction::Launch("photocraft".into()));+ launcher.act(UiAction::Launch { app: "photocraft".into(), args: vec![] }); launcher.flush_actions(); assert_eq!(launcher.settings.launches.get("photocraft"), Some(&1)); let launches = sent.borrow().iter().filter(|command| matches!(command, Command::Launch { .. })).count();@@ -37,6 +37,7 @@ impl LauncherUi { Dialog::Replace { app, path } => modal.show(ctx, |ui| close = self.replace_dialog(ui, app, path)), Dialog::Settings => modal.show(ctx, |ui| close = self.settings_dialog(ui, host)), Dialog::LauncherUpdate(r) => modal.show(ctx, |ui| close = self.launcher_update_dialog(ui, r)),+ Dialog::LaunchFlags { app, flags } => modal.show(ctx, |ui| close = self.launch_flags_dialog(ui, app, flags)), }; if close || response.should_close() { self.dialog = None;@@ -222,6 +223,41 @@ impl LauncherUi { close } + /// The extra command-line flags an app is opened with. Saved only when they split cleanly.+ fn launch_flags_dialog(&mut self, ui: &mut egui::Ui, id: &str, flags: &mut String) -> bool {+ let Some(app) = self.entry(id).map(|e| e.app) else { return true };+ let t = Tokens::get(ui.ctx());+ ui.set_width(460.0f32.min(ui.ctx().content_rect().width() - 80.0));+ Self::title(ui, crate::icons::app(id), &fmt(tl!("{name} launch options"), &[("name", app.name)]), "");+ let label = ui.label(tl!("Flags added each time the app is opened"));+ ui.add(egui::TextEdit::singleline(flags).id_salt("launch_flags").hint_text("--flag \"…\"").desired_width(f32::INFINITY))+ .labelled_by(label.id);+ let split = artcraft_launcher_core::args::split(flags);+ let (note, color) = match split {+ Ok(_) => (tl!("Separate flags with spaces. Quote a value that has spaces in it."), t.muted),+ Err(_) => (tl!("A quote isn't closed."), t.bad),+ };+ ui.label(RichText::new(note).color(color));+ ui.add_space(14.0);+ let mut close = false;+ ui.horizontal(|ui| {+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ let save = ui.add_enabled_ui(split.is_ok(), |ui| widgets::button(ui, tl!("Save"), Kind::Primary, 90.0)).inner;+ if save.clicked() {+ let trimmed = flags.trim();+ if trimmed.is_empty() {+ self.settings.launch_flags.remove(id);+ } else {+ self.settings.launch_flags.insert(id.to_string(), trimmed.to_string());+ }+ close = true;+ }+ close |= widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked();+ });+ });+ close+ }+ /// Warns that `path` already exists and asks whether to replace it, returning `true` when the /// dialog should close, including when the app entry is gone. `Replace` is queued at the action /// boundary, not sent inline.@@ -59,6 +59,13 @@ Show in Files Zobrazit v Souborech Reinstall Znovu nainstalovat Uninstall… Odinstalovat…+ Launch options… Možnosti spuštění…+ {name} launch options Možnosti spuštění aplikace {name}+ Flags added each time the app is opened Přepínače přidané při každém otevření aplikace+ Separate flags with spaces. Quote a value that has spaces in it. Přepínače oddělte mezerami. Hodnotu s mezerami dejte do uvozovek.+ A quote isn't closed. Uvozovky nejsou uzavřené.+ {name} wasn't opened: a quote in its launch options isn't closed. Aplikace {name} nebyla otevřena: uvozovky v jejích možnostech spuštění nejsou uzavřené.+ Save Uložit All releases Všechna vydání ArtCraft Launcher {version} is available. Je k dispozici ArtCraft Launcher {version}. Dismiss Skrýt@@ -72,6 +72,13 @@ Show in Files Im Dateimanager zeigen Reinstall Neu installieren Uninstall… Deinstallieren…+ Launch options… Startoptionen…+ {name} launch options Startoptionen für {name}+ Flags added each time the app is opened Flags, die bei jedem Öffnen der App angehängt werden+ Separate flags with spaces. Quote a value that has spaces in it. Flags durch Leerzeichen trennen. Werte mit Leerzeichen in Anführungszeichen setzen.+ A quote isn't closed. Ein Anführungszeichen wird nicht geschlossen.+ {name} wasn't opened: a quote in its launch options isn't closed. {name} wurde nicht geöffnet: Ein Anführungszeichen in den Startoptionen wird nicht geschlossen.+ Save Speichern All releases Alle Versionen ArtCraft Launcher {version} is available. ArtCraft Launcher {version} ist verfügbar. Dismiss Ausblenden@@ -72,6 +72,13 @@ Show in Files Mostrar en Archivos Reinstall Reinstalar Uninstall… Desinstalar…+ Launch options… Opciones de inicio…+ {name} launch options Opciones de inicio de {name}+ Flags added each time the app is opened Parámetros que se añaden cada vez que se abre la aplicación+ Separate flags with spaces. Quote a value that has spaces in it. Separa los parámetros con espacios. Pon entre comillas un valor que contenga espacios.+ A quote isn't closed. Hay unas comillas sin cerrar.+ {name} wasn't opened: a quote in its launch options isn't closed. {name} no se abrió: hay unas comillas sin cerrar en sus opciones de inicio.+ Save Guardar All releases Todas las versiones ArtCraft Launcher {version} is available. ArtCraft Launcher {version} está disponible. Dismiss Descartar@@ -70,6 +70,13 @@ Show in Files Afficher dans Fichiers Reinstall Réinstaller Uninstall… Désinstaller…+ Launch options… Options de lancement…+ {name} launch options Options de lancement de {name}+ Flags added each time the app is opened Options ajoutées à chaque ouverture de l'application+ Separate flags with spaces. Quote a value that has spaces in it. Séparez les options par des espaces. Mettez entre guillemets une valeur qui contient des espaces.+ A quote isn't closed. Un guillemet n'est pas fermé.+ {name} wasn't opened: a quote in its launch options isn't closed. {name} n'a pas été ouvert : un guillemet n'est pas fermé dans ses options de lancement.+ Save Enregistrer All releases Toutes les versions ArtCraft Launcher {version} is available. ArtCraft Launcher {version} est disponible. Dismiss Ignorer@@ -72,6 +72,13 @@ Show in Files Megjelenítés a fájlkezelőben Reinstall Újratelepítés Uninstall… Eltávolítás…+ Launch options… Indítási beállítások…+ {name} launch options {name} indítási beállításai+ Flags added each time the app is opened Kapcsolók, amelyeket az alkalmazás minden megnyitásakor átad+ Separate flags with spaces. Quote a value that has spaces in it. A kapcsolókat szóközzel válassza el. A szóközt tartalmazó értéket tegye idézőjelbe.+ A quote isn't closed. Egy idézőjel nincs lezárva.+ {name} wasn't opened: a quote in its launch options isn't closed. A(z) {name} nem nyílt meg: az indítási beállításaiban egy idézőjel nincs lezárva.+ Save Mentés All releases Minden kiadás ArtCraft Launcher {version} is available. Elérhető az ArtCraft Launcher új verziója: {version}. Dismiss Elrejtés@@ -74,6 +74,13 @@ Show in Files ファイルマネージャーで表示 Reinstall 再インストール Uninstall… アンインストール…+ Launch options… 起動オプション…+ {name} launch options {name} の起動オプション+ Flags added each time the app is opened アプリを開くたびに追加されるフラグ+ Separate flags with spaces. Quote a value that has spaces in it. フラグはスペースで区切ってください。スペースを含む値は引用符で囲んでください。+ A quote isn't closed. 引用符が閉じられていません。+ {name} wasn't opened: a quote in its launch options isn't closed. {name} を開けませんでした: 起動オプションの引用符が閉じられていません。+ Save 保存 All releases すべてのリリース ArtCraft Launcher {version} is available. ArtCraft Launcher {version} が利用可能です。 Dismiss 閉じる@@ -73,6 +73,13 @@ Show in Files Pokaż w menedżerze plików Reinstall Przeinstaluj Uninstall… Odinstaluj…+ Launch options… Opcje uruchamiania…+ {name} launch options Opcje uruchamiania aplikacji {name}+ Flags added each time the app is opened Flagi dodawane przy każdym otwarciu aplikacji+ Separate flags with spaces. Quote a value that has spaces in it. Oddziel flagi spacjami. Wartość ze spacjami umieść w cudzysłowie.+ A quote isn't closed. Cudzysłów nie jest zamknięty.+ {name} wasn't opened: a quote in its launch options isn't closed. Nie otwarto aplikacji {name}: cudzysłów w jej opcjach uruchamiania nie jest zamknięty.+ Save Zapisz All releases Wszystkie wydania ArtCraft Launcher {version} is available. Dostępna jest nowa wersja ArtCraft Launcher ({version}). Dismiss Zamknij@@ -60,6 +60,13 @@ Show in Files Mostrar no gerenciador de arquivos Reinstall Reinstalar Uninstall… Desinstalar…+ Launch options… Opções de inicialização…+ {name} launch options Opções de inicialização do {name}+ Flags added each time the app is opened Parâmetros adicionados sempre que o aplicativo é aberto+ Separate flags with spaces. Quote a value that has spaces in it. Separe os parâmetros com espaços. Coloque entre aspas um valor que tenha espaços.+ A quote isn't closed. Há aspas que não foram fechadas.+ {name} wasn't opened: a quote in its launch options isn't closed. {name} não foi aberto: há aspas não fechadas nas opções de inicialização.+ Save Salvar All releases Todos os lançamentos ArtCraft Launcher {version} is available. ArtCraft Launcher {version} está disponível. Dismiss Dispensar@@ -70,6 +70,13 @@ Show in Files Показать в файловом менеджере Reinstall Переустановить Uninstall… Удалить…+ Launch options… Параметры запуска…+ {name} launch options Параметры запуска {name}+ Flags added each time the app is opened Флаги, добавляемые при каждом открытии приложения+ Separate flags with spaces. Quote a value that has spaces in it. Разделяйте флаги пробелами. Значение с пробелами заключите в кавычки.+ A quote isn't closed. Кавычка не закрыта.+ {name} wasn't opened: a quote in its launch options isn't closed. {name} не открыто: в параметрах запуска не закрыта кавычка.+ Save Сохранить All releases Все релизы ArtCraft Launcher {version} is available. Доступен ArtCraft Launcher {version}. Dismiss Закрыть@@ -54,6 +54,13 @@ Release notes 更新说明 Reinstall 重新安装 Uninstall… 卸载…+ Launch options… 启动选项…+ {name} launch options {name} 启动选项+ Flags added each time the app is opened 每次打开应用时附加的参数+ Separate flags with spaces. Quote a value that has spaces in it. 用空格分隔各个参数。含有空格的值请用引号括起来。+ A quote isn't closed. 有引号未闭合。+ {name} wasn't opened: a quote in its launch options isn't closed. 未打开 {name}:其启动选项中有引号未闭合。+ Save 保存 All releases 所有版本 ArtCraft Launcher {version} is available. ArtCraft Launcher {version} 已可更新。 Dismiss 忽略@@ -122,6 +122,9 @@ pub struct Settings { pub tray_hint_shown: bool, /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used". pub launches: BTreeMap<String, u32>,+ /// Extra command-line flags each app is opened with (catalog id → the text as typed, split+ /// by [`args::split`](artcraft_launcher_core::args::split) at launch).+ pub launch_flags: BTreeMap<String, String>, } impl Settings {@@ -204,6 +207,7 @@ impl Default for Settings { hidden: std::collections::BTreeSet::new(), tray_hint_shown: false, launches: BTreeMap::new(),+ launch_flags: BTreeMap::new(), } } }@@ -1008,13 +1008,18 @@ impl LauncherUi { }); } - /// The menu items for an installed app: open, reveal in the file manager, reinstall, and- /// uninstall (which asks first).+ /// The menu items for an installed app: open, its launch options, reveal in the file manager,+ /// reinstall, and uninstall (which asks first). fn menu_installed(&mut self, ui: &mut Ui, status: &AppStatus, id: &str, reveal: &'static str) { if ui.button(tl!("Open")).clicked() { self.launch_app(id); ui.close(); }+ if ui.button(tl!("Launch options…")).clicked() {+ let flags = self.settings.launch_flags.get(id).cloned().unwrap_or_default();+ self.dialog = Some(Dialog::LaunchFlags { app: id.to_string(), flags });+ ui.close();+ } if ui.button(t(reveal)).clicked() { self.act_command(Command::Reveal { app: id.to_string() }); ui.close();@@ -1056,6 +1056,68 @@ fn hiding_an_app_removes_it_until_it_is_unhidden() { assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All); } +#[test]+fn launch_options_are_saved_per_app_and_passed_when_it_opens() {+ use egui::accesskit::Role;+ let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Dark, true);+ h.run_steps(6);+ let menu = |h: &mut Harness<'static, Option<LauncherUi>>| {+ let row = h.get_all_by_label("WordCraft").next().unwrap().rect().center().y;+ h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();+ h.run_steps(3);+ };+ menu(&mut h);+ h.get_by_label("Launch options…").click();+ h.run_steps(3);+ let field = "Flags added each time the app is opened";+ h.get_by_role_and_label(Role::TextInput, field).click();+ h.get_by_role_and_label(Role::TextInput, field).type_text(r#"--dir "My Docs" --open"#);+ h.run_steps(3);+ h.get_by_label("Save").click();+ h.run_steps(3);+ assert_eq!(h.state().as_ref().unwrap().settings.launch_flags.get("wordcraft").map(String::as_str), Some(r#"--dir "My Docs" --open"#));++ menu(&mut h);+ h.get_all_by_label("Open").last().unwrap().click(); // the menu's+ h.run_steps(3);+ let args: Vec<String> = ["--dir", "My Docs", "--open"].map(String::from).into();+ assert!(sent.borrow().contains(&Command::Launch { app: "wordcraft".into(), args }));+ // The other apps open with nothing added.+ assert!(!h.state().as_ref().unwrap().settings.launch_flags.contains_key("lightcraft"));+}++#[test]+fn launch_options_with_an_unclosed_quote_are_not_saved_or_launched() {+ use egui::accesskit::Role;+ let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Dark, true);+ h.run_steps(6);+ let row = h.get_all_by_label("WordCraft").next().unwrap().rect().center().y;+ h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();+ h.run_steps(3);+ h.get_by_label("Launch options…").click();+ h.run_steps(3);+ let field = "Flags added each time the app is opened";+ h.get_by_role_and_label(Role::TextInput, field).click();+ h.get_by_role_and_label(Role::TextInput, field).type_text(r#"--dir "My Docs"#);+ h.run_steps(3);+ h.get_by_label("A quote isn't closed.");+ h.get_by_label("Save").click();+ h.run_steps(3);+ assert!(h.state().as_ref().unwrap().settings.launch_flags.is_empty());+ h.get_by_label("Cancel").click();+ h.run_steps(3);++ // Flags edited by hand in the settings file: the app isn't opened with half of them.+ h.state_mut().as_mut().unwrap().settings.launch_flags.insert("wordcraft".into(), "'oops".into());+ sent.borrow_mut().clear();+ let row = h.get_all_by_label("WordCraft").next().unwrap().rect().center().y;+ h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();+ h.run_steps(3);+ h.get_all_by_label("Open").last().unwrap().click(); // the menu's+ h.run_steps(3);+ assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. })));+}+ #[test] /// The view switch stores Table, and the table names each app once in its wordmark rather than /// again on the decorative icon.@@ -2495,7 +2557,7 @@ fn opening_an_app_counts_and_frequently_used_puts_the_most_opened_first() { assert_eq!(opens.len(), 2); opens[1].click(); // WordCraft h.run_steps(3);- assert!(sent.borrow().contains(&Command::Launch { app: "wordcraft".into() }));+ assert!(sent.borrow().contains(&Command::Launch { app: "wordcraft".into(), args: vec![] })); assert_eq!(h.state().as_ref().unwrap().settings.launches.get("wordcraft"), Some(&1)); h.run_steps(4); let word = h.get_by_label("WordCraft details").rect();