# Cratefox Windows installer (PowerShell). The Windows counterpart of install.sh. # One-liner the site hands out (after a valid code unlocks it): # $env:DWNHLPR_CODE='your-code'; irm https://cratefox.app/install.ps1 | iex # # Parity-hardened against the validated install.sh: it validates the code (and # surfaces the exact reason), installs Python via winget with guards, downloads # the gated app + the slskd Soulseek engine (win-x64), writes slskd.yml + .env # (a random Soulseek identity, reused on re-run), picks free ports, registers TWO # scheduled tasks (app + slskd) at logon, and GATES the success banner on # /api/health + slskd before claiming success (no false green). # # Unsigned by design: Windows or your antivirus may warn, which is expected for a # small indie tool. The command isn't hidden, you can read every line above. # # STATUS: authored on macOS, hardened by code review, NOT yet run on real Windows. # Items still needing a real Win 10/11 box (do not assume these work until tested): # the winsdk Windows.Media.Ocr call path, winget/PATH propagation in-session, # bsdtar on the mac-made .tar.gz, the Microsoft-Store python alias, and Defender/ # firewall behaviour. Everything else mirrors install.sh, which is validated. $ErrorActionPreference = 'Stop' # Force TLS 1.2 BEFORE any in-script HTTPS call. On a stock Windows PowerShell 5.1 # box the per-process default is often Ssl3/Tls (1.0), which Cloudflare/GitHub now # refuse - the very first code-validation call would die with "Could not create # SSL/TLS secure channel" and a misleading "check your internet". -bor preserves # TLS 1.3 where present; try/catch because the enum exists on all .NET 4.5+. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {} function Say($m){ Write-Host " $m" -ForegroundColor Cyan } function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow } function Die($m){ Write-Host "`n* $m" -ForegroundColor Red; exit 1 } $script:StepNum = 0 function Step($m){ $script:StepNum++; Write-Host ""; Write-Host "[$($script:StepNum)/5] $m" -ForegroundColor Cyan } function Note($m){ Write-Host " $m" -ForegroundColor DarkGray } # Ask: the distinct Magenta color for every 'Windows may pop up a box' moment # (firewall allow, winget/UAC Yes-No, SmartScreen More info -> Run anyway), so a # permission prompt reads as expected and calm, never as an error. Magenta is used # nowhere else, so it is instantly distinct from the Cyan steps and the Yellow '!' warns. function Ask($m){ Write-Host ""; Write-Host " >> Heads up: $m" -ForegroundColor Magenta } function AskDo($m){ Write-Host " $m" -ForegroundColor Magenta } # Loading spinner for the two poll loops the script already owns (the app health-gate # and the slskd wait). PURE OUTPUT: it only Write-Hosts frames and sleeps the SAME ~1s # the loops already waited (8 x 125ms = ~1000ms), so the 30-try wait budget is unchanged. # `r redraw + -NoNewline keeps it on one line; the caller clears/settles it after the loop. $script:SpinFrames = '|','/','-','\' function Wait-Spin($idx, $label){ for ($f = 0; $f -lt 8; $f++){ $frame = $script:SpinFrames[$f % $script:SpinFrames.Length] Write-Host ("`r {0} {1} (waited {2}s) " -f $frame, $label, ($idx + 1)) -NoNewline -ForegroundColor DarkGray Start-Sleep -Milliseconds 125 } } function Wait-Done($m){ Write-Host ("`r + " + $m + ' ') -ForegroundColor Green } $BASE = if ($env:DWNHLPR_BASE) { $env:DWNHLPR_BASE } else { 'https://cratefox.app' } $CODE = $env:DWNHLPR_CODE $HOMED = Join-Path $env:USERPROFILE 'dwnhlpr' $APPDIR = Join-Path $HOMED 'app' $SLSKD = Join-Path $HOMED 'slskd' $DL = Join-Path $env:USERPROFILE 'Music\dwnhlpr' # If Windows redirected the Music folder into OneDrive (increasingly the default), downloads # would sync-churn and become cloud placeholders slskd/mutagen can't read. Keep them outside it. try { $musicPath = [Environment]::GetFolderPath('MyMusic') if ($env:OneDrive -and $musicPath -and $musicPath.StartsWith($env:OneDrive, [System.StringComparison]::OrdinalIgnoreCase)) { $DL = Join-Path $HOMED 'downloads' } } catch {} $STAGING = Join-Path $SLSKD 'incoming' $SHARED = Join-Path $HOMED 'shared' $APP_PORT = 5055; $SLSKD_PORT = 5030; $SLSK_LISTEN = 50300 if (-not $CODE) { Die "No access code. Run with: `$env:DWNHLPR_CODE='your-code'; irm $BASE/install.ps1 | iex" } # Device fingerprint: locks this code to THIS PC. Hashed with the code plus a # persisted random id; the raw MachineGuid never leaves the machine. A code # claimed on one PC cannot be reused on another, even on the same network. $FpSalt = 'dwnhlpr-fp-v1' $didf = Join-Path $env:USERPROFILE '.dwnhlpr-deviceid' $fpRaw = '' try { $fpRaw = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -EA Stop).MachineGuid } catch {} $fpLocal = '' try { if (-not (Test-Path $didf)) { $rb = New-Object byte[] 16 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($rb) [System.IO.File]::WriteAllText($didf, (($rb | ForEach-Object { $_.ToString('x2') }) -join '')) } $fpLocal = (Get-Content $didf -Raw -EA Stop).Trim() } catch { $fpLocal = '' } # Defender/OneDrive/AV can block a new dotfile; fail soft, the MachineGuid hash is still valid $fpSha = [Security.Cryptography.SHA256]::Create() $fpBytes = [Text.Encoding]::UTF8.GetBytes("$fpRaw|$fpLocal|$CODE|$FpSalt") $DEVICE = (($fpSha.ComputeHash($fpBytes) | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0,32).ToLower() # 0) Windows 10 1809+ (Windows.Media.Ocr, modern tar and winget need it) try { $os = [Environment]::OSVersion.Version if ($os.Major -lt 10 -or ($os.Major -eq 10 -and [int]$os.Build -lt 17763)) { Die "Cratefox needs Windows 10 (version 1809) or newer. Update Windows, then run this again." } } catch {} # 1) validate the code early - GET the JSON so we surface the exact reason (not -Method Head) try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 15 "$BASE/api/app?code=$CODE&dev=$DEVICE&os=win&check=1" $j = $r.Content | ConvertFrom-Json if (-not $j.ok) { if ($j.spent) { Die "That code is already installed on another computer (each code locks to one machine). Ask for a fresh one at $BASE." } Die "That access code wasn't accepted, it is case-sensitive. Check it, or ask for a fresh one at $BASE." } } catch { $sc = $null; try { $sc = [int]$_.Exception.Response.StatusCode } catch {} if ($sc -eq 429) { Die "Too many tries. Wait a few minutes, then run this again with a valid code." } elseif ($sc -eq 403) { Die "That code wasn't accepted or is already used. Ask for a fresh one at $BASE." } else { Die "Couldn't reach $BASE to check your code. Check your internet connection and try again." } } Write-Host "" Write-Host " Setting up Cratefox - you're in good hands" -ForegroundColor Green Write-Host " I'll set everything up for you: the app, the music engine, and your private" -ForegroundColor Gray Write-Host " account. This takes a few minutes (up to about 15 the very first time). You'll see" -ForegroundColor Gray Write-Host " lots of text scroll past - that's just me working, and it's completely normal. You" -ForegroundColor Gray Write-Host " don't have to click or type anything, and the app opens on its own when it's ready." -ForegroundColor Gray Write-Host " I'll stay with you the whole way. You can just watch." -ForegroundColor Gray Ask "A few times, Windows may pop up a box asking permission (allow the app, a Yes/No, or a 'Windows protected your PC' screen where you click More info then Run anyway). That is normal and expected here. Say yes / allow each time, and I'll keep going." Step "Getting things ready (just sit back, nothing to do here)" New-Item -ItemType Directory -Force -Path $HOMED,$APPDIR,$SLSKD,$DL,$STAGING,(Join-Path $STAGING 'incomplete'),$SHARED | Out-Null # store the code so the app can offer one-paste updates later (device-locked, safe on-machine) try { [System.IO.File]::WriteAllText((Join-Path $HOMED '.code'), $CODE) } catch {} # Enable long paths so deep slskd uploader folders don't overflow the 260-char MAX_PATH. Best-effort (needs admin). try { Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -Type DWord -ErrorAction Stop } catch { Warn "Could not enable long-path support (some very deeply nested downloads may not move). Continuing." } # 2) Python - beware the Microsoft Store alias (a stub that just opens the Store), so # don't trust a bare "python" on PATH; verify it is a real interpreter. function Test-RealPython($exe){ if (-not $exe) { return $false } try { $p = & $exe -c "import sys; print(sys.prefix)" 2>$null; return ($LASTEXITCODE -eq 0 -and $p) } catch { return $false } } $PY = $null $cmd = Get-Command python -ErrorAction SilentlyContinue if ($cmd -and ($cmd.Source -notlike "*WindowsApps*") -and (Test-RealPython $cmd.Source)) { $PY = $cmd.Source } if (-not $PY) { Note "First time only: I'm installing a helper called Python. Give it a few minutes - if it looks stuck or frozen, it isn't, that's normal. Nothing for you to do." Ask "Windows might pop up a blue 'Do you want to allow this app to make changes?' box while Python installs, and may pause on 'Windows protected your PC'. That is Windows being careful, and it is expected here." AskDo "If you see the Yes/No box, click 'Yes'. If you see 'Windows protected your PC', click 'More info', then 'Run anyway'. If nothing pops up, great, nothing to do." $pyDone = $false # Preferred: winget (handles PATH). Absent on Windows 10 LTSC / Server / freshly-imaged boxes. if (Get-Command winget -ErrorAction SilentlyContinue) { $eapKeep = $ErrorActionPreference; $ErrorActionPreference = 'Continue' # winget logs to stderr; under Stop that aborts even on success winget install -e --id Python.Python.3.12 --silent --accept-source-agreements --accept-package-agreements $wgRc = $LASTEXITCODE; $ErrorActionPreference = $eapKeep if ($wgRc -eq 0) { $pyDone = $true } } # Fallback: fetch the official installer straight from python.org and run it per-user (no # admin needed). Covers a missing winget AND a winget that errored out. if (-not $pyDone) { Note "Getting Python directly from python.org..." $pyVer = '3.12.10' $pyArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } $pyExe = Join-Path $env:TEMP "python-$pyVer-$pyArch.exe" try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 300 "https://www.python.org/ftp/python/$pyVer/python-$pyVer-$pyArch.exe" -OutFile $pyExe Unblock-File $pyExe -ErrorAction SilentlyContinue $proc = Start-Process -FilePath $pyExe -ArgumentList '/quiet','InstallAllUsers=0','PrependPath=1','Include_launcher=0','Include_test=0' -Wait -PassThru if ($proc.ExitCode -eq 0) { $pyDone = $true } } catch {} Remove-Item $pyExe -Force -ErrorAction SilentlyContinue } if (-not $pyDone) { Die "Couldn't install Python automatically (winget and the python.org download both failed, usually a network or proxy problem). Install Python 3.12 from python.org yourself (tick 'Add python.exe to PATH'), then run this command again." } # refresh PATH in this session, then re-locate python (also check python.org's per-user path) $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') $cmd = Get-Command python -ErrorAction SilentlyContinue if ($cmd -and (Test-RealPython $cmd.Source)) { $PY = $cmd.Source } if (-not $PY) { $pyUserExe = Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe' if ((Test-Path $pyUserExe) -and (Test-RealPython $pyUserExe)) { $PY = $pyUserExe } } if (-not $PY) { Die "Python installed but isn't on PATH yet. Close this window, open a NEW PowerShell, and paste the command again." } } # 3) stop any prior dwnhlpr so a re-run doesn't collide on its own ports, then pick FREE ports foreach ($t in 'dwnhlpr.app','dwnhlpr.slskd') { try { Stop-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue } catch {} } function Get-OurEngines { Get-Process -Name 'pythonw','slskd' -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -and $_.Path.StartsWith($HOMED, [StringComparison]::OrdinalIgnoreCase) } catch { $false } } } Get-OurEngines | Stop-Process -Force -ErrorAction SilentlyContinue # Wait until our old engine is TRULY gone before probing ports or starting the new # one. Stop-Process returns immediately; slskd holds a per-session single-instance # lock, so a lingering old engine makes the replacement abort with "already # running" and a still-listening old port skews free-port selection. Mirror # install.sh _kill_wait: wait, then KILL any survivor, then wait again. for ($n = 0; $n -lt 24 -and (Get-OurEngines); $n++) { Start-Sleep -Milliseconds 500 } Get-OurEngines | Stop-Process -Force -ErrorAction SilentlyContinue for ($n = 0; $n -lt 24 -and (Get-OurEngines); $n++) { Start-Sleep -Milliseconds 500 } # Pick FREE ports, CLAIM-AWARE: a port chosen this run is remembered so the app, # slskd web, and slskd listen can never be handed the SAME number. During install # nothing is bound to a freshly-chosen port yet, so without this the overlapping # scan windows (5030-5079 vs 5055-5104) can return one number twice -> one binds, # the other dies, "can't reach slskd" (the recurring :5088 collision). Mirrors # install.sh _free + its 3-way distinct guard. $script:ClaimedPorts = @() function Test-CanBind([int]$p){ # actually try to bind: catches Windows reserved/excluded ranges (Hyper-V/WSL2/Docker/winnat) # that a Listen-only check misses, which otherwise fail at bind time with WSAEACCES. $l = $null try { $l = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Any, $p); $l.Start(); return $true } catch { return $false } finally { if ($l) { $l.Stop() } } } function Get-FreePort([int]$start){ for ($p = $start; $p -lt $start + 50; $p++){ if ($script:ClaimedPorts -contains $p) { continue } $used = $false try { if (Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue) { $used = $true } } catch {} if (-not $used -and -not (Test-CanBind $p)) { $used = $true } if (-not $used) { $script:ClaimedPorts += $p; return $p } } Die "Couldn't find a free network port near $start. Close some apps and run this again." } $SLSKD_PORT = Get-FreePort 5030 $APP_PORT = Get-FreePort 5055 $SLSK_LISTEN = Get-FreePort 50300 if (($APP_PORT -eq $SLSKD_PORT) -or ($SLSKD_PORT -eq $SLSK_LISTEN) -or ($APP_PORT -eq $SLSK_LISTEN)) { Die "Couldn't find free network ports (app=$APP_PORT slskd=$SLSKD_PORT listen=$SLSK_LISTEN). Close some apps and run this again." } # 4) download + extract the gated app tarball (Windows 10 1803+ ships bsdtar as tar.exe) if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { Die "Your Windows is missing the built-in 'tar' tool (needs Windows 10 1803 or newer). Update Windows, then run this again." } Step "Downloading Cratefox (this part's quick)" $tgz = Join-Path $HOMED 'app.tar.gz' try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 "$BASE/api/app?code=$CODE&dev=$DEVICE&os=win" -OutFile $tgz } catch { Die "Couldn't download Cratefox (a network or proxy problem). Check your connection and run this again." } $eapKeep = $ErrorActionPreference; $ErrorActionPreference = 'Continue' # a mac-made .tar.gz can make Windows bsdtar print harmless xattr warnings to stderr; don't abort on them tar -xzf $tgz -C $HOMED 2>&1 | Out-Null $tarRc = $LASTEXITCODE; $ErrorActionPreference = $eapKeep if ($tarRc -ne 0) { Remove-Item $tgz -Force -EA SilentlyContinue; Die "The download was incomplete or corrupt. Run the same command again." } Remove-Item $tgz -Force -EA SilentlyContinue if (-not (Test-Path (Join-Path $APPDIR 'app.py'))) { Die "The app didn't unpack correctly. Run the same command again." } # 5) venv + dependencies (mirror install.sh: hard set is gated, native wheels are best-effort # and installed ONE AT A TIME so a missing wheel for one can't sink the others) Step "Adding the pieces that make it work" Note "This is the slowest step, so it's a good moment for a coffee. It may go quiet for a few minutes - that's fine, just please leave this window open." & $PY -m venv (Join-Path $APPDIR 'venv') $VPY = Join-Path $APPDIR 'venv\Scripts\python.exe' if (-not (Test-Path $VPY)) { Die "Couldn't set up Python's virtual environment. Reinstall Python 3.12 from python.org (tick 'Add to PATH') and run this again." } # pip writes warnings/errors to stderr; under $ErrorActionPreference='Stop' that becomes a # terminating NativeCommandError (even with 2>$null), so a harmless warning or a missing # OPTIONAL wheel would abort the whole install. Soften EAP for pip; gate on the exit code only. $eapKeep = $ErrorActionPreference; $ErrorActionPreference = 'Continue' & $VPY -m pip install -q --upgrade pip 2>&1 | Out-Null & $VPY -m pip install -q flask requests anthropic pillow python-dotenv mutagen 2>&1 | Out-Null $coreRc = $LASTEXITCODE foreach ($pkg in 'numpy','soundfile','winsdk','pyrekordbox') { & $VPY -m pip install -q --only-binary=:all: $pkg 2>&1 | Out-Null # best-effort: key+BPM, OCR, USB export; a missing wheel must NOT abort } $ErrorActionPreference = $eapKeep if ($coreRc -ne 0) { Die "Couldn't install the core libraries the app needs (a network or pip problem)." } # 6) slskd Soulseek engine (win-x64) + clear Mark-of-the-Web + check it survived antivirus Step "Downloading the music engine that finds your songs" try { $tag = (Invoke-RestMethod -TimeoutSec 20 'https://api.github.com/repos/slskd/slskd/releases/latest').tag_name } catch { $tag = $null } if (-not $tag) { $tag = '0.25.1' } # GitHub rate-limit / offline fallback, matches install.sh $zip = Join-Path $SLSKD 'slskd.zip' try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 "https://github.com/slskd/slskd/releases/download/$tag/slskd-$tag-win-x64.zip" -OutFile $zip } catch { Die "Couldn't download the Soulseek engine (a network or proxy problem). Check your connection and run this again." } Expand-Archive -Path $zip -DestinationPath $SLSKD -Force Remove-Item $zip -Force -EA SilentlyContinue Get-ChildItem $SLSKD -Recurse | Unblock-File # clear Mark-of-the-Web so SmartScreen lets it run $slskdExe = Join-Path $SLSKD 'slskd.exe' if (-not (Test-Path $slskdExe)) { Die "Couldn't find the Soulseek engine after unpacking. Your antivirus may have removed it, allow the folder $SLSKD, then run this again." } # A half-extracted archive (interrupted Expand-Archive, or AV pruning files) can # leave slskd.exe present but wwwroot/ missing, which makes slskd abort with # "ContentPath ... wwwroot non-existent" and never open its web port. Mirror # install.sh: assert the web root too so the failure is obvious and self-corrects. if (-not (Test-Path (Join-Path $SLSKD 'wwwroot'))) { Die "The Soulseek engine unpacked incomplete (no wwwroot). Your antivirus may have removed files, allow the folder $SLSKD, then run this again." } # 7) slskd.yml + .env - a random Soulseek identity, REUSED on re-run so it keeps its reputation $ymlPath = Join-Path $SLSKD 'slskd.yml' $slskUser = $null; $slskPass = $null; $apiKey = $null if (Test-Path $ymlPath) { $y = Get-Content $ymlPath -Raw if ($y -match '(?m)^\s*username:\s*(.+)$') { $slskUser = $Matches[1].Trim() } if ($y -match '(?m)^\s*password:\s*(.+)$') { $slskPass = $Matches[1].Trim() } if ($y -match '(?m)^\s*key:\s*(.+)$') { $apiKey = $Matches[1].Trim() } } function New-Token([int]$bytes){ $b = New-Object byte[] $bytes; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); (([Convert]::ToBase64String($b)) -replace '[^A-Za-z0-9]','') } # 12 bytes -> 16 base64 chars; even after stripping +/= the result stays >=8 with # overwhelming probability, so .Substring(0,8) can't throw (6 bytes = exactly 8 # chars and stripping made it shorter ~22% of the time, an uncaught crash mid-install). if (-not $slskUser) { $slskUser = 'dwnhlpr_' + (New-Token 12).Substring(0,8) } if (-not $slskPass) { $slskPass = New-Token 16 } if (-not $apiKey) { $apiKey = New-Token 32 } # forward slashes are valid in slskd YAML and dodge backslash-escaping $dlY = $DL -replace '\\','/'; $stY = $STAGING -replace '\\','/'; $shY = $SHARED -replace '\\','/' $yml = @" soulseek: username: $slskUser password: $slskPass listen_port: $SLSK_LISTEN directories: downloads: $stY incomplete: $stY/incomplete shares: directories: - $dlY - $shY web: port: $SLSKD_PORT https: disabled: true authentication: api_keys: dwnhlpr: key: $apiKey role: readwrite cidr: 127.0.0.1/32,::1/128 "@ [System.IO.File]::WriteAllText($ymlPath, $yml, (New-Object System.Text.UTF8Encoding($false))) $envText = @" SLSKD_URL=http://127.0.0.1:$SLSKD_PORT SLSKD_API_KEY=$apiKey DOWNLOAD_DIR=$DL SLSKD_DOWNLOADS_DIR=$STAGING PORT=$APP_PORT "@ [System.IO.File]::WriteAllText((Join-Path $APPDIR '.env'), $envText, (New-Object System.Text.UTF8Encoding($false))) # 8) free on-device OCR check (Windows.Media.Ocr via winsdk) - warn, never fail. # Soften EAP so a stderr traceback from the import can't become a fatal NativeCommandError. $eapKeep = $ErrorActionPreference; $ErrorActionPreference = 'Continue' $env:PYTHONPATH = $APPDIR # so ocr_windows (in APPDIR) imports; apostrophe-safe vs r'$APPDIR' in source & $VPY -c "import ocr_windows,sys; sys.exit(0 if ocr_windows.available() else 1)" 2>&1 | Out-Null $ocrRc = $LASTEXITCODE; Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue; $ErrorActionPreference = $eapKeep if ($ocrRc -ne 0) { Warn "Free on-device screenshot reading isn't available on this PC yet. You can still paste a track list as text, or add an Anthropic API key to $APPDIR\.env." } # 9) firewall: pre-authorize slskd's listen port (best-effort, needs admin) try { New-NetFirewallRule -DisplayName 'dwnhlpr slskd' -Direction Inbound -Program $slskdExe -Action Allow -ErrorAction Stop | Out-Null } catch { Ask "Windows may ask to allow the music engine through the firewall on first run. When that box appears, click 'Allow access'. I'll be right here." } # 10) autostart BOTH at logon (engine + app). Use a per-user Startup-folder shortcut, NOT a # scheduled task: Register-ScheduledTask goes through the CIM task store, which needs admin and # failed with "Access is denied" (0x80070005) on a standard account - and this installer must run # WITHOUT admin. A .lnk in the user's Startup folder launches at logon with no elevation, sets the # working dir, and is best-effort (a failure here must never block the install). $dataDir = Join-Path $SLSKD 'data' $slskdArgs = "--config `"$ymlPath`" --app-dir `"$dataDir`"" $pyw = Join-Path $APPDIR 'venv\Scripts\pythonw.exe' function Add-Autostart($name, $target, $arguments, $workdir) { $startup = [Environment]::GetFolderPath('Startup') $sc = (New-Object -ComObject WScript.Shell).CreateShortcut((Join-Path $startup "$name.lnk")) $sc.TargetPath = $target; $sc.Arguments = $arguments; $sc.WorkingDirectory = $workdir; $sc.WindowStyle = 7 $sc.Save() } # slskd is a console app: a Startup .lnk to it flashes a black window every logon (and a user who # closes it kills downloads). Launch it via a hidden VBS (WScript.Shell.Run style 0 = hidden) aimed # at wscript.exe so nothing shows. The pythonw app has no console and stays a plain .lnk. $wscript = Join-Path $env:WINDIR 'System32\wscript.exe' $slskdVbs = Join-Path $SLSKD 'start-slskd.vbs' $vbs = @" Set sh = CreateObject("WScript.Shell") sh.CurrentDirectory = "$SLSKD" sh.Run """$slskdExe"" --config ""$ymlPath"" --app-dir ""$dataDir""", 0, False "@ [System.IO.File]::WriteAllText($slskdVbs, $vbs, (New-Object System.Text.UTF8Encoding($false))) try { Add-Autostart 'dwnhlpr-slskd' $wscript ('"' + $slskdVbs + '"') $SLSKD Add-Autostart 'dwnhlpr-app' $pyw ('"' + (Join-Path $APPDIR 'app.py') + '"') $APPDIR } catch { Warn "Couldn't set Cratefox to start automatically at login (it is running now). You can relaunch it later from $APPDIR." } Ask "Cratefox is a small indie tool, so Windows may show a blue 'Windows protected your PC' screen, or your antivirus may ask, as the app starts. This is normal for tools like this and does not mean anything is wrong." AskDo "If you see 'Windows protected your PC': click 'More info', then 'Run anyway'. If antivirus asks: choose 'Allow' or 'Keep'." Step "Starting it up - almost there" Start-Process $slskdExe -ArgumentList $slskdArgs -WorkingDirectory $SLSKD -WindowStyle Hidden # Pre-flight: import the app once, synchronously, so ANY load error is SHOWN. Under pythonw the # app has no console, so an import/startup error would otherwise be invisible (the silent # "didn't start"). EAP softened so the traceback is captured instead of aborting the installer. $eapKeep = $ErrorActionPreference; $ErrorActionPreference = 'Continue' $env:PYTHONPATH = $APPDIR # pass APPDIR via env so an apostrophe in the path (O'Brien) can't SyntaxError the -c source $loadErr = & $VPY -c 'import app' 2>&1 | Out-String $loadRc = $LASTEXITCODE Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue $ErrorActionPreference = $eapKeep if ($loadRc -ne 0) { Write-Host "`n--- Cratefox could not load on this PC (please screenshot this) ---" -ForegroundColor Yellow Write-Host $loadErr -ForegroundColor Gray Die "Cratefox hit an error while loading. Screenshot THIS window and send it to whoever shared Cratefox." } Start-Process $pyw -ArgumentList ('"' + (Join-Path $APPDIR 'app.py') + '"') -WorkingDirectory $APPDIR -WindowStyle Hidden # 11) VERIFY before claiming success - poll on 127.0.0.1, NOT 'localhost' (Windows resolves # localhost to IPv6 ::1 first, but the app binds IPv4 127.0.0.1, so a localhost probe can miss a # perfectly healthy app). No false green - mirrors install.sh. $appOk = $false Note "Almost there. Waking up Cratefox and waiting for it to answer (this is usually quick)." for ($i = 0; $i -lt 30; $i++) { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 4 "http://127.0.0.1:$APP_PORT/api/ping" | Out-Null; $appOk = $true; break } catch { Wait-Spin $i 'Waiting for Cratefox to answer' } } if ($appOk) { Wait-Done 'Cratefox is up.' } if (-not $appOk) { Write-Host "`n--- Cratefox startup log (please screenshot ALL of this) ---" -ForegroundColor Yellow $errLog = Join-Path $APPDIR 'app-err.log' if (Test-Path $errLog) { Get-Content $errLog -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } } else { Write-Host " (no app-err.log was written - the app may have exited before logging)" -ForegroundColor Gray } Die "Cratefox didn't answer on port $APP_PORT. Screenshot THIS WHOLE window (with the log above) and send it to whoever shared dwnhlpr." } $slskdOk = $false Note "Now waking the music engine that finds your songs. Give it a few seconds." for ($i = 0; $i -lt 30; $i++) { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Headers @{ 'X-API-Key' = $apiKey } "http://127.0.0.1:$SLSKD_PORT/api/v0/application" | Out-Null; $slskdOk = $true; break } catch { Wait-Spin $i 'Waiting for the music engine' } } if ($slskdOk) { Wait-Done 'Music engine is up.' } if (-not $slskdOk) { Die "The music engine (slskd) didn't come up on port $SLSKD_PORT. Downloads won't work yet. Send a screenshot of this window to whoever shared dwnhlpr." } Start-Process "http://127.0.0.1:$APP_PORT" Write-Host "" Write-Host " =====================================================" -ForegroundColor Green Write-Host " All done! Cratefox is installed and running." -ForegroundColor Green Write-Host " =====================================================" -ForegroundColor Green Write-Host "" Write-Host " Nice - you're all set. Your browser just opened Cratefox for you." -ForegroundColor Gray Write-Host " To open it again any time, go to this address:" -ForegroundColor Gray Write-Host " http://127.0.0.1:$APP_PORT" -ForegroundColor White Write-Host " Your downloaded songs are saved here:" -ForegroundColor Gray Write-Host " $DL" -ForegroundColor White Write-Host " It starts by itself every time you turn on your PC, so you're good to go." -ForegroundColor Gray Write-Host " You can safely close this window now." -ForegroundColor Gray Write-Host ""