collective

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

diff --git a/apps/artcraft-launcher/src/settings_file.rs b/apps/artcraft-launcher/src/settings_file.rs--- a/apps/artcraft-launcher/src/settings_file.rs+++ b/apps/artcraft-launcher/src/settings_file.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));     }diff --git a/crates/core/src/args.rs b/crates/core/src/args.rsnew file mode 100644--- /dev/null+++ b/crates/core/src/args.rs@@ -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));+    }+}diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs--- a/crates/core/src/lib.rs+++ b/crates/core/src/lib.rs@@ -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;diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs--- a/crates/core/src/protocol.rs+++ b/crates/core/src/protocol.rs@@ -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 {diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -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 {diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs--- a/crates/native/src/install.rs+++ b/crates/native/src/install.rs@@ -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))diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs--- a/crates/native/src/process.rs+++ b/crates/native/src/process.rs@@ -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> {diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -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();diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -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.diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -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ýtdiff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -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	Ausblendendiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -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	Descartardiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -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	Ignorerdiff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -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ésdiff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -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	閉じるdiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -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	Zamknijdiff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv--- a/crates/ui-egui/src/i18n/pt-BR.tsv+++ b/crates/ui-egui/src/i18n/pt-BR.tsv@@ -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	Dispensardiff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -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	Закрытьdiff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv--- a/crates/ui-egui/src/i18n/zh-CN.tsv+++ b/crates/ui-egui/src/i18n/zh-CN.tsv@@ -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	忽略diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs--- a/crates/ui-egui/src/settings.rs+++ b/crates/ui-egui/src/settings.rs@@ -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(),         }     } }diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -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();diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs--- a/crates/ui-egui/tests/ui.rs+++ b/crates/ui-egui/tests/ui.rs@@ -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();