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
@@ -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};
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
@@ -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}");
         }
     }
 }
diff --git a/crates/native/src/process/linux.rs b/crates/native/src/process/linux.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/process/linux.rs
@@ -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());
+    }
+}
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
@@ -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.
diff --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
@@ -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.
diff --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
@@ -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.
diff --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
@@ -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.
diff --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
@@ -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.
diff --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
@@ -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} が実行中かどうか確認できませんでした。もう一度お試しください。
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
@@ -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.
diff --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
@@ -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.
diff --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
@@ -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}. Повторите попытку.
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
@@ -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} 是否正在运行。请重试。
