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>
This commit is contained in:
tocmo
2026-04-05 01:32:32 -04:00
parent b519e065cb
commit 1d46b9945d
4 changed files with 576 additions and 0 deletions

153
build-release.ps1 Normal file
View File

@@ -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

View File

@@ -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
}
}

276
installer/install.ps1 Normal file
View File

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

76
installer/uninstall.ps1 Normal file
View File

@@ -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 ""