Browse / github.com/storytold/craft-launcher / patch
Match native Linux app processes without locale-dependent text
Match native Linux app processes without locale-dependent text Native Linux process checks can miss a running app whose path contains Unicode when the launcher inherits the C locale. That check protects updates and removal. The existing live-process regression reproduces the missed match without installing or removing an app. Read native Linux command lines directly from procfs and match the path as literal bytes. Keep wrapper arguments, spaces and regex characters working, skip the launcher's own process, and carry matches across fixed-size reads. The scan uses a cooperative five-second deadline. It reports an incomplete scan as an error so replacement
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- IanHollow (imported from pull request #482, open)
- Base
307efcf45a080af9643e750d43034eb25540cef8- Size
- +286 −8 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
82109feedaeb7be7c926ead2
How it went
No other operator has reported using it yet.
Diff
raw · crates/native/src/install.rs crates/native/src/process.rs crates/native/src/process/linux.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
@@ -77,9 +77,8 @@ pub fn install_at( let destination = destination(app, kind, existing, previous, job.install_locations)?; if let Some(inst) = existing && inst.source.launcher_can_update()- && process::is_running(Path::new(&inst.location)) {- return Err(format!("{} is running. Quit it, then try again.", app.name));+ process::ensure_not_running(Path::new(&inst.location), app.name)?; } log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label()); let file = download(release, asset, job)?;@@ -360,10 +359,8 @@ pub fn uninstall( if let Some(msg) = managed_elsewhere(app, installed, true) { return Err(msg); }- if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher)- && process::is_running(&path)- {- return Err(format!("{} is running. Quit it, then try again.", app.name));+ if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) {+ process::ensure_not_running(&path, app.name)?; } match installed.source { #[cfg(windows)]@@ -1666,6 +1663,34 @@ mod tests { assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2); } + #[cfg(target_os = "linux")]+ #[test]+ fn an_uncheckable_process_blocks_install_and_uninstall() {+ // This exercises native procfs, not Flatpak's separate host helper.+ if process::in_sandbox() {+ eprintln!("native Linux process check requires running outside Flatpak");+ return;+ }+ let app = CraftApp::by_id("wordcraft").unwrap();+ let existing = Installed { version: None, source: InstallSource::AppImage, location: String::new(), package: None };+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let http = Http::new().unwrap();+ let stop = Stop::default();+ let mut phases = Vec::new();+ let mut phase = |p: JobPhase| phases.push(p);+ let locations = InstallLocations::default();+ let mut job = Job { http: &http, paths: &paths, stop: &stop, phase: &mut phase, install_locations: &locations, replace: false };+ let expected = format!("Couldn't check whether {} is running. Try again.", app.name);+ assert_eq!(+ install(app, &Release::default(), &Asset::default(), PackageKind::AppImage, Some(&existing), None, &mut job).unwrap_err(),+ expected+ );+ assert_eq!(uninstall(app, &existing, None, &paths, &stop, &mut phase).unwrap_err(), expected);+ assert!(phases.is_empty(), "neither operation reaches a download or installer phase");+ assert!(!paths.cache.exists());+ }+ #[test] fn an_unavailable_custom_folder_is_rejected_before_downloading() { use crate::http::tests::{Reply, body, serve};@@ -226,7 +226,30 @@ fn explorer_select_arg(path: &Path) -> Option<std::ffi::OsString> { } /// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running.-pub fn is_running(path: &Path) -> bool {+pub fn is_running(path: &Path) -> std::io::Result<bool> {+ #[cfg(target_os = "linux")]+ if !in_sandbox() {+ return linux::is_running(path);+ }+ Ok(is_running_with_helper(path))+}++/// Refuse a replacement or removal if an app is running or its state cannot be checked.+pub fn ensure_not_running(path: &Path, name: &str) -> Result<(), String> {+ match is_running(path) {+ Ok(false) => Ok(()),+ Ok(true) => Err(format!("{name} is running. Quit it, then try again.")),+ Err(error) => {+ log::warn!("couldn't check running application {}: {error}", path.display());+ Err(format!("Couldn't check whether {name} is running. Try again."))+ }+ }+}++#[cfg(target_os = "linux")]+mod linux;++fn is_running_with_helper(path: &Path) -> bool { if cfg!(windows) { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false }; let filter = format!("IMAGENAME eq {name}");@@ -297,13 +320,15 @@ mod tests { ("plain/PhotoCraft.app", "plain/PhotoCraft.app", true), ("Apps (work)/PhotoCraft.app", "Apps (work)/PhotoCraft.app", true), ("Apps [work]/PhotoCraft.app", "Apps [work]/PhotoCraft.app", true),+ ("trailing/PhotoCraft.app ", "trailing/PhotoCraft.app ", true),+ ("trailing/PhotoCraft.app", "trailing/PhotoCraft.app ", false), ("other/PhotoCraftXapp", "other/PhotoCraft.app", false), ("symbols/应用.^$*+?()[]{}|\\.app", "symbols/应用.^$*+?()[]{}|\\.app", true), ] { let actual = format!("{root}/{actual}"); let requested = format!("{root}/{requested}"); let _probe = RunningProbe::start(&actual);- assert_eq!(is_running(Path::new(&requested)), expected, "{requested}");+ assert_eq!(is_running(Path::new(&requested)).unwrap(), expected, "{requested}"); } } }new file mode 100644@@ -0,0 +1,208 @@+//! Native Linux process checks read command-line bytes rather than locale-dependent display text.+use std::ffi::OsStr;+use std::io::{self, Read};+use std::os::unix::ffi::OsStrExt;+use std::path::Path;+use std::time::{Duration, Instant};++pub(super) fn is_running(path: &Path) -> io::Result<bool> {+ scan(Path::new("/proc"), path.as_os_str().as_bytes(), std::process::id(), Instant::now() + Duration::from_secs(5))+}++fn scan(root: &Path, query: &[u8], own_pid: u32, deadline: Instant) -> io::Result<bool> {+ let mut matcher = Matcher::new(query)?;+ let mut incomplete = None;+ for entry in std::fs::read_dir(root)? {+ check_deadline(deadline)?;+ let entry = entry?;+ let name = entry.file_name();+ let Some(pid) = numeric_pid(&name) else { continue };+ if pid == own_pid {+ continue;+ }+ matcher.reset();+ let result = std::fs::File::open(entry.path().join("cmdline")).and_then(|file| matches_command_line(file, &mut matcher, deadline));+ match result {+ Ok(true) => return Ok(true),+ Ok(false) => {}+ // Processes can exit during the scan. As with pgrep, inaccessible processes are+ // outside this best-effort observation, including hidepid-protected processes.+ Err(error) if process_unavailable(&error) => {}+ Err(error) => incomplete = Some(error),+ }+ }+ check_deadline(deadline)?;+ match incomplete {+ Some(error) => Err(error),+ None => Ok(false),+ }+}++fn process_unavailable(error: &io::Error) -> bool {+ // ESRCH is Linux errno 3. A task may disappear after its cmdline file was opened.+ error.raw_os_error() == Some(3) || matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied)+}++fn numeric_pid(name: &OsStr) -> Option<u32> {+ let bytes = name.as_bytes();+ if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {+ return None;+ }+ std::str::from_utf8(bytes).ok()?.parse().ok()+}++fn check_deadline(deadline: Instant) -> io::Result<()> {+ if Instant::now() >= deadline { Err(io::Error::new(io::ErrorKind::TimedOut, "process scan did not finish within five seconds")) } else { Ok(()) }+}++fn matches_command_line(mut reader: impl Read, matcher: &mut Matcher<'_>, deadline: Instant) -> io::Result<bool> {+ let mut buffer = [0; 8192];+ let mut separators = 0usize;+ loop {+ check_deadline(deadline)?;+ let count = match reader.read(&mut buffer) {+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,+ result => result?,+ };+ if count == 0 {+ check_deadline(deadline)?;+ // procps drops all terminal NULs, including empty final arguments. Actual spaces+ // in the final argument remain searchable; they have already reached the matcher.+ return Ok(false);+ }+ for &byte in &buffer[..count] {+ if byte == 0 {+ separators =+ separators.checked_add(1).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "too many command-line separators"))?;+ continue;+ }+ while separators > 0 {+ if separators % buffer.len() == 0 {+ check_deadline(deadline)?;+ }+ if matcher.feed(b' ') {+ return Ok(true);+ }+ separators -= 1;+ }+ if matcher.feed(byte) {+ return Ok(true);+ }+ }+ }+}++/// A linear-time literal search carrying only query-sized state between fixed-size reads.+struct Matcher<'a> {+ query: &'a [u8],+ prefix: Vec<usize>,+ matched: usize,+}++impl<'a> Matcher<'a> {+ fn new(query: &'a [u8]) -> io::Result<Self> {+ if query.is_empty() || query.contains(&0) {+ return Err(io::Error::new(io::ErrorKind::InvalidInput, "application path is empty or contains NUL"));+ }+ let mut prefix = Vec::new();+ prefix.try_reserve_exact(query.len()).map_err(io::Error::other)?;+ prefix.resize(query.len(), 0);+ let mut matched = 0;+ for index in 1..query.len() {+ while matched > 0 && query.get(index) != query.get(matched) {+ matched = prefix[matched - 1];+ }+ if query.get(index) == query.get(matched) {+ matched += 1;+ }+ prefix[index] = matched;+ }+ Ok(Self { query, prefix, matched: 0 })+ }++ fn reset(&mut self) {+ self.matched = 0;+ }++ fn feed(&mut self, byte: u8) -> bool {+ while self.matched > 0 && self.query.get(self.matched) != Some(&byte) {+ self.matched = self.prefix[self.matched - 1];+ }+ if self.query.get(self.matched) == Some(&byte) {+ self.matched += 1;+ }+ self.matched == self.query.len()+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ fn deadline() -> Instant {+ Instant::now() + Duration::from_secs(5)+ }++ fn matches(bytes: &[u8], query: &[u8]) -> bool {+ matches_command_line(bytes, &mut Matcher::new(query).unwrap(), deadline()).unwrap()+ }++ #[test]+ fn literal_command_lines_preserve_wrappers_and_argument_spacing() {+ let bytes = b"/bin/sh\0/Apps (work)/\xe5\xba\x94\xe7\x94\xa8.AppImage\0--name\0\0Inter\0";+ assert!(matches(bytes, b"/Apps (work)/\xe5\xba\x94\xe7\x94\xa8.AppImage"));+ assert!(matches(bytes, b"--name Inter"));+ assert!(!matches(bytes, b"Inter "));+ assert!(!matches(bytes, b"/Apps (work)/\xe5\xba\x94\xe7\x94\xa8XAppImage"));+ assert!(matches(b"/fonts/\xff/app\0", b"/fonts/\xff/app"));+ assert!(matches(b"prefix\0/Apps/PhotoCraft.app/Contents/MacOS/app\0", b"/Apps/PhotoCraft.app"));+ assert!(!matches(b"", b"app"));+ assert!(!matches(b"/app\0\0\0", b"/app "));+ assert!(matches(b"/app \0\0\0", b"/app "));+ assert!(matches(b"/app\0\0tail\0\0", b"/app tail"));+ }++ #[test]+ fn matches_across_reads_without_retaining_the_command_line() {+ let mut bytes = vec![b'x'; 8191];+ bytes.extend_from_slice(b"/path with spaces/app\0");+ assert!(matches(&bytes, b"/path with spaces/app"));+ assert!(matches(b"aaaaab\0", b"aaab"));+ let mut separated = vec![b'x'; 8191];+ separated.extend_from_slice(b"\0\0tail\0\0");+ assert!(matches(&separated, b"x tail"));+ assert!(!matches(&separated, b"tail "));+ assert!(Matcher::new(b"").is_err());+ assert!(Matcher::new(b"/bad\0path").is_err());+ }++ #[test]+ fn incomplete_scans_are_errors_and_names_are_numeric() {+ let mut matcher = Matcher::new(b"app").unwrap();+ assert_eq!(matches_command_line(&b"app\0"[..], &mut matcher, Instant::now()).unwrap_err().kind(), io::ErrorKind::TimedOut);+ assert!(scan(Path::new("/proc/no-such-artcraft-process-root"), b"app", 0, deadline()).is_err());+ assert_eq!(numeric_pid(OsStr::new("123")), Some(123));+ assert_eq!(numeric_pid(OsStr::new("123x")), None);+ assert_eq!(numeric_pid(OsStr::new("self")), None);+ assert_eq!(numeric_pid(OsStr::new("")), None);+ assert!(process_unavailable(&io::Error::from_raw_os_error(3)));+ assert!(process_unavailable(&io::Error::from(io::ErrorKind::NotFound)));+ assert!(process_unavailable(&io::Error::from(io::ErrorKind::PermissionDenied)));+ assert!(!process_unavailable(&io::Error::from(io::ErrorKind::Other)));+ }++ #[test]+ fn scanning_skips_self_and_disappeared_entries() {+ let temp = tempfile::tempdir().unwrap();+ for pid in [12, 13] {+ std::fs::create_dir(temp.path().join(pid.to_string())).unwrap();+ }+ std::fs::write(temp.path().join("12/cmdline"), b"/app\0").unwrap();+ assert!(!scan(temp.path(), b"/app", 12, deadline()).unwrap());+ std::fs::write(temp.path().join("13/cmdline"), b"/app\0").unwrap();+ assert!(scan(temp.path(), b"/app", 12, deadline()).unwrap());+ std::fs::remove_file(temp.path().join("13/cmdline")).unwrap();+ std::fs::create_dir(temp.path().join("13/cmdline")).unwrap();+ assert!(scan(temp.path(), b"/app", 12, deadline()).is_err());+ }+}@@ -330,3 +330,5 @@ filter Installed Nainstalováno Replace what's there? Nahradit, co tam je? {path} already exists. Installing {name} will overwrite it. {path} už existuje. Instalace aplikace {name} ho přepíše. Replace Nahradit++@msg Couldn't check whether {name} is running. Try again. Nepodařilo se zjistit, zda je aplikace {name} spuštěná. Zkuste to znovu.@@ -362,3 +362,5 @@ filter Installed Installiert Replace what's there? Vorhandenes ersetzen? {path} already exists. Installing {name} will overwrite it. {path} existiert bereits. Die Installation von {name} überschreibt es. Replace Ersetzen++@msg Couldn't check whether {name} is running. Try again. Es konnte nicht geprüft werden, ob {name} läuft. Versuche es erneut.@@ -343,3 +343,5 @@ filter Installed Instaladas Replace what's there? ¿Reemplazar lo que hay? {path} already exists. Installing {name} will overwrite it. {path} ya existe. Instalar {name} lo sobrescribirá. Replace Reemplazar++@msg Couldn't check whether {name} is running. Try again. No se pudo comprobar si {name} está abierta. Inténtalo de nuevo.@@ -360,3 +360,5 @@ filter Installed Installées Replace what's there? Remplacer l’existant ? {path} already exists. Installing {name} will overwrite it. {path} existe déjà. L’installation de {name} l’écrasera. Replace Remplacer++@msg Couldn't check whether {name} is running. Try again. Impossible de vérifier si {name} est en cours d'exécution. Réessayez.@@ -363,3 +363,5 @@ filter Installed Telepítve Replace what's there? Lecseréli, ami ott van? {path} already exists. Installing {name} will overwrite it. {path} már létezik. A(z) {name} telepítése felülírja. Replace Csere++@msg Couldn't check whether {name} is running. Try again. Nem sikerült ellenőrizni, hogy a(z) {name} fut-e. Próbáld újra.@@ -365,3 +365,5 @@ filter Installed インストール済み Replace what's there? 既存の項目を置き換えますか? {path} already exists. Installing {name} will overwrite it. {path} は既に存在します。{name} をインストールすると上書きされます。 Replace 置き換える++@msg Couldn't check whether {name} is running. Try again. {name} が実行中かどうか確認できませんでした。もう一度お試しください。@@ -364,3 +364,5 @@ filter Installed Zainstalowane Replace what's there? Zastąpić to, co tam jest? {path} already exists. Installing {name} will overwrite it. {path} już istnieje. Instalacja {name} go nadpisze. Replace Zastąp++@msg Couldn't check whether {name} is running. Try again. Nie można sprawdzić, czy aplikacja {name} jest uruchomiona. Spróbuj ponownie.@@ -350,3 +350,5 @@ filter Installed Instalados Replace what's there? Substituir o que já existe? {path} already exists. Installing {name} will overwrite it. {path} já existe. Instalar o {name} vai substituí-lo. Replace Substituir++@msg Couldn't check whether {name} is running. Try again. Não foi possível verificar se {name} está em execução. Tente novamente.@@ -358,3 +358,5 @@ filter Installed Установлены Replace what's there? Заменить существующее? {path} already exists. Installing {name} will overwrite it. {path} уже существует. Установка {name} перезапишет его. Replace Заменить++@msg Couldn't check whether {name} is running. Try again. Не удалось проверить, запущено ли приложение {name}. Повторите попытку.@@ -330,3 +330,5 @@ figcraft Interface design and prototyping 界面设计与原型制作 Replace what's there? 替换现有内容? {path} already exists. Installing {name} will overwrite it. {path} 已存在。安装 {name} 将覆盖它。 Replace 替换++@msg Couldn't check whether {name} is running. Try again. 无法检查 {name} 是否正在运行。请重试。