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