diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -130,6 +130,36 @@ on macOS, `%LOCALAPPDATA%\ArtCraft Launcher` on Windows, `ArtCraftLauncherData`
 portable zip's executable) and keeps the previous two runs as `artcraft-launcher.1.log` and
 `artcraft-launcher.2.log`. `RUST_LOG` changes what it records (`debug`, `info,zbus=warn`, …).
 
+### Windows apps in custom folders
+
+If you unpack Craft apps yourself into another folder or drive, you can opt in to detecting
+those copies by creating `%LOCALAPPDATA%\ArtCraft Launcher\external-apps.json`. For a portable
+copy of ArtCraft Launcher, the file goes in `ArtCraftLauncherData\data\external-apps.json`
+beside the launcher's executable instead.
+
+```json
+{
+  "apps": {
+    "photocraft": {
+      "path": "D:\\CreativeApps\\PhotoCraft\\photocraft.exe",
+      "version": "0.7.0"
+    }
+  }
+}
+```
+
+Use the app's lowercase catalog id and an absolute path to its existing executable. The file
+name must be one of that app's release names, such as `photocraft.exe` (`printcraft.exe` also
+works for PdfCraft). `version` is optional; omit it if you do not know the installed version,
+and keep it current when updating the app yourself. Restart ArtCraft Launcher after editing
+the file.
+
+These copies can be opened from the launcher, but are updated and removed with the tool or
+method you used to install them. The launcher does not scan custom folders or run binaries to
+discover versions. Its own installation records and Windows Installer entries take priority.
+Without this optional file, detection is unchanged. Malformed files and files larger than
+64 KiB are ignored; the log explains why. Remove an entry to stop detecting that copy.
+
 ## Downloads
 
 Download the build for your computer from the [latest release](https://github.com/storytold/craft-launcher/releases/latest), or browse [all releases](https://github.com/storytold/craft-launcher/releases). Every release has a `SHA256SUMS.txt` to check the files against. `<ver>` is the version number, for example `0.1.0`.
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
@@ -182,7 +182,7 @@ impl NativeBackend {
             // Leftovers from interrupted installs or older versions of the launcher. A paused
             // download stays a month; a finished one kept after a failed install, a week.
             http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400), Duration::from_secs(7 * 86_400));
-            let snapshot = crate::detect::Snapshot::take();
+            let snapshot = crate::detect::Snapshot::take(&paths);
             let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
             for app in &SUITE {
                 let found = crate::detect::detect(app, &manifest, &snapshot);
@@ -364,7 +364,7 @@ impl NativeBackend {
             report_partial(app, &shared, &paths, platform, &options);
             // Look again: the result is whatever is on disk now.
             let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
-            let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());
+            let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take(&paths));
             // One installer call can cover several apps, and fail after some of them went in.
             let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
             let result = match (result, latest) {
diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs
--- a/crates/native/src/detect.rs
+++ b/crates/native/src/detect.rs
@@ -1,8 +1,8 @@
 //! Finding Craft Apps that are already installed, however they got there.
 //!
 //! - macOS: `<App>.app` in `/Applications` or `~/Applications`, read from its `Info.plist`.
-//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable
-//!   copies the launcher unpacked.
+//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), portable
+//!   copies the launcher unpacked, and explicitly registered external executables.
 //! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them),
 //!   `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball
 //!   installs.
@@ -14,24 +14,37 @@ use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version};
 
 use crate::manifest::Manifest;
 
-/// One look at the system, shared by every app's detection (package lists are read once).
+/// One look at the system, shared by every app's detection (package lists and optional external
+/// app registrations are read once).
 #[derive(Default)]
 pub struct Snapshot {
     #[allow(dead_code)]
     packages: Vec<crate::packages::Package>,
+    #[cfg(windows)]
+    external_apps: external_windows::Apps,
 }
 
 impl Snapshot {
-    pub fn take() -> Self {
+    pub fn take(paths: &crate::paths::Paths) -> Self {
+        let _ = paths;
         #[cfg(target_os = "linux")]
         {
             Self { packages: linux::packages() }
         }
-        #[cfg(not(target_os = "linux"))]
+        #[cfg(windows)]
+        {
+            Self { external_apps: external_windows::Apps::load(&paths.external_apps()), ..Self::default() }
+        }
+        #[cfg(not(any(target_os = "linux", windows)))]
         {
             Self::default()
         }
     }
+
+    #[cfg(windows)]
+    fn external(&self, app: &CraftApp) -> Option<Installed> {
+        self.external_apps.detect(app)
+    }
 }
 
 /// What's installed of `app`, best source first.
@@ -43,7 +56,7 @@ pub fn detect(app: &CraftApp, manifest: &Manifest, snapshot: &Snapshot) -> Optio
     #[cfg(target_os = "macos")]
     return macos::detect(app);
     #[cfg(windows)]
-    return windows::detect(app);
+    return windows::detect(app).or_else(|| snapshot.external(app));
     #[cfg(target_os = "linux")]
     return linux::detect(app, snapshot);
     #[cfg(target_os = "freebsd")]
@@ -52,6 +65,80 @@ pub fn detect(app: &CraftApp, manifest: &Manifest, snapshot: &Snapshot) -> Optio
     None
 }
 
+/// Explicitly selected executables remain owned by the user, separate from `installed.json`.
+#[cfg(windows)]
+mod external_windows {
+    use std::collections::BTreeMap;
+    use std::io::{ErrorKind, Read};
+
+    use serde::Deserialize;
+
+    use super::*;
+
+    const MAX_BYTES: u64 = 64 * 1024;
+
+    #[derive(Default, Deserialize)]
+    pub(super) struct Apps {
+        apps: BTreeMap<String, App>,
+    }
+
+    #[derive(Deserialize)]
+    struct App {
+        path: PathBuf,
+        #[serde(default)]
+        version: Option<String>,
+    }
+
+    impl Apps {
+        pub(super) fn load(path: &Path) -> Self {
+            match Self::read(path) {
+                Ok(apps) => apps,
+                Err(e) => {
+                    log::warn!("ignoring external-apps.json ({e}); check the file's format, size and permissions");
+                    Self::default()
+                }
+            }
+        }
+
+        fn read(path: &Path) -> Result<Self, String> {
+            let file = match std::fs::File::open(path) {
+                Ok(file) => file,
+                Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
+                Err(e) => return Err(e.to_string()),
+            };
+            let metadata = file.metadata().map_err(|e| e.to_string())?;
+            if !metadata.is_file() || metadata.len() > MAX_BYTES {
+                return Err("expected a regular file no larger than 64 KiB".into());
+            }
+            // Bound the read too: the file can grow after the metadata check.
+            let mut bytes = Vec::new();
+            file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
+            if bytes.len() as u64 > MAX_BYTES {
+                return Err("the file is larger than 64 KiB".into());
+            }
+            serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON or app registration at line {}, column {}", e.line(), e.column()))
+        }
+
+        pub(super) fn detect(&self, app: &CraftApp) -> Option<Installed> {
+            let rec = self.apps.get(app.id)?;
+            let path = &rec.path;
+            if !path.is_absolute() || !path.is_file() || !path.extension()?.eq_ignore_ascii_case("exe") {
+                return None;
+            }
+            let stem = path.file_stem()?.to_str()?;
+            if !app.stems().any(|s| s.eq_ignore_ascii_case(stem)) {
+                return None;
+            }
+            Some(Installed {
+                version: rec.version.as_deref().and_then(version::parse),
+                source: InstallSource::Binary,
+                location: path.display().to_string(),
+                package: None,
+            })
+        }
+    }
+}
+
 fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {
     let rec = manifest.apps.get(app.id)?;
     if !rec.path.exists() {
@@ -610,3 +697,183 @@ mod tests {
         }
     }
 }
+
+#[cfg(all(test, windows))]
+#[allow(clippy::unwrap_used)]
+mod external_windows_tests {
+    use std::collections::BTreeMap;
+
+    use super::*;
+    use crate::paths::Paths;
+
+    fn create_exe(root: &Path, name: &str) -> PathBuf {
+        let exe = root.join("Creative Apps").join(name);
+        std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
+        // Detection must not run an external executable to learn its version.
+        std::fs::write(&exe, b"not executable").unwrap();
+        exe
+    }
+
+    fn register(paths: &Paths, id: &str, path: &Path, version: Option<&str>) {
+        let mut rec = serde_json::json!({ "path": path });
+        if let Some(version) = version {
+            rec["version"] = version.into();
+        }
+        let apps = BTreeMap::from([(id, rec)]);
+        std::fs::create_dir_all(&paths.data).unwrap();
+        std::fs::write(paths.external_apps(), serde_json::to_vec(&serde_json::json!({ "apps": apps })).unwrap()).unwrap();
+    }
+
+    fn external(paths: &Paths, id: &str) -> Option<Installed> {
+        Snapshot::take(paths).external(CraftApp::by_id(id).unwrap())
+    }
+
+    #[test]
+    fn registered_custom_exe_can_be_detected() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        register(&paths, "photocraft", &exe, Some("0.7.0"));
+
+        let installed = external(&paths, "photocraft").unwrap();
+        assert_eq!(installed.source, InstallSource::Binary);
+        assert_eq!(installed.location, exe.display().to_string());
+        assert_eq!(installed.version.unwrap().to_string(), "0.7.0");
+        assert_eq!(installed.package, None);
+    }
+
+    #[test]
+    fn external_detection_is_disabled_without_registration() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        create_exe(dir.path(), "photocraft.exe");
+        assert!(external(&paths, "photocraft").is_none());
+        assert!(!paths.data.exists(), "detection must not create a configuration or adopt a copy");
+    }
+
+    #[test]
+    fn absent_or_invalid_versions_are_unknown() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        for v in [None, Some("not a version"), Some("")] {
+            register(&paths, "photocraft", &exe, v);
+            assert!(external(&paths, "photocraft").unwrap().version.is_none());
+        }
+    }
+
+    #[test]
+    fn registration_accepts_case_insensitive_and_former_executable_names() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        for (id, name) in [("photocraft", "PhotoCraft.EXE"), ("pdfcraft", "PrintCraft.EXE")] {
+            let exe = create_exe(dir.path(), name);
+            register(&paths, id, &exe, Some("0.7.0"));
+            assert_eq!(external(&paths, id).unwrap().location, exe.display().to_string());
+        }
+    }
+
+    #[test]
+    fn registration_rejects_missing_relative_directory_or_other_app_paths() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let folder = dir.path().join("directory").join("photocraft.exe");
+        std::fs::create_dir_all(&folder).unwrap();
+        let other = create_exe(dir.path(), "wordcraft.exe");
+        let cli = create_exe(dir.path(), "photocraft-cli.exe");
+        let wrong_extension = create_exe(dir.path(), "photocraft.txt");
+        let candidates = [
+            dir.path().join("missing").join("photocraft.exe"),
+            "photocraft.exe".into(),
+            "C:photocraft.exe".into(),
+            folder,
+            other,
+            cli,
+            wrong_extension,
+        ];
+        for path in candidates {
+            register(&paths, "photocraft", &path, Some("0.7.0"));
+            assert!(external(&paths, "photocraft").is_none(), "{}", path.display());
+        }
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        register(&paths, "unknown-app", &exe, None);
+        assert!(external(&paths, "photocraft").is_none());
+    }
+
+    #[test]
+    fn malformed_and_oversized_registrations_are_ignored() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        std::fs::create_dir_all(&paths.data).unwrap();
+        for bytes in [b"{not json".to_vec(), br#"{"apps":{"photocraft":{"version":"0.7.0"}}}"#.to_vec(), vec![b' '; 64 * 1024 + 1]] {
+            std::fs::write(paths.external_apps(), bytes).unwrap();
+            assert!(external(&paths, "photocraft").is_none());
+        }
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        register(&paths, "photocraft", &exe, Some("0.7.0"));
+        let mut bytes = std::fs::read(paths.external_apps()).unwrap();
+        bytes.resize(64 * 1024, b' ');
+        std::fs::write(paths.external_apps(), &bytes).unwrap();
+        assert!(external(&paths, "photocraft").is_some(), "a valid file exactly at the cap is accepted");
+        bytes.push(b' ');
+        std::fs::write(paths.external_apps(), bytes).unwrap();
+        assert!(external(&paths, "photocraft").is_none(), "even valid JSON is ignored beyond the cap");
+    }
+
+    #[test]
+    fn registrations_are_read_once_per_pass_and_refresh_on_the_next_pass() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        let app = CraftApp::by_id("photocraft").unwrap();
+        register(&paths, app.id, &exe, Some("0.7.0"));
+        let snapshot = Snapshot::take(&paths);
+        register(&paths, app.id, &exe, Some("0.8.0"));
+        assert_eq!(snapshot.external(app).unwrap().version.unwrap().to_string(), "0.7.0");
+        assert_eq!(external(&paths, app.id).unwrap().version.unwrap().to_string(), "0.8.0");
+        std::fs::remove_file(paths.external_apps()).unwrap();
+        assert!(external(&paths, app.id).is_none());
+    }
+
+    #[test]
+    fn launcher_owned_installation_keeps_precedence() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        register(&paths, "photocraft", &exe, Some("0.7.0"));
+        let managed = create_exe(&dir.path().join("managed"), "photocraft.exe");
+        let manifest = Manifest {
+            apps: BTreeMap::from([(
+                "photocraft".into(),
+                crate::manifest::Record {
+                    version: "0.8.0".into(),
+                    kind: artcraft_launcher_core::PackageKind::PortableZip,
+                    path: managed.clone(),
+                    installed_at: 1,
+                    files: vec![],
+                    needs_root: false,
+                },
+            )]),
+        };
+        let installed = detect(CraftApp::by_id("photocraft").unwrap(), &manifest, &Snapshot::take(&paths)).unwrap();
+        assert_eq!(installed.source, InstallSource::Launcher);
+        assert_eq!(installed.location, managed.display().to_string());
+        assert_eq!(installed.version.unwrap().to_string(), "0.8.0");
+    }
+
+    #[test]
+    fn registered_apps_cannot_be_updated_or_removed_by_the_launcher() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        let app = CraftApp::by_id("photocraft").unwrap();
+        let exe = create_exe(dir.path(), "photocraft.exe");
+        register(&paths, app.id, &exe, None);
+        let installed = external(&paths, app.id).unwrap();
+        assert!(crate::install::managed_elsewhere(app, &installed, false).is_some());
+        let result = crate::install::uninstall(app, &installed, None, &paths, &crate::http::Stop::default(), &mut |_| {});
+        assert!(result.is_err());
+        assert!(exe.is_file());
+        assert!(!paths.manifest().exists());
+        assert_eq!(external(&paths, app.id), Some(installed));
+    }
+}
diff --git a/crates/native/src/paths.rs b/crates/native/src/paths.rs
--- a/crates/native/src/paths.rs
+++ b/crates/native/src/paths.rs
@@ -56,6 +56,11 @@ impl Paths {
         self.data.join("installed.json")
     }
 
+    /// Optional Windows executables the user registered, without giving the launcher ownership.
+    pub fn external_apps(&self) -> PathBuf {
+        self.data.join("external-apps.json")
+    }
+
     /// The launcher's own log files (`artcraft-launcher.log` and the previous runs').
     pub fn logs(&self) -> PathBuf {
         self.data.join("logs")
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
@@ -423,7 +423,7 @@ impl LauncherUi {
     fn announce_updates(&mut self, host: &mut dyn Host) {
         let mut fresh = Vec::new();
         for e in &self.entries {
-            if !self.in_filter(e, Filter::Updates) {
+            if !self.in_filter(e, Filter::Updates) || e.external_windows_copy(self.platform) {
                 continue;
             }
             let Some(v) = e.latest_label() else { continue };
@@ -460,7 +460,7 @@ impl LauncherUi {
         let to_update: Vec<(String, String)> = self
             .entries
             .iter()
-            .filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)
+            .filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform) && e.job.is_none() && !e.ready)
             .filter_map(|e| {
                 let v = e.latest_label()?;
                 if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
diff --git a/crates/ui-egui/src/model.rs b/crates/ui-egui/src/model.rs
--- a/crates/ui-egui/src/model.rs
+++ b/crates/ui-egui/src/model.rs
@@ -4,7 +4,7 @@ use artcraft_launcher_core::platform::{Platform, pick_asset};
 use artcraft_launcher_core::protocol::{JobPhase, LatestRelease, PausedDownload};
 use artcraft_launcher_core::release::Release;
 use artcraft_launcher_core::status::{self, AppStatus, Latest};
-use artcraft_launcher_core::{CraftApp, Installed};
+use artcraft_launcher_core::{CraftApp, InstallSource, Installed, Os};
 
 use crate::i18n::t;
 
@@ -36,6 +36,11 @@ impl AppEntry {
         }
     }
 
+    /// Explicitly registered Windows executables are opened here, but managed by their owner.
+    pub fn external_windows_copy(&self, platform: Option<Platform>) -> bool {
+        platform.is_some_and(|p| p.os == Os::Windows) && self.installed.as_ref().is_some_and(|i| i.source == InstallSource::Binary)
+    }
+
     /// Whether the latest release has something for `platform`.
     pub fn has_download(&self, platform: Option<Platform>) -> bool {
         let (Some(r), Some(p)) = (self.release(), platform) else { return false };
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
@@ -198,7 +198,7 @@ impl LauncherUi {
         }
         self.announcements(ui);
         ui.horizontal(|ui| {
-            let updates = self.count(Filter::Updates);
+            let updates = self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform)).count();
             let mut filters = vec![
                 (Filter::All, tl!("All"), tl!("Every Craft app"), None),
                 // The tab names a group ("installed ones"); the status pill says "Installed".
@@ -221,8 +221,12 @@ impl LauncherUi {
                         fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())])
                     };
                     if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
-                        let ids: Vec<&str> =
-                            self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
+                        let ids: Vec<&str> = self
+                            .entries
+                            .iter()
+                            .filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform) && e.job.is_none())
+                            .map(|e| e.app.id)
+                            .collect();
                         for id in ids {
                             self.send(Command::Install { app: id.to_string() });
                         }
@@ -663,6 +667,9 @@ impl LauncherUi {
                 (_, None) => Primary::Unavailable(tl!("Not available")),
             };
         }
+        if status.is_installed() && entry.external_windows_copy(self.platform) {
+            return Primary::Open;
+        }
         match status {
             AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into()),
             AppStatus::UpdateAvailable if entry.ready => {
@@ -745,6 +752,7 @@ impl LauncherUi {
         }
         if let Some(p) = entry.paused
             && self.caps.manage_apps
+            && !entry.external_windows_copy(self.platform)
         {
             let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() };
             // Resume, then discard and the menu (two squares).
@@ -811,6 +819,7 @@ impl LauncherUi {
     fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) {
         let id = entry.app.id.to_string();
         let manage = self.caps.manage_apps;
+        let external = entry.external_windows_copy(self.platform);
         let reveal = self.caps.reveal_label;
         widgets::more_menu(ui, |ui| {
             if entry.release().is_some() && ui.button(tl!("Release notes")).clicked() {
@@ -819,7 +828,7 @@ impl LauncherUi {
             }
             // Only where Install would download: an up-to-date app would be reinstalled.
             let can_download_again =
-                manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);
+                manage && !external && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);
             if can_download_again && ui.button(tl!("Download again")).clicked() {
                 self.send(Command::Cancel { app: id.clone() });
                 self.send(Command::Install { app: id.clone() });
@@ -834,14 +843,16 @@ impl LauncherUi {
                     self.send(Command::Reveal { app: id.clone() });
                     ui.close();
                 }
-                if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {
+                if !external && *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {
                     self.send(Command::Install { app: id.clone() });
                     ui.close();
                 }
-                ui.separator();
-                if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {
-                    self.dialog = Some(Dialog::ConfirmUninstall(id.clone()));
-                    ui.close();
+                if !external {
+                    ui.separator();
+                    if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {
+                        self.dialog = Some(Dialog::ConfirmUninstall(id.clone()));
+                        ui.close();
+                    }
                 }
             }
             if !manage && let (Some(r), Some(p)) = (entry.release(), self.platform) {
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
@@ -1496,3 +1496,128 @@ fn with_animations_on_hovering_a_card_animates() {
     let frames = frames_to_hover_the_next_card(&mut h);
     assert!(frames >= 10, "{frames} frames");
 }
+
+/// A Windows backend with an external portable PhotoCraft and a normal MSI EffectCraft.
+fn external_windows_harness(auto_update: bool) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
+    external_windows_harness_with_notifications(auto_update, false, Rc::default())
+}
+
+fn external_windows_harness_with_notifications(
+    auto_update: bool,
+    notify: bool,
+    notices: Rc<RefCell<Vec<(String, String)>>>,
+) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
+    struct Notifications(Rc<RefCell<Vec<(String, String)>>>);
+    impl Host for Notifications {
+        fn notify(&mut self, title: &str, body: &str) {
+            self.0.borrow_mut().push((title.into(), body.into()));
+        }
+    }
+    struct Windows(Fake);
+    impl Backend for Windows {
+        fn send(&mut self, command: Command) {
+            self.0.send(command);
+        }
+        fn poll(&mut self) -> Vec<Event> {
+            self.0.poll()
+        }
+        fn capabilities(&self) -> Capabilities {
+            Capabilities { manage_apps: true, reveal_label: "Show in Explorer", self_update: true }
+        }
+        fn platform(&self) -> Option<Platform> {
+            Some(Platform { os: Os::Windows, arch: Arch::X64 })
+        }
+        fn now(&self) -> i64 {
+            NOW
+        }
+    }
+    artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
+    let ev = events()
+        .into_iter()
+        .filter_map(|event| match event {
+            Event::Detected { app, installed } => Some(Event::Detected {
+                installed: installed.map(|copy| Installed {
+                    source: if app == "photocraft" || app == "lightcraft" { InstallSource::Binary } else { InstallSource::Msi },
+                    location: format!("D:\\CreativeApps\\{app}\\{app}.exe"),
+                    ..copy
+                }),
+                app,
+            }),
+            Event::Latest { app, latest: LatestRelease::Found(mut release), from } => {
+                for asset in &mut release.assets {
+                    asset.name = format!("{app}-{}-windows-x64.msi", release.tag.trim_start_matches('v'));
+                }
+                Some(Event::Latest { app, latest: LatestRelease::Found(release), from })
+            }
+            Event::Progress { .. } | Event::Paused { .. } | Event::ReadyToInstall { .. } => None,
+            other => Some(other),
+        })
+        .collect();
+    let sent = Rc::new(RefCell::new(Vec::new()));
+    let mut backend = Some(Windows(Fake {
+        events: ev,
+        sent: sent.clone(),
+        manage: true,
+        pictures: Rc::default(),
+        asked: Vec::new(),
+        clock: Rc::new(Cell::new(NOW)),
+    }));
+    let h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
+        move |ui, state: &mut Option<LauncherUi>| {
+            if state.is_none()
+                && let Some(b) = backend.take()
+            {
+                *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { view: ViewMode::Table, auto_update, notify, ..Settings::default() }));
+            }
+            if let Some(l) = state.as_mut() {
+                l.logic(&ui.ctx().clone(), &mut Notifications(notices.clone()));
+                l.ui(ui, &mut Notifications(notices.clone()));
+            }
+        },
+        None,
+    );
+    (h, sent)
+}
+
+#[test]
+fn external_windows_copies_open_and_stay_out_of_update_all_and_removal() {
+    let (mut h, sent) = external_windows_harness(false);
+    h.run_steps(8);
+    assert!(h.query_by_label("Update to 0.5.0").is_none());
+    h.get_by_label("Update to 0.6.0");
+    let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
+    h.get_all_by_label("Open").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
+    h.run_steps(2);
+    assert!(sent.borrow().contains(&Command::Launch { app: "photocraft".into() }));
+    h.get_by_label("Update 1 app").click();
+    h.run_steps(2);
+    let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();
+    assert_eq!(installs, ["effectcraft"]);
+    for name in ["PhotoCraft", "LightCraft"] {
+        let row = h.get_all_by_label(name).next().unwrap().rect().center().y;
+        h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
+        h.run_steps(3);
+        assert!(h.query_by_label("Uninstall…").is_none());
+        assert!(h.query_by_label("Reinstall").is_none());
+        h.get_by_label("Show in Explorer").click();
+        h.run_steps(2);
+    }
+    assert!(sent.borrow().contains(&Command::Reveal { app: "photocraft".into() }));
+    assert!(sent.borrow().contains(&Command::Reveal { app: "lightcraft".into() }));
+}
+
+#[test]
+fn automatic_updates_skip_external_windows_copies_but_keep_msi_updates() {
+    let (mut h, sent) = external_windows_harness(true);
+    h.run_steps(4);
+    let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();
+    assert_eq!(installs, ["effectcraft"]);
+}
+
+#[test]
+fn update_notifications_skip_external_windows_copies_but_keep_msi_updates() {
+    let notices = Rc::new(RefCell::new(Vec::new()));
+    let (mut h, _) = external_windows_harness_with_notifications(false, true, notices.clone());
+    h.run_steps(4);
+    assert_eq!(*notices.borrow(), [("EffectCraft 0.6.0 is available".into(), "Open ArtCraft Launcher to update.".into())]);
+}
