Files
duplicate-finder/installer/install.ps1
tocmo 1d46b9945d 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 <noreply@anthropic.com>
2026-04-05 01:32:32 -04:00

277 lines
12 KiB
PowerShell

#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"
}