diff --git a/installer-script.py b/installer-script.py index 3482c63..f4d027e 100644 --- a/installer-script.py +++ b/installer-script.py @@ -879,12 +879,227 @@ def verify_installation(plugin_dir: Path | None = None, scripts_dir=None, return ok +# --------------------------------------------------------------------------- +# Uninstall logic +# --------------------------------------------------------------------------- + +def get_dir_size(path: Path) -> int: + """Return total size of a directory in bytes.""" + total = 0 + try: + for f in path.rglob("*"): + if f.is_file(): + total += f.stat().st_size + except OSError: + pass + return total + + +def format_size(size_bytes: int) -> str: + """Format bytes as human-readable string.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + + +def enumerate_installed_paths(scripts_dir=None, modules_dir=None, + config_dir=None, clipabit_dir=None): + """Return a list of (path, is_dir, description) for all installed artifacts.""" + scripts_dir, modules_dir = get_resolve_directories(scripts_dir, modules_dir) + config_dir = get_config_directory(override=config_dir) + clipabit_dir = get_clipabit_directory(override=clipabit_dir) + + paths = [ + (scripts_dir / "ClipABit.py", False, "Bootstrap shim"), + (modules_dir / "clipabit", True, "Plugin package"), + (clipabit_dir / "python", True, "Python runtime"), + (clipabit_dir / "deps", True, "Dependencies"), + (config_dir / "config.dat", False, "Configuration"), + ] + + # Also check for .bak variants + for path, is_dir, desc in list(paths): + bak = Path(str(path) + ".bak") + if bak.exists(): + paths.append((bak, is_dir or bak.is_dir(), f"{desc} (backup)")) + + return [(p, d, desc) for p, d, desc in paths if p.exists()] + + +def clear_keyring(): + """Attempt to clear ClipABit keyring credentials. + + Tries platform-native commands since the bundled Python/deps may already + be deleted by the time this runs. + """ + system = platform.system() + try: + if system == "Darwin": + result = subprocess.run( + ["security", "delete-generic-password", + "-s", "clipabit-plugin", "-a", "tokens"], + capture_output=True, text=True, + ) + if result.returncode == 0: + print_success("Keychain credentials removed.") + else: + if "could not be found" in result.stderr.lower(): + print_info("No keychain credentials found.") + else: + print_warning(f"Keychain cleanup: {result.stderr.strip()}") + elif system == "Windows": + result = subprocess.run( + ["cmdkey", "/delete:clipabit-plugin"], + capture_output=True, text=True, + ) + if result.returncode == 0: + print_success("Credential Manager entry removed.") + else: + print_info("No Credential Manager entry found.") + except FileNotFoundError: + print_warning("Could not clear keyring (command not found).") + + +def forget_pkg_receipt(): + """Remove the macOS installer pkg receipt.""" + if platform.system() != "Darwin": + return + try: + result = subprocess.run( + ["pkgutil", "--forget", "com.clipabit.plugin.installer"], + capture_output=True, text=True, + ) + if result.returncode == 0: + print_success("Package receipt removed.") + else: + print_info("No package receipt found.") + except FileNotFoundError: + pass + + +def uninstall(yes: bool = False, scripts_dir=None, modules_dir=None, + config_dir=None, clipabit_dir=None) -> bool: + """Uninstall ClipABit from the current system. + + All path parameters accept overrides for testability. + Returns True if uninstall completed, False otherwise. + """ + print_header("ClipABit Uninstaller") + + # Check if Resolve is running + resolve_running = check_resolve_running() + if resolve_running: + print_warning("Please close DaVinci Resolve before uninstalling.") + if not yes: + response = input("\n Continue anyway? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print_info("Uninstall cancelled.") + return False + + # Enumerate what exists + installed = enumerate_installed_paths(scripts_dir, modules_dir, + config_dir, clipabit_dir) + + if not installed: + print_info("No ClipABit installation found. Nothing to remove.") + return True + + # Display summary + print_info("The following will be removed:\n") + total_size = 0 + for path, is_dir, desc in installed: + if is_dir: + size = get_dir_size(path) + else: + try: + size = path.stat().st_size + except OSError: + size = 0 + total_size += size + print(f" {desc}: {path} ({format_size(size)})") + print(f"\n Total: {format_size(total_size)}") + print() + print_info("Keyring credentials (clipabit-plugin) will also be cleared.") + if platform.system() == "Darwin": + print_info("Package receipt (com.clipabit.plugin.installer) will be removed.") + + # Confirmation + if not yes: + response = input("\n Proceed with uninstall? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print_info("Uninstall cancelled.") + return False + + # Delete artifacts — shim first (removes Resolve menu entry) + removed = [] + failed = [] + for path, is_dir, desc in installed: + try: + if is_dir: + shutil.rmtree(path) + else: + path.unlink() + removed.append(desc) + print_success(f"Removed: {desc}") + except OSError as e: + failed.append((desc, str(e))) + print_error(f"Failed to remove {desc}: {e}") + + # Clear keyring credentials + clear_keyring() + + # Remove pkg receipt (macOS only) + forget_pkg_receipt() + + # Clean up staging leftovers + staging = Path(tempfile.gettempdir()) / "clipabit-staging" + if staging.exists(): + try: + shutil.rmtree(staging) + print_success("Removed staging leftovers.") + except OSError: + pass + + # Clean up empty parent directories + scripts_dir_resolved, modules_dir_resolved = get_resolve_directories(scripts_dir, modules_dir) + config_dir_resolved = get_config_directory(override=config_dir) + clipabit_dir_resolved = get_clipabit_directory(override=clipabit_dir) + for d in [clipabit_dir_resolved, config_dir_resolved]: + if d.exists() and not any(d.iterdir()): + try: + d.rmdir() + print_success(f"Removed empty directory: {d}") + except OSError: + pass + + # Summary + print() + if failed: + print_warning(f"Uninstall completed with {len(failed)} error(s).") + for desc, err in failed: + print_error(f" {desc}: {err}") + return False + else: + print_header("ClipABit Uninstall Complete") + print_success(f"Removed {len(removed)} item(s).") + if resolve_running: + print_warning("Restart DaVinci Resolve to clear the ClipABit menu entry.") + return True + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="ClipABit Plugin Installer") + parser.add_argument("--uninstall", action="store_true", + help="Uninstall ClipABit from this system.") + parser.add_argument("--yes", "-y", action="store_true", + help="Skip confirmation prompts (use with --uninstall).") parser.add_argument("--download-only", action="store_true", help="Download the plugin to staging dir and exit.") parser.add_argument("--local", type=str, default=None, @@ -897,6 +1112,11 @@ def main(): help="Directory to stage the downloaded plugin into.") args = parser.parse_args() + # Handle uninstall mode + if args.uninstall: + success = uninstall(yes=args.yes) + sys.exit(0 if success else 1) + print_header("ClipABit Plugin Installer") # Determine staging directory diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py new file mode 100644 index 0000000..dff6a85 --- /dev/null +++ b/tests/test_uninstall.py @@ -0,0 +1,290 @@ +"""Tests for ClipABit uninstall functionality.""" + +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from tests.conftest import installer_script + + +@pytest.fixture +def install_dirs(tmp_path): + """Create the 4 override directories used by uninstall.""" + scripts_dir = tmp_path / "Scripts" / "Utility" + modules_dir = tmp_path / "Modules" + clipabit_dir = tmp_path / "ClipABit" + config_dir = tmp_path / "Config" + for d in [scripts_dir, modules_dir, clipabit_dir, config_dir]: + d.mkdir(parents=True) + return scripts_dir, modules_dir, clipabit_dir, config_dir + + +@pytest.fixture +def installed_state(install_dirs): + """Create a fully installed state.""" + scripts_dir, modules_dir, clipabit_dir, config_dir = install_dirs + + # Shim + (scripts_dir / "ClipABit.py").write_text("# shim\n") + + # Plugin package + pkg = modules_dir / "clipabit" + pkg.mkdir() + (pkg / "__init__.py").write_text("# pkg\n") + + # Python runtime + python_dir = clipabit_dir / "python" + python_dir.mkdir() + (python_dir / "python3").write_text("# fake python\n") + + # Deps + deps = clipabit_dir / "deps" + deps.mkdir() + (deps / "requests.py").write_text("# fake\n") + + # Config + (config_dir / "config.dat").write_text("encoded-config") + + return install_dirs + + +# --------------------------------------------------------------------------- +# enumerate_installed_paths +# --------------------------------------------------------------------------- + +class TestEnumerateInstalledPaths: + def test_all_paths_found(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + paths = installer_script.enumerate_installed_paths( + scripts_dir, modules_dir, config_dir, clipabit_dir + ) + descriptions = [desc for _, _, desc in paths] + assert "Bootstrap shim" in descriptions + assert "Plugin package" in descriptions + assert "Python runtime" in descriptions + assert "Dependencies" in descriptions + assert "Configuration" in descriptions + + def test_empty_install_returns_empty(self, install_dirs): + scripts_dir, modules_dir, clipabit_dir, config_dir = install_dirs + paths = installer_script.enumerate_installed_paths( + scripts_dir, modules_dir, config_dir, clipabit_dir + ) + assert paths == [] + + def test_partial_install(self, install_dirs): + scripts_dir, modules_dir, clipabit_dir, config_dir = install_dirs + # Only shim exists + (scripts_dir / "ClipABit.py").write_text("# shim\n") + paths = installer_script.enumerate_installed_paths( + scripts_dir, modules_dir, config_dir, clipabit_dir + ) + assert len(paths) == 1 + assert paths[0][2] == "Bootstrap shim" + + def test_includes_bak_files(self, install_dirs): + scripts_dir, modules_dir, clipabit_dir, config_dir = install_dirs + (scripts_dir / "ClipABit.py").write_text("# shim\n") + (scripts_dir / "ClipABit.py.bak").write_text("# old shim\n") + paths = installer_script.enumerate_installed_paths( + scripts_dir, modules_dir, config_dir, clipabit_dir + ) + descriptions = [desc for _, _, desc in paths] + assert "Bootstrap shim" in descriptions + assert "Bootstrap shim (backup)" in descriptions + + +# --------------------------------------------------------------------------- +# uninstall +# --------------------------------------------------------------------------- + +class TestUninstall: + def test_uninstall_removes_all_files(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch.object(installer_script, "clear_keyring"), \ + patch.object(installer_script, "forget_pkg_receipt"): + result = installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + assert result is True + assert not (scripts_dir / "ClipABit.py").exists() + assert not (modules_dir / "clipabit").exists() + assert not (clipabit_dir / "python").exists() + assert not (clipabit_dir / "deps").exists() + assert not (config_dir / "config.dat").exists() + + def test_uninstall_nothing_installed(self, install_dirs): + scripts_dir, modules_dir, clipabit_dir, config_dir = install_dirs + with patch.object(installer_script, "check_resolve_running", return_value=False): + result = installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + assert result is True + + def test_uninstall_calls_clear_keyring(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch.object(installer_script, "clear_keyring") as mock_keyring, \ + patch.object(installer_script, "forget_pkg_receipt"): + installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + mock_keyring.assert_called_once() + + def test_uninstall_calls_forget_pkg_receipt(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch.object(installer_script, "clear_keyring"), \ + patch.object(installer_script, "forget_pkg_receipt") as mock_receipt: + installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + mock_receipt.assert_called_once() + + def test_uninstall_removes_bak_files(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + # Add .bak files + (scripts_dir / "ClipABit.py.bak").write_text("# old\n") + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch.object(installer_script, "clear_keyring"), \ + patch.object(installer_script, "forget_pkg_receipt"): + result = installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + assert result is True + assert not (scripts_dir / "ClipABit.py.bak").exists() + + def test_uninstall_cleans_empty_parent_dirs(self, installed_state): + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch.object(installer_script, "clear_keyring"), \ + patch.object(installer_script, "forget_pkg_receipt"): + installer_script.uninstall( + yes=True, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + # clipabit_dir should be removed since it's empty after cleanup + assert not clipabit_dir.exists() + + def test_uninstall_prompt_cancelled(self, installed_state): + """Without --yes, declining the prompt cancels uninstall.""" + scripts_dir, modules_dir, clipabit_dir, config_dir = installed_state + with patch.object(installer_script, "check_resolve_running", return_value=False), \ + patch("builtins.input", return_value="n"): + result = installer_script.uninstall( + yes=False, + scripts_dir=scripts_dir, modules_dir=modules_dir, + config_dir=config_dir, clipabit_dir=clipabit_dir, + ) + assert result is False + # Files should still exist + assert (scripts_dir / "ClipABit.py").exists() + + +# --------------------------------------------------------------------------- +# clear_keyring +# --------------------------------------------------------------------------- + +class TestClearKeyring: + @patch("platform.system", return_value="Darwin") + @patch("subprocess.run") + def test_macos_keyring_success(self, mock_run, _): + mock_run.return_value = MagicMock(returncode=0) + installer_script.clear_keyring() + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "security" in args + assert "clipabit-plugin" in args + + @patch("platform.system", return_value="Darwin") + @patch("subprocess.run") + def test_macos_keyring_not_found(self, mock_run, _): + mock_run.return_value = MagicMock( + returncode=44, stderr="The specified item could not be found" + ) + # Should not raise + installer_script.clear_keyring() + + @patch("platform.system", return_value="Windows") + @patch("subprocess.run") + def test_windows_keyring_success(self, mock_run, _): + mock_run.return_value = MagicMock(returncode=0) + installer_script.clear_keyring() + args = mock_run.call_args[0][0] + assert "cmdkey" in args + + @patch("platform.system", return_value="Darwin") + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_command_not_found(self, mock_run, _): + # Should not raise + installer_script.clear_keyring() + + +# --------------------------------------------------------------------------- +# forget_pkg_receipt +# --------------------------------------------------------------------------- + +class TestForgetPkgReceipt: + @patch("platform.system", return_value="Darwin") + @patch("subprocess.run") + def test_macos_receipt_removed(self, mock_run, _): + mock_run.return_value = MagicMock(returncode=0) + installer_script.forget_pkg_receipt() + args = mock_run.call_args[0][0] + assert "pkgutil" in args + assert "com.clipabit.plugin.installer" in args + + @patch("platform.system", return_value="Windows") + def test_windows_noop(self, _): + # Should not call anything on Windows + installer_script.forget_pkg_receipt() + + @patch("platform.system", return_value="Darwin") + @patch("subprocess.run") + def test_no_receipt(self, mock_run, _): + mock_run.return_value = MagicMock(returncode=1) + # Should not raise + installer_script.forget_pkg_receipt() + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +class TestHelpers: + def test_get_dir_size(self, tmp_path): + d = tmp_path / "test_dir" + d.mkdir() + (d / "file1.txt").write_bytes(b"x" * 100) + (d / "file2.txt").write_bytes(b"y" * 200) + assert installer_script.get_dir_size(d) == 300 + + def test_get_dir_size_empty(self, tmp_path): + d = tmp_path / "empty" + d.mkdir() + assert installer_script.get_dir_size(d) == 0 + + def test_get_dir_size_nonexistent(self, tmp_path): + assert installer_script.get_dir_size(tmp_path / "nope") == 0 + + def test_format_size_bytes(self): + assert installer_script.format_size(500) == "500 B" + + def test_format_size_kb(self): + assert installer_script.format_size(2048) == "2.0 KB" + + def test_format_size_mb(self): + assert installer_script.format_size(5 * 1024 * 1024) == "5.0 MB" diff --git a/uninstall.bat b/uninstall.bat new file mode 100644 index 0000000..402a7f8 --- /dev/null +++ b/uninstall.bat @@ -0,0 +1,162 @@ +@echo off +REM ClipABit Uninstaller for Windows +REM Standalone script — no Python required. +setlocal enabledelayedexpansion + +set "failures=0" + +echo. +echo ============================================================ +echo ClipABit Uninstaller +echo ============================================================ +echo. + +REM LOCALAPPDATA fallback (matches Python get_clipabit_directory logic) +if not defined LOCALAPPDATA set "LOCALAPPDATA=%USERPROFILE%\AppData\Local" + +REM Resolve directories +set "SHIM=%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion\Scripts\Utility\ClipABit.py" +set "PLUGIN_PKG=%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion\Modules\clipabit" +REM ClipABit application directories +set "PYTHON_DIR=%LOCALAPPDATA%\ClipABit\python" +set "DEPS_DIR=%LOCALAPPDATA%\ClipABit\deps" +set "CONFIG_FILE=%APPDATA%\ClipABit\config.dat" +set "CLIPABIT_LOCAL=%LOCALAPPDATA%\ClipABit" +set "CLIPABIT_ROAMING=%APPDATA%\ClipABit" + +REM Check if Resolve is running +tasklist /FI "IMAGENAME eq Resolve.exe" 2>NUL | find /I "Resolve.exe" >NUL +if %ERRORLEVEL% equ 0 ( + echo WARNING: DaVinci Resolve is currently running. + echo Please close it before uninstalling, or restart it after. + if /I not "%~1"=="-y" if /I not "%~1"=="--yes" ( + set /p "answer= Continue anyway? [y/N]: " + if /I not "!answer!"=="y" if /I not "!answer!"=="yes" ( + echo Uninstall cancelled. + goto :eof + ) + ) +) + +REM Check what exists +set "found=0" +if exist "%SHIM%" set "found=1" +if exist "%PLUGIN_PKG%" set "found=1" +if exist "%PYTHON_DIR%" set "found=1" +if exist "%DEPS_DIR%" set "found=1" +if exist "%CONFIG_FILE%" set "found=1" + +if "%found%"=="0" ( + echo No ClipABit installation found. Nothing to remove. + goto :eof +) + +REM Display what will be removed +echo The following will be removed: +echo. +if exist "%SHIM%" echo Bootstrap shim: %SHIM% +if exist "%PLUGIN_PKG%" echo Plugin package: %PLUGIN_PKG% +if exist "%PYTHON_DIR%" echo Python runtime: %PYTHON_DIR% +if exist "%DEPS_DIR%" echo Dependencies: %DEPS_DIR% +if exist "%CONFIG_FILE%" echo Configuration: %CONFIG_FILE% +echo. +echo Credential Manager entry (clipabit-plugin) will also be cleared. + +REM Confirmation +if /I not "%~1"=="-y" if /I not "%~1"=="--yes" ( + echo. + set /p "answer= Proceed with uninstall? [y/N]: " + if /I not "!answer!"=="y" if /I not "!answer!"=="yes" ( + echo Uninstall cancelled. + goto :eof + ) +) + +echo. + +REM Remove files — shim first (removes Resolve menu entry) +if exist "%SHIM%" ( + del /f "%SHIM%" && ( + echo Removed: Bootstrap shim + ) || ( + echo Failed to remove: Bootstrap shim + set /a "failures+=1" + ) +) +if exist "%SHIM%.bak" del /f "%SHIM%.bak" >NUL 2>&1 +if exist "%PLUGIN_PKG%" ( + rmdir /s /q "%PLUGIN_PKG%" && ( + echo Removed: Plugin package + ) || ( + echo Failed to remove: Plugin package + set /a "failures+=1" + ) +) +if exist "%PLUGIN_PKG%.bak" rmdir /s /q "%PLUGIN_PKG%.bak" >NUL 2>&1 +if exist "%PYTHON_DIR%" ( + rmdir /s /q "%PYTHON_DIR%" && ( + echo Removed: Python runtime + ) || ( + echo Failed to remove: Python runtime + set /a "failures+=1" + ) +) +if exist "%PYTHON_DIR%.bak" rmdir /s /q "%PYTHON_DIR%.bak" >NUL 2>&1 +if exist "%DEPS_DIR%" ( + rmdir /s /q "%DEPS_DIR%" && ( + echo Removed: Dependencies + ) || ( + echo Failed to remove: Dependencies + set /a "failures+=1" + ) +) +if exist "%DEPS_DIR%.bak" rmdir /s /q "%DEPS_DIR%.bak" >NUL 2>&1 +if exist "%CONFIG_FILE%" ( + del /f "%CONFIG_FILE%" && ( + echo Removed: Configuration + ) || ( + echo Failed to remove: Configuration + set /a "failures+=1" + ) +) +if exist "%CONFIG_FILE%.bak" del /f "%CONFIG_FILE%.bak" >NUL 2>&1 + +REM Clear Credential Manager entry +cmdkey /delete:clipabit-plugin >NUL 2>&1 +if %ERRORLEVEL% equ 0 ( + echo Credential Manager entry removed. +) else ( + echo No Credential Manager entry found. +) + +REM Clean up staging leftovers +if exist "%TEMP%\clipabit-staging" rmdir /s /q "%TEMP%\clipabit-staging" >NUL 2>&1 + +REM Clean up empty directories +if exist "%CLIPABIT_LOCAL%" ( + dir /b "%CLIPABIT_LOCAL%" 2>NUL | findstr "." >NUL 2>&1 + if errorlevel 1 ( + rmdir "%CLIPABIT_LOCAL%" && echo Removed empty directory: %CLIPABIT_LOCAL% + ) +) +if exist "%CLIPABIT_ROAMING%" ( + dir /b "%CLIPABIT_ROAMING%" 2>NUL | findstr "." >NUL 2>&1 + if errorlevel 1 ( + rmdir "%CLIPABIT_ROAMING%" && echo Removed empty directory: %CLIPABIT_ROAMING% + ) +) + +REM Summary +echo. +if !failures! gtr 0 ( + echo ============================================================ + echo Uninstall completed with !failures! error(s^). + echo ============================================================ +) else ( + echo ============================================================ + echo ClipABit Uninstall Complete + echo ============================================================ +) +echo. + +endlocal diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..affbdac --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# ClipABit Uninstaller for macOS +# Standalone script — no Python required. +# NOTE: intentionally no set -e — uninstaller must continue past individual failures. +set -u + +BOLD='\033[1m' +GREEN='\033[92m' +YELLOW='\033[93m' +RED='\033[91m' +CYAN='\033[96m' +RESET='\033[0m' + +failures=0 + +# Helper: remove a file with error tracking +remove_file() { + local path="$1" label="$2" quiet="${3:-}" + if [ -f "$path" ]; then + if rm -f "$path"; then + [ -z "$quiet" ] && echo -e "${GREEN} Removed: ${label}${RESET}" + else + echo -e "${RED} Failed to remove: ${label}${RESET}" + failures=$((failures + 1)) + fi + fi +} + +# Helper: remove a directory with error tracking +remove_dir() { + local path="$1" label="$2" quiet="${3:-}" + if [ -d "$path" ]; then + if rm -rf "$path"; then + [ -z "$quiet" ] && echo -e "${GREEN} Removed: ${label}${RESET}" + else + echo -e "${RED} Failed to remove: ${label}${RESET}" + failures=$((failures + 1)) + fi + fi +} + +# Resolve directories +SHIM="$HOME/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/ClipABit.py" +PLUGIN_PKG="$HOME/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Modules/clipabit" +# ClipABit application directories +CLIPABIT_DIR="$HOME/Library/Application Support/ClipABit" +PYTHON_DIR="$CLIPABIT_DIR/python" +DEPS_DIR="$CLIPABIT_DIR/deps" +CONFIG_FILE="$CLIPABIT_DIR/config.dat" + +echo -e "\n${BOLD}============================================================${RESET}" +echo -e "${BOLD}ClipABit Uninstaller${RESET}" +echo -e "${BOLD}============================================================${RESET}\n" + +# Check if Resolve is running +if pgrep -x "DaVinci Resolve" > /dev/null 2>&1; then + echo -e "${YELLOW} DaVinci Resolve is currently running.${RESET}" + echo -e "${YELLOW} Please close it before uninstalling, or restart it after.${RESET}" + if [ "${1:-}" != "-y" ] && [ "${1:-}" != "--yes" ]; then + read -rp " Continue anyway? [y/N]: " answer + if [[ ! "$answer" =~ ^[Yy] ]]; then + echo -e "${CYAN} Uninstall cancelled.${RESET}" + exit 0 + fi + fi +fi + +# Check what exists +found=0 +for item in "$SHIM" "$PLUGIN_PKG" "$PYTHON_DIR" "$DEPS_DIR" "$CONFIG_FILE"; do + [ -e "$item" ] && found=1 && break +done + +if [ "$found" -eq 0 ]; then + echo -e "${CYAN} No ClipABit installation found. Nothing to remove.${RESET}" + exit 0 +fi + +# Display what will be removed +echo -e "${CYAN} The following will be removed:${RESET}\n" +[ -e "$SHIM" ] && echo " Bootstrap shim: $SHIM" +[ -e "$PLUGIN_PKG" ] && echo " Plugin package: $PLUGIN_PKG" +[ -e "$PYTHON_DIR" ] && echo " Python runtime: $PYTHON_DIR" +[ -e "$DEPS_DIR" ] && echo " Dependencies: $DEPS_DIR" +[ -e "$CONFIG_FILE" ]&& echo " Configuration: $CONFIG_FILE" +echo "" +echo -e "${CYAN} Keychain credentials (clipabit-plugin) will also be cleared.${RESET}" +echo -e "${CYAN} Package receipt (com.clipabit.plugin.installer) will be removed.${RESET}" + +# Confirmation +if [ "${1:-}" != "-y" ] && [ "${1:-}" != "--yes" ]; then + echo "" + read -rp " Proceed with uninstall? [y/N]: " answer + if [[ ! "$answer" =~ ^[Yy] ]]; then + echo -e "${CYAN} Uninstall cancelled.${RESET}" + exit 0 + fi +fi + +echo "" + +# Remove files — shim first (removes Resolve menu entry) +remove_file "$SHIM" "Bootstrap shim" +remove_file "$SHIM.bak" "Bootstrap shim (backup)" quiet +remove_dir "$PLUGIN_PKG" "Plugin package" +remove_dir "$PLUGIN_PKG.bak" "Plugin package (backup)" quiet +remove_dir "$PYTHON_DIR" "Python runtime" +remove_dir "$PYTHON_DIR.bak" "Python runtime (backup)" quiet +remove_dir "$DEPS_DIR" "Dependencies" +remove_dir "$DEPS_DIR.bak" "Dependencies (backup)" quiet +remove_file "$CONFIG_FILE" "Configuration" +remove_file "$CONFIG_FILE.bak" "Configuration (backup)" quiet + +# Clear keychain credentials +if security delete-generic-password -s "clipabit-plugin" -a "tokens" > /dev/null 2>&1; then + echo -e "${GREEN} Keychain credentials removed.${RESET}" +else + echo -e "${CYAN} No keychain credentials found.${RESET}" +fi + +# Remove pkg receipt +if pkgutil --forget com.clipabit.plugin.installer > /dev/null 2>&1; then + echo -e "${GREEN} Package receipt removed.${RESET}" +else + echo -e "${CYAN} No package receipt found.${RESET}" +fi + +# Clean up staging leftovers +remove_dir "/tmp/clipabit-staging" "Staging leftovers" quiet + +# Remove empty ClipABit directory +if [ -d "$CLIPABIT_DIR" ] && [ -z "$(ls -A "$CLIPABIT_DIR" 2>/dev/null)" ]; then + rmdir "$CLIPABIT_DIR" && echo -e "${GREEN} Removed empty directory: $CLIPABIT_DIR${RESET}" +fi + +# Summary +echo "" +if [ "$failures" -gt 0 ]; then + echo -e "${BOLD}============================================================${RESET}" + echo -e "${YELLOW} Uninstall completed with ${failures} error(s).${RESET}" + echo -e "${BOLD}============================================================${RESET}\n" + exit 1 +else + echo -e "${BOLD}============================================================${RESET}" + echo -e "${BOLD}ClipABit Uninstall Complete${RESET}" + echo -e "${BOLD}============================================================${RESET}\n" +fi