From 667a3bb0e5e071c7ea3cc907a6ba32ae9b6fd27f Mon Sep 17 00:00:00 2001 From: Alex Cheema <41707476+AlexCheema@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:06:02 +0100 Subject: [PATCH] feat: keep-models option when uninstalling EXO (#1997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a **Keep downloaded models (~/.exo/models)** checkbox to the macOS uninstall confirmation dialog (Settings → Advanced → Danger Zone). The full `~/.exo` directory is now removed on uninstall by default; if the checkbox is checked, `~/.exo/models` is preserved. - The standalone `app/EXO/uninstall-exo.sh` gains a matching `--keep-models` flag and the same `~/.exo` cleanup so GUI and CLI flows stay in sync. Resolves the user home via `$SUDO_USER` since the script runs under `sudo`. Previously, "Uninstall EXO" only cleaned up system-level components (LaunchDaemon, network location, logs, app bundle) and left the entire `~/.exo` directory behind. Now uninstalling actually removes EXO's user data, with a one-click opt-out for the (potentially many GB) of downloaded models. ![Uninstall dialog with new checkbox](https://raw.githubusercontent.com/exo-explore/exo/703b7fbbf13441217ad2903bb199f07e92af4490/uninstall-dialog.png) > Note: the rendered icon in the screenshot above is the generic system folder icon because it was captured from a small standalone Swift binary (no app bundle / icon resource). When triggered from the actual EXO.app, the EXO app icon is shown. ## Test plan - [ ] Build EXO.app locally; open Settings → Advanced → Danger Zone → Uninstall EXO; confirm the new "Keep downloaded models (~/.exo/models)" checkbox is present and unchecked by default. - [ ] Uninstall with the checkbox **checked** → `~/.exo/models/` survives, all other entries under `~/.exo` are gone, system components removed, app moved to Trash. - [ ] Uninstall with the checkbox **unchecked** → `~/.exo` is fully removed. - [ ] `sudo app/EXO/uninstall-exo.sh --keep-models` → `~/.exo/models/` is preserved, the rest of `~/.exo` is removed. - [ ] `sudo app/EXO/uninstall-exo.sh` (no flag) → `~/.exo` is fully removed. - [ ] `app/EXO/uninstall-exo.sh --help` prints usage and exits 0; unknown args exit 2 with a usage hint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Evan --- app/EXO/EXO/Views/SettingsView.swift | 33 +++++++++++++++++-- app/EXO/uninstall-exo.sh | 49 +++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift index 52184e303..425cc7600 100644 --- a/app/EXO/EXO/Views/SettingsView.swift +++ b/app/EXO/EXO/Views/SettingsView.swift @@ -552,15 +552,24 @@ struct SettingsView: View { let alert = NSAlert() alert.messageText = "Uninstall EXO" alert.informativeText = """ - This will remove EXO and all its system components: + This will remove EXO and all its components: • Network configuration daemon • Launch at login registration • EXO network location + • EXO data directory (~/.exo) The app will be moved to Trash. """ alert.alertStyle = .warning + + let checkbox = NSButton( + checkboxWithTitle: "Keep downloaded models (~/.exo/models)", + target: nil, action: nil) + checkbox.state = .off + checkbox.sizeToFit() + alert.accessoryView = checkbox + alert.addButton(withTitle: "Uninstall") alert.addButton(withTitle: "Cancel") @@ -570,11 +579,11 @@ struct SettingsView: View { let response = alert.runModal() if response == .alertFirstButtonReturn { - performUninstall() + performUninstall(keepModels: checkbox.state == .on) } } - private func performUninstall() { + private func performUninstall(keepModels: Bool) { uninstallInProgress = true controller.cancelPendingLaunch() @@ -584,6 +593,7 @@ struct SettingsView: View { DispatchQueue.global(qos: .utility).async { do { try NetworkSetupHelper.uninstall() + try Self.removeExoDirectory(keepModels: keepModels) DispatchQueue.main.async { LaunchAtLoginHelper.disable() @@ -607,6 +617,23 @@ struct SettingsView: View { } } + private static func removeExoDirectory(keepModels: Bool) throws { + let fm = FileManager.default + let exoDir = ExoProcessController.exoDirectoryURL + guard fm.fileExists(atPath: exoDir.path) else { return } + + if !keepModels { + try fm.removeItem(at: exoDir) + return + } + + let contents = try fm.contentsOfDirectory( + at: exoDir, includingPropertiesForKeys: nil, options: []) + for entry in contents where entry.lastPathComponent != "models" { + try? fm.removeItem(at: entry) + } + } + private func moveAppToTrash() { guard let appURL = Bundle.main.bundleURL as URL? else { return } do { diff --git a/app/EXO/uninstall-exo.sh b/app/EXO/uninstall-exo.sh index d10b9fb3c..b14490df8 100755 --- a/app/EXO/uninstall-exo.sh +++ b/app/EXO/uninstall-exo.sh @@ -3,18 +3,41 @@ # EXO Uninstaller Script # # This script removes all EXO system components that persist after deleting the app. -# Run with: sudo ./uninstall-exo.sh +# Run with: sudo ./uninstall-exo.sh [--keep-models] +# +# Options: +# --keep-models Preserve ~/.exo/models when removing the EXO data directory. # # Components removed: # - LaunchDaemon: /Library/LaunchDaemons/io.exo.networksetup.plist # - Network script: /Library/Application Support/EXO/ # - Log files: /var/log/io.exo.networksetup.* # - Network location: "exo" +# - EXO data directory: ~/.exo (or all of ~/.exo except models/ when --keep-models is set) # - Launch at login registration # set -euo pipefail +KEEP_MODELS=0 +for arg in "$@"; do + case "$arg" in + --keep-models) + KEEP_MODELS=1 + ;; + -h | --help) + echo "Usage: sudo ./uninstall-exo.sh [--keep-models]" + echo " --keep-models Preserve ~/.exo/models when removing the EXO data directory." + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: sudo ./uninstall-exo.sh [--keep-models]" >&2 + exit 2 + ;; + esac +done + LABEL="io.exo.networksetup" # Current script path. Older installs used a different filename; keep the # legacy path here so a fresh uninstall still cleans up upgraded machines. @@ -25,6 +48,10 @@ LOG_OUT="/var/log/${LABEL}.log" LOG_ERR="/var/log/${LABEL}.err.log" APP_BUNDLE_ID="io.exo.EXO" +# Resolve the invoking user's home, even when run via sudo. +USER_HOME="$(eval echo "~${SUDO_USER:-$USER}")" +EXO_DIR="$USER_HOME/.exo" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -124,6 +151,22 @@ if networksetup -listnetworkservices 2>/dev/null | grep -q "Thunderbolt Bridge"; echo_info "Re-enabled Thunderbolt Bridge" fi +# Remove EXO data directory (~/.exo) +EXO_DIR_REMOVED="" +if [[ -d $EXO_DIR ]]; then + if [[ $KEEP_MODELS == "1" && -d "$EXO_DIR/models" ]]; then + find "$EXO_DIR" -mindepth 1 -maxdepth 1 ! -name models -exec rm -rf {} + + EXO_DIR_REMOVED="kept_models" + echo_info "Removed ~/.exo (preserved models/)" + else + rm -rf "$EXO_DIR" + EXO_DIR_REMOVED="full" + echo_info "Removed ~/.exo" + fi +else + echo_warn "~/.exo not found (already removed?)" +fi + # Note about launch at login registration # SMAppService-based login items cannot be removed from a shell script. # They can only be unregistered from within the app itself or manually via System Settings. @@ -153,6 +196,10 @@ echo " • Network setup LaunchDaemon" echo " • Network configuration script" echo " • Log files" echo " • 'exo' network location" +case "$EXO_DIR_REMOVED" in +full) echo " • EXO data directory (~/.exo)" ;; +kept_models) echo " • EXO data directory (~/.exo, models preserved)" ;; +esac echo "" echo "Your network has been restored to use the 'Automatic' location." echo "Thunderbolt Bridge has been re-enabled (if present)."