Add an uninstall script (#21213)

Stanislav Alekseev created

Closes #14306

This looks at what #16660 did and install.sh script as a base for the
uninstall.sh script. The script is bundled with the cli by default
unless the cli/no-bundled-uninstall feature is selected which is done,
so package managers could build zed without bundling a useless feature
and increasing binary size.

I don't have capabilities to test this right now, so any help with that
is appreciated.

Release Notes:

- Added an uninstall script for Zed installations done via zed.dev. To
uninstall zed, run `zed --uninstall` via the CLI binary.

Change summary

crates/cli/Cargo.toml  |   4 +
crates/cli/build.rs    |   5 +
crates/cli/src/main.rs |  30 ++++++++
script/uninstall.sh    | 158 ++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 197 insertions(+)

Detailed changes

crates/cli/Cargo.toml 🔗

@@ -16,6 +16,10 @@ doctest = false
 name = "cli"
 path = "src/main.rs"
 
+[features]
+no-bundled-uninstall = []
+default = []
+
 [dependencies]
 anyhow.workspace = true
 clap.workspace = true

crates/cli/build.rs 🔗

@@ -0,0 +1,5 @@
+fn main() {
+    if std::env::var("ZED_UPDATE_EXPLANATION").is_ok() {
+        println!(r#"cargo:rustc-cfg=feature="no-bundled-uninstall""#);
+    }
+}

crates/cli/src/main.rs 🔗

@@ -59,6 +59,13 @@ struct Args {
     /// Run zed in dev-server mode
     #[arg(long)]
     dev_server_token: Option<String>,
+    /// Uninstall Zed from user system
+    #[cfg(all(
+        any(target_os = "linux", target_os = "macos"),
+        not(feature = "no-bundled-uninstall")
+    ))]
+    #[arg(long)]
+    uninstall: bool,
 }
 
 fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
@@ -119,6 +126,29 @@ fn main() -> Result<()> {
         return Ok(());
     }
 
+    #[cfg(all(
+        any(target_os = "linux", target_os = "macos"),
+        not(feature = "no-bundled-uninstall")
+    ))]
+    if args.uninstall {
+        static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh");
+
+        let tmp_dir = tempfile::tempdir()?;
+        let script_path = tmp_dir.path().join("uninstall.sh");
+        fs::write(&script_path, UNINSTALL_SCRIPT)?;
+
+        use std::os::unix::fs::PermissionsExt as _;
+        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
+
+        let status = std::process::Command::new("sh")
+            .arg(&script_path)
+            .env("ZED_CHANNEL", &*release_channel::RELEASE_CHANNEL_NAME)
+            .status()
+            .context("Failed to execute uninstall script")?;
+
+        std::process::exit(status.code().unwrap_or(1));
+    }
+
     let (server, server_name) =
         IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
     let url = format!("zed-cli://{server_name}");

script/uninstall.sh 🔗

@@ -0,0 +1,158 @@
+#!/usr/bin/env sh
+set -eu
+
+# Uninstalls Zed that was installed using the install.sh script
+
+check_remaining_installations() {
+    platform="$(uname -s)"
+    if [ "$platform" = "Darwin" ]; then
+        # Check for any Zed variants in /Applications
+        remaining=$(ls -d /Applications/Zed*.app 2>/dev/null | wc -l)
+        [ "$remaining" -eq 0 ]
+    else
+        # Check for any Zed variants in ~/.local
+        remaining=$(ls -d "$HOME/.local/zed"*.app 2>/dev/null | wc -l)
+        [ "$remaining" -eq 0 ]
+    fi
+}
+
+prompt_remove_preferences() {
+    printf "Do you want to keep your Zed preferences? [Y/n] "
+    read -r response
+    case "$response" in
+        [nN]|[nN][oO])
+            rm -rf "$HOME/.config/zed"
+            echo "Preferences removed."
+            ;;
+        *)
+            echo "Preferences kept."
+            ;;
+    esac
+}
+
+main() {
+    platform="$(uname -s)"
+    channel="${ZED_CHANNEL:-stable}"
+
+    if [ "$platform" = "Darwin" ]; then
+        platform="macos"
+    elif [ "$platform" = "Linux" ]; then
+        platform="linux"
+    else
+        echo "Unsupported platform $platform"
+        exit 1
+    fi
+
+    "$platform"
+
+    echo "Zed has been uninstalled"
+}
+
+linux() {
+    suffix=""
+    if [ "$channel" != "stable" ]; then
+        suffix="-$channel"
+    fi
+
+    appid=""
+    db_suffix="stable"
+    case "$channel" in
+      stable)
+        appid="dev.zed.Zed"
+        db_suffix="stable"
+        ;;
+      nightly)
+        appid="dev.zed.Zed-Nightly"
+        db_suffix="nightly"
+        ;;
+      preview)
+        appid="dev.zed.Zed-Preview"
+        db_suffix="preview"
+        ;;
+      dev)
+        appid="dev.zed.Zed-Dev"
+        db_suffix="dev"
+        ;;
+      *)
+        echo "Unknown release channel: ${channel}. Using stable app ID."
+        appid="dev.zed.Zed"
+        db_suffix="stable"
+        ;;
+    esac
+
+    # Remove the app directory
+    rm -rf "$HOME/.local/zed$suffix.app"
+
+    # Remove the binary symlink
+    rm -f "$HOME/.local/bin/zed"
+
+    # Remove the .desktop file
+    rm -f "$HOME/.local/share/applications/${appid}.desktop"
+
+    # Remove the database directory for this channel
+    rm -rf "$HOME/.local/share/zed/db/0-$db_suffix"
+
+    # Remove socket file
+    rm -f "$HOME/.local/share/zed/zed-$db_suffix.sock"
+
+    # Remove the entire Zed directory if no installations remain
+    if check_remaining_installations; then
+        rm -rf "$HOME/.local/share/zed"
+        prompt_remove_preferences
+    fi
+
+    rm -rf $HOME/.zed_server
+}
+
+macos() {
+    app="Zed.app"
+    db_suffix="stable"
+    app_id="dev.zed.Zed"
+    case "$channel" in
+      nightly)
+        app="Zed Nightly.app"
+        db_suffix="nightly"
+        app_id="dev.zed.Zed-Nightly"
+        ;;
+      preview)
+        app="Zed Preview.app"
+        db_suffix="preview"
+        app_id="dev.zed.Zed-Preview"
+        ;;
+      dev)
+        app="Zed Dev.app"
+        db_suffix="dev"
+        app_id="dev.zed.Zed-Dev"
+        ;;
+    esac
+
+    # Remove the app bundle
+    if [ -d "/Applications/$app" ]; then
+        rm -rf "/Applications/$app"
+    fi
+
+    # Remove the binary symlink
+    rm -f "$HOME/.local/bin/zed"
+
+    # Remove the database directory for this channel
+    rm -rf "$HOME/Library/Application Support/Zed/db/0-$db_suffix"
+
+    # Remove app-specific files and directories
+    rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/$app_id.sfl"*
+    rm -rf "$HOME/Library/Caches/$app_id"
+    rm -rf "$HOME/Library/HTTPStorages/$app_id"
+    rm -rf "$HOME/Library/Preferences/$app_id.plist"
+    rm -rf "$HOME/Library/Saved Application State/$app_id.savedState"
+
+    # Remove the entire Zed directory if no installations remain
+    if check_remaining_installations; then
+        rm -rf "$HOME/Library/Application Support/Zed"
+        rm -rf "$HOME/Library/Logs/Zed"
+
+        prompt_remove_preferences
+    fi
+
+    rm -rf $HOME/.zed_server
+}
+
+main "$@"