From 1d46b9945dbfbc0a2c47356d86b594b6c1a6d663 Mon Sep 17 00:00:00 2001 From: tocmo Date: Sun, 5 Apr 2026 01:32:32 -0400 Subject: [PATCH] Add portable flash-drive installer - build-release.ps1: builds Docker image, saves to tar, bundles everything into dist\ ready to copy to a flash drive - installer/install.ps1: checks WSL2, Docker Desktop, loads image (or builds from source as fallback), prompts for photo/data paths, writes docker-compose.override.yml, starts container, creates desktop shortcut - installer/uninstall.ps1: stops container, optionally removes image and data, removes shortcut and app directory - installer/dupfinder-start-stop.ps1: start/stop/restart/open helper copied to target machine during install; desktop shortcut uses -Action open which polls until the app is responsive before launching browser Co-Authored-By: Claude Sonnet 4.6 --- build-release.ps1 | 153 ++++++++++++++++ installer/dupfinder-start-stop.ps1 | 71 ++++++++ installer/install.ps1 | 276 +++++++++++++++++++++++++++++ installer/uninstall.ps1 | 76 ++++++++ 4 files changed, 576 insertions(+) create mode 100644 build-release.ps1 create mode 100644 installer/dupfinder-start-stop.ps1 create mode 100644 installer/install.ps1 create mode 100644 installer/uninstall.ps1 diff --git a/build-release.ps1 b/build-release.ps1 new file mode 100644 index 0000000..4ed8ec8 --- /dev/null +++ b/build-release.ps1 @@ -0,0 +1,153 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Builds the DupFinder flash-drive installer bundle. +.DESCRIPTION + 1. Builds the Docker image + 2. Saves it to dist\image\dupfinder.tar + 3. Copies all installer scripts and source into dist\ + Run this from the repo root before copying dist\ to a flash drive. +.EXAMPLE + .\build-release.ps1 + .\build-release.ps1 -SkipBuild # Skip docker build (reuse existing image) +#> +param( + [switch]$SkipBuild, + [string]$ImageName = "dupfinder", + [string]$ImageTag = "latest" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoRoot = $PSScriptRoot +$DistDir = Join-Path $RepoRoot "dist" +$ImageFull = "${ImageName}:${ImageTag}" + +function Write-Step([string]$msg) { + Write-Host "`n==> $msg" -ForegroundColor Cyan +} +function Write-OK([string]$msg) { + Write-Host " [OK] $msg" -ForegroundColor Green +} +function Write-Fail([string]$msg) { + Write-Host " [!!] $msg" -ForegroundColor Red +} + +# ── Check Docker is running ─────────────────────────────────────────────────── +Write-Step "Checking Docker..." +docker info 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Fail "Docker is not running. Start Docker Desktop and try again." + exit 1 +} +Write-OK "Docker is running" + +# ── Build image ─────────────────────────────────────────────────────────────── +if (-not $SkipBuild) { + Write-Step "Building Docker image ($ImageFull)..." + docker build -t $ImageFull --progress=plain $RepoRoot + if ($LASTEXITCODE -ne 0) { Write-Fail "Docker build failed."; exit 1 } + Write-OK "Image built: $ImageFull" +} else { + Write-Step "Skipping build (-SkipBuild). Checking image exists..." + $exists = docker images $ImageFull --format "{{.ID}}" 2>$null + if (-not $exists) { + Write-Fail "Image $ImageFull not found locally. Remove -SkipBuild to build it." + exit 1 + } + Write-OK "Image found: $ImageFull" +} + +# ── Clean dist\ ────────────────────────────────────────────────────────────── +Write-Step "Preparing dist\ directory..." +if (Test-Path $DistDir) { + Remove-Item $DistDir -Recurse -Force +} +New-Item -ItemType Directory -Path $DistDir | Out-Null +New-Item -ItemType Directory -Path "$DistDir\image" | Out-Null +New-Item -ItemType Directory -Path "$DistDir\source" | Out-Null +New-Item -ItemType Directory -Path "$DistDir\assets" | Out-Null +Write-OK "dist\ ready" + +# ── Save Docker image ───────────────────────────────────────────────────────── +Write-Step "Saving Docker image to dist\image\dupfinder.tar (this may take a minute)..." +docker save -o "$DistDir\image\dupfinder.tar" $ImageFull +if ($LASTEXITCODE -ne 0) { Write-Fail "docker save failed."; exit 1 } +$tarSize = [math]::Round((Get-Item "$DistDir\image\dupfinder.tar").Length / 1MB, 1) +Write-OK "Image saved (${tarSize} MB)" + +# ── Copy installer scripts ──────────────────────────────────────────────────── +Write-Step "Copying installer scripts..." +Copy-Item "$RepoRoot\installer\install.ps1" "$DistDir\install.ps1" +Copy-Item "$RepoRoot\installer\uninstall.ps1" "$DistDir\uninstall.ps1" +Copy-Item "$RepoRoot\installer\dupfinder-start-stop.ps1" "$DistDir\dupfinder-start-stop.ps1" +Copy-Item "$RepoRoot\docker-compose.yml" "$DistDir\docker-compose.yml" + +# install.bat launcher (no-click PS1 execution for non-technical users) +@' +@echo off +echo Starting DupFinder installer... +PowerShell -ExecutionPolicy Bypass -File "%~dp0install.ps1" +pause +'@ | Set-Content "$DistDir\INSTALL.bat" -Encoding ASCII + +Write-OK "Scripts copied" + +# ── Copy source (fallback build) ────────────────────────────────────────────── +Write-Step "Copying source files (offline build fallback)..." +$excludeDirs = @('dist', '__pycache__', 'data', '.git', '.claude', 'installer') +$excludeFiles = @('*.db', '*.db-shm', '*.db-wal', '*.pyc', '*.pyo') + +Get-ChildItem $RepoRoot -Recurse | Where-Object { + $item = $_ + $skip = $false + foreach ($d in $excludeDirs) { if ($item.FullName -match [regex]::Escape($d)) { $skip = $true } } + foreach ($f in $excludeFiles) { if ($item.Name -like $f) { $skip = $true } } + -not $skip +} | ForEach-Object { + $rel = $_.FullName.Substring($RepoRoot.Length + 1) + $dst = Join-Path "$DistDir\source" $rel + if ($_.PSIsContainer) { + New-Item -ItemType Directory -Path $dst -Force | Out-Null + } else { + $dstDir = Split-Path $dst -Parent + if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null } + Copy-Item $_.FullName $dst -Force + } +} +Write-OK "Source copied" + +# ── README for flash drive ──────────────────────────────────────────────────── +@" +DupFinder Installer +=================== + +Requirements: + - Windows 10/11 (64-bit) + - Docker Desktop for Windows (if not installed, the installer will guide you) + +To install: + 1. Right-click INSTALL.bat -> "Run as administrator" + OR + Open PowerShell as Administrator and run: + PowerShell -ExecutionPolicy Bypass -File install.ps1 + + 2. Follow the prompts (photos path, data path) + + 3. A "DupFinder" shortcut will appear on the desktop when done. + +To uninstall: + Run uninstall.ps1 as Administrator. + +Built: $(Get-Date -Format 'yyyy-MM-dd HH:mm') +Image: $ImageFull +"@ | Set-Content "$DistDir\README.txt" -Encoding UTF8 + +# ── Summary ─────────────────────────────────────────────────────────────────── +$totalMB = [math]::Round((Get-ChildItem $DistDir -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB, 1) +Write-Host "" +Write-Host "============================================" -ForegroundColor Green +Write-Host " Build complete! dist\ is ${totalMB} MB total" -ForegroundColor Green +Write-Host " Copy the dist\ folder to your flash drive." -ForegroundColor Green +Write-Host "============================================" -ForegroundColor Green diff --git a/installer/dupfinder-start-stop.ps1 b/installer/dupfinder-start-stop.ps1 new file mode 100644 index 0000000..a7bfdd8 --- /dev/null +++ b/installer/dupfinder-start-stop.ps1 @@ -0,0 +1,71 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Start, stop, restart DupFinder, or open it in the browser. +.PARAMETER Action + start | stop | restart | open (default: open) +.EXAMPLE + .\dupfinder-start-stop.ps1 -Action open + .\dupfinder-start-stop.ps1 -Action stop +#> +param( + [ValidateSet("start","stop","restart","open")] + [string]$Action = "open" +) + +$ConfigFile = "C:\ProgramData\DupFinder\dupfinder.conf" + +if (-not (Test-Path $ConfigFile)) { + Write-Error "DupFinder is not installed. Run install.ps1 first." + exit 1 +} + +# Read config +$conf = @{} +Get-Content $ConfigFile | ForEach-Object { + if ($_ -match '^(.+?)=(.+)$') { $conf[$Matches[1]] = $Matches[2] } +} + +$ComposeDir = $conf["COMPOSE_DIR"] +$AppPort = $conf["APP_PORT"] +$ComposeYml = "$ComposeDir\docker-compose.yml" +$OverrideYml = "$ComposeDir\docker-compose.override.yml" +$Url = "http://localhost:$AppPort" + +function Invoke-Compose([string]$cmd) { + & docker compose -f $ComposeYml -f $OverrideYml $cmd.Split(" ") +} + +switch ($Action) { + "start" { + Write-Host "Starting DupFinder..." + Invoke-Compose "up -d --pull never" + } + "stop" { + Write-Host "Stopping DupFinder..." + Invoke-Compose "stop" + } + "restart" { + Write-Host "Restarting DupFinder..." + Invoke-Compose "restart" + } + "open" { + # Ensure container is running + $running = docker ps --filter "name=dup-finder" --format "{{.Names}}" 2>$null + if (-not $running) { + Write-Host "Starting DupFinder..." + Invoke-Compose "up -d --pull never" + } + # Poll until responsive (up to 15s) + $tries = 0 + while ($tries -lt 15) { + try { + $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop + break + } catch { } + Start-Sleep 1 + $tries++ + } + Start-Process $Url + } +} diff --git a/installer/install.ps1 b/installer/install.ps1 new file mode 100644 index 0000000..d7e6957 --- /dev/null +++ b/installer/install.ps1 @@ -0,0 +1,276 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Installs DupFinder on this workstation. +.DESCRIPTION + - Verifies Docker Desktop is installed and running + - Loads the pre-built Docker image (or builds from source as fallback) + - Prompts for photos library path and data storage path + - Writes a docker-compose.override.yml + - Starts the container + - Creates a desktop shortcut +.PARAMETER ForceReload + Re-load the Docker image even if it's already present locally. +.EXAMPLE + PowerShell -ExecutionPolicy Bypass -File install.ps1 +#> +param( + [switch]$ForceReload +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptDir = $PSScriptRoot +$AppDir = "C:\ProgramData\DupFinder" +$ConfigFile = "$AppDir\dupfinder.conf" +$OverrideYml = "$AppDir\docker-compose.override.yml" +$ComposeYml = "$AppDir\docker-compose.yml" +$ImageName = "dupfinder:latest" +$TarPath = "$ScriptDir\image\dupfinder.tar" +$SourcePath = "$ScriptDir\source" +$AppPort = 8765 + +function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } +function Write-OK([string]$msg) { Write-Host " OK $msg" -ForegroundColor Green } +function Write-Warn([string]$msg) { Write-Host " !! $msg" -ForegroundColor Yellow } +function Write-Fail([string]$msg) { Write-Host "`n[FAIL] $msg" -ForegroundColor Red } +function Pause-Continue { + Write-Host "`nPress Enter to continue..." -NoNewline + $null = Read-Host +} + +# ── 1. Admin check ──────────────────────────────────────────────────────────── +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Fail "This script must be run as Administrator." + Write-Host "Right-click install.ps1 and choose 'Run as administrator', or use:" + Write-Host " PowerShell -ExecutionPolicy Bypass -File `"$PSCommandPath`"" + exit 1 +} + +Write-Host "" +Write-Host " ====================================" -ForegroundColor Magenta +Write-Host " DupFinder Installer" -ForegroundColor Magenta +Write-Host " ====================================" -ForegroundColor Magenta +Write-Host "" + +# ── 2. WSL2 check ───────────────────────────────────────────────────────────── +Write-Step "Checking WSL2..." +$wslOut = & wsl --status 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Warn "WSL2 is not installed or needs updating." + Write-Host " Installing WSL2 (requires internet + possible reboot)..." + & wsl --install --no-distribution 2>&1 | Out-Null + Write-Warn "A reboot may be required. After rebooting, re-run this installer." + Pause-Continue + exit 0 +} +Write-OK "WSL2 is present" + +# ── 3. Docker detection ─────────────────────────────────────────────────────── +Write-Step "Checking Docker Desktop..." + +$dockerExe = $null +$dockerCmd = Get-Command docker -ErrorAction SilentlyContinue +if ($dockerCmd) { + $dockerExe = $dockerCmd.Source +} else { + # Check known install locations + $candidates = @( + "$env:ProgramFiles\Docker\Docker\resources\bin\docker.exe", + "$env:LOCALAPPDATA\Programs\Docker\Docker\resources\bin\docker.exe" + ) + foreach ($c in $candidates) { + if (Test-Path $c) { $dockerExe = $c; break } + } +} + +if (-not $dockerExe) { + Write-Warn "Docker Desktop is not installed." + + $bundledInstaller = "$ScriptDir\assets\DockerDesktopInstaller.exe" + if (Test-Path $bundledInstaller) { + Write-Host " Found bundled Docker Desktop installer. Installing..." + Start-Process -Wait $bundledInstaller -ArgumentList "install --quiet --accept-license" + Write-Warn "Docker Desktop was installed. A reboot may be required." + Write-Host " After rebooting, re-run this installer." + Pause-Continue + exit 0 + } else { + Write-Host " Opening Docker Desktop download page in your browser..." + Start-Process "https://www.docker.com/products/docker-desktop/" + Write-Host " Install Docker Desktop, then re-run this script." + Pause-Continue + exit 0 + } +} +Write-OK "Docker executable found: $dockerExe" + +# ── 4. Ensure Docker daemon is running ──────────────────────────────────────── +Write-Step "Waiting for Docker daemon..." +$maxWait = 60 +$waited = 0 +while ($waited -lt $maxWait) { + $info = docker info 2>&1 + if ($LASTEXITCODE -eq 0) { break } + if ($waited -eq 0) { + # Try to start Docker Desktop + $desktopExe = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" + if (Test-Path $desktopExe) { + Write-Host " Starting Docker Desktop..." + Start-Process $desktopExe + } + Write-Host " Waiting for Docker to become ready (up to ${maxWait}s)..." + Write-Host " If a Docker Desktop setup window appeared, please complete it." + } + Start-Sleep 3 + $waited += 3 + Write-Host " ... ${waited}s" -NoNewline +} +Write-Host "" +docker info 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Fail "Docker did not start within ${maxWait}s. Please start Docker Desktop manually and re-run." + exit 1 +} +Write-OK "Docker daemon is running" + +# ── 5. docker compose V2 check ──────────────────────────────────────────────── +docker compose version 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Fail "docker compose (V2 plugin) not found. Please update Docker Desktop to 4.0+ and re-run." + exit 1 +} +Write-OK "docker compose V2 available" + +# ── 6. Load / build image ───────────────────────────────────────────────────── +Write-Step "Preparing DupFinder Docker image..." +$existingImage = (docker images $ImageName --format "{{.ID}}" 2>$null) + +if ($existingImage -and -not $ForceReload) { + Write-OK "Image already loaded (use -ForceReload to replace it)" +} elseif (Test-Path $TarPath) { + Write-Host " Loading image from $TarPath ..." + docker load -i $TarPath + if ($LASTEXITCODE -ne 0) { Write-Fail "docker load failed."; exit 1 } + Write-OK "Image loaded from tar" +} elseif (Test-Path "$SourcePath\Dockerfile") { + Write-Warn "No pre-built image found. Building from source (requires internet)..." + docker build -t $ImageName $SourcePath + if ($LASTEXITCODE -ne 0) { Write-Fail "docker build failed."; exit 1 } + Write-OK "Image built from source" +} else { + Write-Fail "No image tar and no source Dockerfile found. Bundle may be incomplete." + exit 1 +} + +# ── 7. Collect paths from user ──────────────────────────────────────────────── +Write-Step "Configuration" +Write-Host "" + +# Load existing config as defaults if re-running +$defaultPhotos = "C:\Photos" +$defaultData = "C:\ProgramData\DupFinder\data" +if (Test-Path $ConfigFile) { + Get-Content $ConfigFile | ForEach-Object { + if ($_ -match '^PHOTOS_PATH=(.+)$') { $defaultPhotos = $Matches[1] } + if ($_ -match '^DATA_PATH=(.+)$') { $defaultData = $Matches[1] } + } +} + +# Photos path +do { + Write-Host " Photos library path (read-only mount):" + Write-Host " Default: $defaultPhotos" + $input = (Read-Host " Path").Trim().Trim('"') + if ([string]::IsNullOrWhiteSpace($input)) { $input = $defaultPhotos } + $PhotosPath = $input + if (-not (Test-Path $PhotosPath -PathType Container)) { + Write-Warn "Path not found: $PhotosPath (try again)" + } +} while (-not (Test-Path $PhotosPath -PathType Container)) +Write-OK "Photos: $PhotosPath" + +# Data path +Write-Host "" +Write-Host " Database / data storage path:" +Write-Host " Default: $defaultData" +$input = (Read-Host " Path").Trim().Trim('"') +if ([string]::IsNullOrWhiteSpace($input)) { $input = $defaultData } +$DataPath = $input +New-Item -ItemType Directory -Force -Path $DataPath | Out-Null +Write-OK "Data: $DataPath" + +# ── 8. Port conflict check ──────────────────────────────────────────────────── +$portInUse = Test-NetConnection localhost -Port $AppPort ` + -InformationLevel Quiet -WarningAction SilentlyContinue 2>$null +if ($portInUse) { + Write-Warn "Port $AppPort is already in use. DupFinder may already be running." +} + +# ── 9. Write config and compose override ───────────────────────────────────── +Write-Step "Writing configuration..." +New-Item -ItemType Directory -Force -Path $AppDir | Out-Null + +# Docker requires forward slashes +$PhotosDocker = $PhotosPath -replace '\\', '/' -replace '//', '/' +$DataDocker = $DataPath -replace '\\', '/' -replace '//', '/' + +@" +PHOTOS_PATH=$PhotosPath +DATA_PATH=$DataPath +APP_PORT=$AppPort +COMPOSE_DIR=$AppDir +"@ | Set-Content $ConfigFile -Encoding UTF8 + +@" +services: + dup-finder: + ports: + - "${AppPort}:8000" + volumes: + - "$PhotosDocker:/photos:ro" + - "$DataDocker:/data" +"@ | Set-Content $OverrideYml -Encoding UTF8 + +# Copy base compose file +Copy-Item "$ScriptDir\docker-compose.yml" $ComposeYml -Force + +# Copy start-stop helper +Copy-Item "$ScriptDir\dupfinder-start-stop.ps1" "$AppDir\dupfinder-start-stop.ps1" -Force + +Write-OK "Config written to $AppDir" + +# ── 10. Start container ─────────────────────────────────────────────────────── +Write-Step "Starting DupFinder container..." +docker compose -f $ComposeYml -f $OverrideYml up -d --pull never +if ($LASTEXITCODE -ne 0) { Write-Fail "docker compose up failed."; exit 1 } +Write-OK "Container started" + +# ── 11. Create desktop shortcut ─────────────────────────────────────────────── +Write-Step "Creating desktop shortcut..." +$ShortcutPath = "$env:PUBLIC\Desktop\DupFinder.lnk" +$WshShell = New-Object -ComObject WScript.Shell +$Shortcut = $WshShell.CreateShortcut($ShortcutPath) +$Shortcut.TargetPath = "powershell.exe" +$Shortcut.Arguments = "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$AppDir\dupfinder-start-stop.ps1`" -Action open" +$Shortcut.Description = "Open DupFinder Duplicate Photo Scanner" +$Shortcut.WindowStyle = 7 # Minimized — hides the PS window +$Shortcut.Save() +Write-OK "Shortcut created: $ShortcutPath" + +# ── 12. Done ────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host " ============================================" -ForegroundColor Green +Write-Host " DupFinder installed successfully!" -ForegroundColor Green +Write-Host " Open: http://localhost:$AppPort" -ForegroundColor Green +Write-Host " Or double-click DupFinder on your desktop." -ForegroundColor Green +Write-Host " ============================================" -ForegroundColor Green +Write-Host "" + +# Open browser +$open = Read-Host "Open DupFinder in browser now? (Y/n)" +if ($open -ne 'n' -and $open -ne 'N') { + Start-Process "http://localhost:$AppPort" +} diff --git a/installer/uninstall.ps1 b/installer/uninstall.ps1 new file mode 100644 index 0000000..519a364 --- /dev/null +++ b/installer/uninstall.ps1 @@ -0,0 +1,76 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Uninstalls DupFinder from this workstation. +#> + +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Error "This script must be run as Administrator." + exit 1 +} + +$ConfigFile = "C:\ProgramData\DupFinder\dupfinder.conf" +$AppDir = "C:\ProgramData\DupFinder" +$ShortcutPath = "$env:PUBLIC\Desktop\DupFinder.lnk" + +Write-Host "" +Write-Host " DupFinder Uninstaller" -ForegroundColor Magenta +Write-Host "" + +if (-not (Test-Path $ConfigFile)) { + Write-Warning "DupFinder config not found. It may not be installed, or was already removed." + exit 0 +} + +# Read config +$conf = @{} +Get-Content $ConfigFile | ForEach-Object { + if ($_ -match '^(.+?)=(.+)$') { $conf[$Matches[1]] = $Matches[2] } +} +$ComposeDir = $conf["COMPOSE_DIR"] +$DataPath = $conf["DATA_PATH"] +$ComposeYml = "$ComposeDir\docker-compose.yml" +$OverrideYml = "$ComposeDir\docker-compose.override.yml" + +# ── Stop and remove container ───────────────────────────────────────────────── +Write-Host "Stopping and removing container..." +docker compose -f $ComposeYml -f $OverrideYml down 2>$null +Write-Host " Done." + +# ── Remove Docker image? ────────────────────────────────────────────────────── +$rmImage = Read-Host "Remove the DupFinder Docker image? Frees ~300-600 MB (Y/n)" +if ($rmImage -ne 'n' -and $rmImage -ne 'N') { + docker rmi dupfinder:latest 2>$null + Write-Host " Image removed." +} + +# ── Remove data directory? ──────────────────────────────────────────────────── +Write-Host "" +Write-Host "Data directory: $DataPath" +Write-Host "This contains the scan database and all decisions." +$rmData = Read-Host "Remove data directory? This CANNOT be undone. (y/N)" +if ($rmData -eq 'y' -or $rmData -eq 'Y') { + if (Test-Path $DataPath) { + Remove-Item $DataPath -Recurse -Force + Write-Host " Data directory removed." + } +} else { + Write-Host " Data directory kept at: $DataPath" +} + +# ── Remove shortcut ─────────────────────────────────────────────────────────── +if (Test-Path $ShortcutPath) { + Remove-Item $ShortcutPath -Force + Write-Host "Desktop shortcut removed." +} + +# ── Remove app directory ────────────────────────────────────────────────────── +if (Test-Path $AppDir) { + Remove-Item $AppDir -Recurse -Force + Write-Host "App directory removed: $AppDir" +} + +Write-Host "" +Write-Host " DupFinder has been uninstalled." -ForegroundColor Green +Write-Host ""