diff --git a/scripts/windows/check-portable-leaks.ps1 b/scripts/windows/check-portable-leaks.ps1 new file mode 100644 index 00000000..2052b394 --- /dev/null +++ b/scripts/windows/check-portable-leaks.ps1 @@ -0,0 +1,393 @@ +<# +.SYNOPSIS + Runs an extracted portable StemDeck once and lists everything it wrote + outside its own folder. Exits 1 when it wrote anything there. + +.DESCRIPTION + A portable StemDeck keeps everything in its own folder, except the library + folder the user picks in Settings. This checks that on a real launch: + + 1. Lists, before and after the run, every file changed today and every + folder under %LOCALAPPDATA%, %APPDATA%, %USERPROFILE%, Documents and + %ProgramData%, the entries beside the package folder, and every + HKEY_CURRENT_USER registry entry that names StemDeck. + 2. Starts StemDeck.exe from the package folder, with the environment of a + double-click (no STEMDECK_*, PYTHON*, XDG_*, TORCH_HOME, HF_*, PIP_* or + TMPDIR from this shell), drives one ordinary session through + drive-portable.mjs (setup with the model step skipped, or run in full with + -WithModels; Settings opened; Export logs pressed and its save dialog + closed; one short WAV imported and split), then closes the window the + normal way. + 3. With -RedirectProfile, also points LOCALAPPDATA, APPDATA, TEMP, TMP, + USERPROFILE (and HOME when set) at an empty folder under -WorkDir. Anything + that finds the user profile through the environment then writes there, + where nothing else on the machine writes, so every file in it is the + package's own leak. + + Other programs write to the same profile folders while this runs. Each + change is therefore sorted: "StemDeck" (a path that names StemDeck, its + identifier or this package folder, a file the window downloaded by itself, + or anything in the redirected profile: the check fails), "Windows" (what + Windows and the graphics driver write for every program: listed, cannot be + prevented), "Review" (a path that names a tool StemDeck uses, such as pip, + torch or WebView2: read it), and "Other" (everything else, written to the + report file only). + + The driver connects over the DevTools port, and Playwright then sends any + download the window makes by itself into -WorkDir\driver-temp instead of the + Downloads folder. Those files are listed as leaks: without the driver they + would have gone to Downloads unasked. + + Windows only, and never in CI: it needs a desktop session, WebView2 and a + built package. Uses robocopy /L (lists only, copies nothing) and reg query. + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/check-portable-leaks.ps1 ` + -PackageDir data\leak\pkg -WorkDir data\leak\work -Wav data\leak\six-seconds.wav +#> +param( + [Parameter(Mandatory)][string]$PackageDir, + [Parameter(Mandatory)][string]$WorkDir, + [string]$Wav, + [switch]$RedirectProfile, + [switch]$WithModels, + [switch]$Manual, + [int]$DevToolsPort = 9339, + [int]$Minutes = 20 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$PackageDir = (Resolve-Path $PackageDir).Path.TrimEnd('\') +$exe = Join-Path $PackageDir "StemDeck.exe" +if (-not (Test-Path (Join-Path $PackageDir "portable.txt")) -or -not (Test-Path $exe)) { + throw "$PackageDir is not an extracted portable package (no StemDeck.exe and portable.txt)" +} +New-Item -ItemType Directory -Force $WorkDir | Out-Null +$WorkDir = (Resolve-Path $WorkDir).Path.TrimEnd('\') +if ($WorkDir.StartsWith("$PackageDir\", [StringComparison]::OrdinalIgnoreCase)) { + throw "-WorkDir must be outside the package folder" +} +if (Get-ChildItem $WorkDir -Force) { + throw "-WorkDir must be empty: $WorkDir" +} +if (-not $Manual -and -not $Wav) { + throw "-Wav is needed unless -Manual is given" +} +if (Get-ChildItem (Join-Path $PackageDir "data") -Recurse -File -Force -ErrorAction SilentlyContinue) { + Write-Warning "data\ already holds files from an earlier run, so this is not a first run" +} +# The session imports and splits a song into the package's library. A package +# used before may have its library set elsewhere, even to a real one. +$settingsFile = Join-Path $PackageDir "data\settings.json" +if (Test-Path $settingsFile) { + $library = (Get-Content $settingsFile -Raw | ConvertFrom-Json).PSObject.Properties["jobs_dir"] + if ($library -and $library.Value -and + -not ([IO.Path]::GetFullPath($library.Value)).StartsWith("$PackageDir\", [StringComparison]::OrdinalIgnoreCase)) { + throw "this package's library is $($library.Value), outside the package: use a fresh extract" + } +} +if (-not $Manual -and (Get-NetTCPConnection -LocalPort $DevToolsPort -State Listen -ErrorAction SilentlyContinue)) { + throw "port $DevToolsPort is in use: the driver would reach whatever listens there, not this package" +} + +$documents = [Environment]::GetFolderPath("MyDocuments") +$roots = @( + @{ Path = $env:LOCALAPPDATA; Exclude = @() }, + @{ Path = $env:APPDATA; Exclude = @() }, + @{ Path = $env:USERPROFILE; Exclude = @((Join-Path $env:USERPROFILE "AppData")) }, + @{ Path = $env:ProgramData; Exclude = @() } +) +if (-not $documents.StartsWith("$env:USERPROFILE\", [StringComparison]::OrdinalIgnoreCase)) { + $roots += @{ Path = $documents; Exclude = @() } +} + +function Invoke-RobocopyList { + param([string]$Root, [string[]]$Exclude, [string[]]$Options) + $arguments = @($Root, (Join-Path $WorkDir "no-such-destination"), "/L", "/S", "/NJH", "/NJS", + "/NC", "/FP", "/XJ", "/R:0", "/W:0") + $Options + if ($Exclude) { $arguments += @("/XD") + $Exclude } + $lines = & robocopy @arguments + # robocopy: 0 to 7 is success, 8 means some entries could not be read + # (in use or access denied), 16 is a fatal error. + if ($LASTEXITCODE -ge 16) { throw "robocopy failed on $Root with exit code $LASTEXITCODE" } + $lines +} + +# Files changed since the day before the run, as "path|time|size", and all folders. +function Get-ProfileState { + param([string]$Since) + $files = @{} + $folders = @{} + foreach ($root in $roots) { + if (-not (Test-Path $root.Path)) { continue } + foreach ($line in (Invoke-RobocopyList $root.Path $root.Exclude @("/NDL", "/BYTES", "/TS", "/MAXAGE:$Since"))) { + if ($line -match '^\s*(\d+)\s+(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})\s+(.+)$') { + $files[$Matches[3]] = "$($Matches[2])|$($Matches[1])" + } + } + foreach ($line in (Invoke-RobocopyList $root.Path $root.Exclude @("/E", "/NFL", "/NS"))) { + $folder = $line.Trim() + if ($folder) { $folders[$folder.TrimEnd('\')] = $true } + } + } + $beside = @{} + foreach ($entry in Get-ChildItem (Split-Path $PackageDir) -Force) { + if ($entry.FullName -in @($PackageDir, $WorkDir)) { continue } + $beside[$entry.FullName] = "$($entry.LastWriteTimeUtc.Ticks)" + } + # reg query prints a key, then its values under it: keep each value with + # its key, so a value that changed is reported under the right key. + $registry = @{} + $key = "" + foreach ($line in (& reg query HKCU /s /f stemdeck 2>$null)) { + if ($line -match '^HKEY_') { $key = $line; $registry[$key] = $true } + elseif ($line.Trim() -and $line -notmatch '^End of search') { $registry["$key :: $($line.Trim())"] = $true } + } + @{ Files = $files; Folders = $folders; Beside = $beside; Registry = $registry } +} + +# A path the package's own processes would write to: StemDeck by name, its +# Tauri identifier, a StemDeck file in Downloads (a window download in a +# -Manual run), or this package folder in Windows' own records of it. +$ownPattern = '(?i)(\\StemDeck(\\|$)|\\app\.stemdeck\.desktop(\\|$)|\\Downloads\\StemDeck[^\\]*$|' + + [regex]::Escape($PackageDir) + '|' + [regex]::Escape($PackageDir.Replace('\', '#')) + ')' +# A tool StemDeck runs, whose cache or crash dump could be StemDeck's or +# another program's: listed for a person to read. +$reviewPattern = '(?i)(\\pip\\|matplotlib|\\torch\\|\\hub\\|huggingface|yt-dlp|\\deno\\|numba|' + + '__pycache__|EBWebView|EdgeWebView|msedgewebview2|CrashDumps\\(python|ffmpeg|ffprobe|uv)|\\\.cache\\)' +# Claude Code's own folders carry the repository name, never StemDeck's writes. +$toolingPattern = '(?i)(\\\.claude\\|\\Temp\\claude\\|\\claude-cli-nodejs\\)' +# Keys Windows itself writes for every program a user starts (launch counts, +# the Program Compatibility Assistant, the shell's name cache, privacy +# consent). No program can keep Windows from writing them. +$windowsPattern = '(?i)^HKEY_CURRENT_USER\\Software\\(Microsoft\\Windows\\CurrentVersion\\Explorer\\|' + + 'Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\|Classes\\Local Settings\\Software\\Microsoft\\Windows\\Shell\\MuiCache|' + + 'Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\|Microsoft\\Windows\\CurrentVersion\\Search\\)' + +# Files Windows and the graphics driver keep for every program that draws or +# looks up a folder: shader caches (the WebView2 GPU process compiles shaders), +# the shell's folder caches, WinINet's cache and the account cache. They follow +# the profile in the environment, so a redirected run finds them there. +$windowsFilePattern = '(?i)\\AppData\\(Local|LocalLow)\\(NVIDIA|AMD|Intel)\\|\\D3DSCache\\|' + + '\\Microsoft\\(Windows\\(Caches|INetCache|INetCookies|History)|IdentityCache|Feeds Cache|Internet Explorer)\\' + +function Get-Category { + param([string]$Path) + if ($Path -match $toolingPattern) { return "Other" } + if ($Path -match $windowsPattern) { return "Windows" } + if ($Path -match $ownPattern) { return "StemDeck" } + if ($Path -match $reviewPattern) { return "Review" } + "Other" +} + +$since = (Get-Date).AddDays(-1).ToString("yyyyMMdd") +Write-Host "Taking the 'before' snapshot (a few minutes)..." +$before = Get-ProfileState $since +$started = Get-Date + +$profileDir = Join-Path $WorkDir "profile" +$info = New-Object System.Diagnostics.ProcessStartInfo $exe +$info.WorkingDirectory = $PackageDir +$info.UseShellExecute = $false +$removed = @() +foreach ($name in @($info.EnvironmentVariables.Keys)) { + if ($name -match '^(STEMDECK_|PYTHON|VIRTUAL_ENV|CONDA|XDG_|TORCH_HOME$|HF_|HUGGINGFACE|PIP_|UV_|MPLCONFIGDIR$|NUMBA_|TMPDIR$|WEBVIEW2_)') { + $info.EnvironmentVariables.Remove($name) + $removed += $name + } +} +if ($RedirectProfile) { + $redirect = @{ + LOCALAPPDATA = Join-Path $profileDir "Local" + APPDATA = Join-Path $profileDir "Roaming" + TEMP = Join-Path $profileDir "Temp" + TMP = Join-Path $profileDir "Temp" + USERPROFILE = Join-Path $profileDir "home" + } + if ($info.EnvironmentVariables.ContainsKey("HOME")) { $redirect.HOME = Join-Path $profileDir "home" } + foreach ($name in $redirect.Keys) { + New-Item -ItemType Directory -Force $redirect[$name] | Out-Null + $info.EnvironmentVariables[$name] = $redirect[$name] + } +} +if (-not $Manual) { + $info.EnvironmentVariables["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = "--remote-debugging-port=$DevToolsPort" +} + +Write-Host "Starting $exe" +$app = [System.Diagnostics.Process]::Start($info) +$driven = "not driven (-Manual)" +if ($Manual) { + Write-Host "Use StemDeck, then close its window." + $app.WaitForExit() +} else { + # The driver's own temporary files (Playwright's) go to -WorkDir, so they + # are not mistaken for the package's. + $driverTemp = Join-Path $WorkDir "driver-temp" + New-Item -ItemType Directory -Force $driverTemp | Out-Null + $userTemp = $env:TEMP, $env:TMP + # Whatever fails below, the app is closed and the report still written. + try { + $driver = Join-Path $PSScriptRoot "drive-portable.mjs" + $env:TEMP = $driverTemp + $env:TMP = $driverTemp + $models = if ($WithModels) { "warmup" } else { "skip" } + # Windows PowerShell 5.1 turns a native stderr line into a terminating + # error under "Stop"; the driver's errors belong in its log. + $ErrorActionPreference = "Continue" + & node $driver $DevToolsPort $app.Id (Resolve-Path $Wav).Path $Minutes $models 2>&1 | + ForEach-Object { "$_" } | Tee-Object -FilePath (Join-Path $WorkDir "drive.log") | Write-Host + $driverExit = $LASTEXITCODE + $ErrorActionPreference = "Stop" + $driven = "drive-portable.mjs exit code $driverExit" + if ($driverExit -notin @(0, 3)) { $driven += " (see drive.log)" } + if ($driverExit -eq 3) { + # No DevTools port: setup runs by itself with -WithModels (with Skip it + # waits for a press), so wait for the backend it starts and run the + # same session over HTTP: the settings Settings shows, then one import. + $until = (Get-Date).AddMinutes($Minutes) + $port = $null + while (-not $port -and (Get-Date) -lt $until) { + $backend = Get-CimInstance Win32_Process -Filter "Name = 'python.exe'" | Where-Object { + $_.CommandLine -and $_.CommandLine.StartsWith("`"$PackageDir\", [StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine -match 'uvicorn .*--port (\d+)' + } | Select-Object -First 1 + if ($backend -and $backend.CommandLine -match '--port (\d+)') { $port = $Matches[1] } else { Start-Sleep -Seconds 2 } + } + if (-not $port) { throw "the package's backend did not start within $Minutes minutes" } + $base = "http://127.0.0.1:$port" + while ((Get-Date) -lt $until) { + try { Invoke-RestMethod "$base/api/health" -TimeoutSec 5 | Out-Null; break } catch { Start-Sleep -Seconds 2 } + } + Invoke-RestMethod "$base/api/settings" | Out-Null + Add-Type -AssemblyName System.Net.Http + $client = New-Object System.Net.Http.HttpClient + $form = New-Object System.Net.Http.MultipartFormDataContent + $audio = New-Object System.Net.Http.ByteArrayContent(, [IO.File]::ReadAllBytes((Resolve-Path $Wav).Path)) + $form.Add($audio, "file", (Split-Path $Wav -Leaf)) + $response = $client.PostAsync("$base/api/jobs", $form).Result + $answer = $response.Content.ReadAsStringAsync().Result + if (-not $response.IsSuccessStatusCode) { throw "the import over HTTP was refused: $answer" } + $job = ($answer | ConvertFrom-Json).job_id + do { + Start-Sleep -Seconds 3 + $status = (Invoke-RestMethod "$base/api/jobs/$job").status + } while ($status -notin @("done", "error", "failed", "cancelled", "stopped") -and (Get-Date) -lt $until) + $driven += ", session over HTTP instead: settings read, job $job $status" + } + } catch { + $driven += ", session failed: $_" + } finally { + $ErrorActionPreference = "Stop" + $env:TEMP, $env:TMP = $userTemp + Write-Host "Closing the window" + [void]$app.CloseMainWindow() + if (-not $app.WaitForExit(90000)) { + Write-Warning "StemDeck did not exit within 90 s of closing its window; stopping it" + & taskkill /PID $app.Id /T /F | Out-Null + $driven += ", window close did not end the app (taskkill used)" + } + } +} + +# The backend and WebView2 end shortly after the window: wait for every +# process started from, or keeping its profile in, the package folder. +$deadline = (Get-Date).AddSeconds(60) +do { + $left = @(Get-CimInstance Win32_Process | Where-Object { + $_.CommandLine -and $_.CommandLine.IndexOf($PackageDir, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and + $_.CommandLine -notmatch 'check-portable-leaks\.ps1' + }) + if ($left.Count -eq 0) { break } + Start-Sleep -Seconds 1 +} while ((Get-Date) -lt $deadline) +if ($left.Count -gt 0) { + $driven += ", $($left.Count) process(es) still running from the package 60 s after exit" +} +Start-Sleep -Seconds 3 +$ended = Get-Date +# Another StemDeck (an installed one, another portable folder) writes to the +# same places under its own name: its writes would look like this package's. +$others = @(Get-CimInstance Win32_Process -Filter "Name = 'StemDeck.exe'" | Where-Object { + $_.CreationDate -lt $ended -and $_.ExecutablePath -ne $exe + } | ForEach-Object { $_.ExecutablePath }) +if ($others) { + $driven += ", ANOTHER StemDeck ran during the check: $($others -join ', ')" +} + +Write-Host "Taking the 'after' snapshot (a few minutes)..." +$after = Get-ProfileState $since + +$changes = New-Object System.Collections.Generic.List[object] +$startedText = $started.AddSeconds(-1).ToString("yyyy/MM/dd HH:mm:ss", [Globalization.CultureInfo]::InvariantCulture) +foreach ($path in $after.Files.Keys) { + $state = $after.Files[$path] + if ($before.Files[$path] -eq $state) { continue } + # Only what changed while StemDeck ran, not what another program wrote + # while the 'before' snapshot was being taken. + if (($state.Split('|')[0]) -lt $startedText) { continue } + $kind = if ($before.Files.ContainsKey($path)) { "changed file" } else { "new file" } + $changes.Add([pscustomobject]@{ Category = Get-Category $path; Kind = $kind; Path = $path }) +} +foreach ($folder in $after.Folders.Keys) { + if (-not $before.Folders.ContainsKey($folder)) { + $changes.Add([pscustomobject]@{ Category = Get-Category $folder; Kind = "new folder"; Path = $folder }) + } +} +foreach ($path in $after.Beside.Keys) { + if ($before.Beside[$path] -ne $after.Beside[$path]) { + $changes.Add([pscustomobject]@{ Category = "StemDeck"; Kind = "beside the package"; Path = $path }) + } +} +foreach ($entry in $after.Registry.Keys) { + if (-not $before.Registry.ContainsKey($entry)) { + $changes.Add([pscustomobject]@{ Category = Get-Category $entry; Kind = "registry"; Path = $entry }) + } +} +if (-not $Manual) { + foreach ($download in Get-ChildItem $driverTemp -Directory -Filter "playwright-artifacts-*" | Get-ChildItem -File) { + $changes.Add([pscustomobject]@{ Category = "StemDeck"; Kind = "window download"; Path = $download.FullName }) + } +} +if ($RedirectProfile) { + # Folders are listed only when empty: one that holds files is listed + # through them. + $entries = Get-ChildItem @($redirect.Values | Sort-Object -Unique) -Recurse -Force | Where-Object { + -not $_.PSIsContainer -or -not (Get-ChildItem $_.FullName -Force) + } + foreach ($entry in $entries) { + $category = if ($entry.FullName -match $windowsFilePattern) { "Windows" } else { "StemDeck" } + $changes.Add([pscustomobject]@{ Category = $category; Kind = "redirected profile"; Path = $entry.FullName }) + } +} + +$sections = [ordered]@{ + StemDeck = "written outside the package folder by the package. Each one is a leak." + Windows = "written by Windows or the graphics driver for every program that starts, draws or looks up a folder; no program can prevent these." + Review = "names a tool StemDeck uses, but another program on this machine may have written it." + Other = "unrelated programs on this machine." +} +$body = @( + "Portable leak check" + "Package : $PackageDir" + "Run : $($started.ToUniversalTime().ToString('u')) to $($ended.ToUniversalTime().ToString('u'))" + "Profile : $(if ($RedirectProfile) { "redirected to $profileDir" } else { 'the real one' })" + "Session : $driven" + "Env removed: $(if ($removed) { $removed -join ', ' } else { 'none' })" +) +$shown = @() +foreach ($category in $sections.Keys) { + $entries = @($changes | Where-Object Category -eq $category | Sort-Object Path) + $lines = @("", "$category ($($entries.Count)): $($sections[$category])") + + @($entries | ForEach-Object { " [$($_.Kind)] $($_.Path)" }) + $body += $lines + if ($category -ne "Other") { $shown += $lines } +} +$report = Join-Path $WorkDir "report.txt" +Set-Content -Path $report -Value $body -Encoding utf8 +($body[0..5] + $shown) | Write-Host +Write-Host "Full report: $report" +if (@($changes | Where-Object Category -eq "StemDeck").Count -gt 0) { exit 1 } +exit 0 diff --git a/scripts/windows/drive-portable.mjs b/scripts/windows/drive-portable.mjs new file mode 100644 index 00000000..c53eca35 --- /dev/null +++ b/scripts/windows/drive-portable.mjs @@ -0,0 +1,163 @@ +// Drives a running portable StemDeck through one ordinary session, for +// check-portable-leaks.ps1: first-run setup (with "skip", the AI model step is +// skipped and each model downloads when first needed, the way a real Split +// fetches it; with "warmup", the step runs in full), Settings opened, Export +// logs pressed, and one short WAV imported and split. +// +// It reaches the window through the WebView2 DevTools port the leak check +// opens with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. It never quits the app: +// the leak check closes the window itself, so the shutdown is the normal one. +// +// Usage: node scripts/windows/drive-portable.mjs [minutes] [skip|warmup] + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { chromium } from "@playwright/test"; + +const [port, appPid, wav, minutes = "20", models = "skip"] = process.argv.slice(2); +if (!port || !appPid || !wav || !["skip", "warmup"].includes(models)) { + console.error("usage: drive-portable.mjs [minutes] [skip|warmup]"); + process.exit(2); +} + +// Closes (cancels) every visible standard dialog of the app's process: the +// native save dialog is not part of the page, so DevTools cannot reach it. +function closeSaveDialogs(pid) { + const script = ` +Add-Type @' +using System; using System.Runtime.InteropServices; using System.Text; +public static class SaveDialogs { + delegate bool EnumProc(IntPtr window, IntPtr data); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr data); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder name, int size); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, IntPtr w, IntPtr l); + public static int Close(uint target) { + int closed = 0; + EnumWindows((window, data) => { + uint pid; GetWindowThreadProcessId(window, out pid); + var name = new StringBuilder(64); GetClassName(window, name, 64); + if (pid == target && name.ToString() == "#32770" && IsWindowVisible(window)) { + PostMessage(window, 0x0010, IntPtr.Zero, IntPtr.Zero); closed++; + } + return true; + }, IntPtr.Zero); + return closed; + } +} +'@ +[SaveDialogs]::Close(${Number(pid)})`; + const powershell = `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + const encoded = Buffer.from(script, "utf16le").toString("base64"); + const result = spawnSync(powershell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { + encoding: "utf8", + }); + if (result.status !== 0) throw new Error(`closing the save dialog failed: ${result.stderr}`); + return Number(result.stdout.trim()); +} +const setupButtons = models === "skip" ? ["#skip-models", "#continue"] : ["#continue"]; +const deadline = Date.now() + Number(minutes) * 60_000; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const say = (line) => console.log(`[drive] ${line}`); + +function remaining() { + const left = deadline - Date.now(); + if (left <= 0) throw new Error("timed out"); + return left; +} + +// WebView2 does not always open the DevTools port: on a first run with the +// profile redirected it stayed closed. Exit code 3 tells the leak check to +// run the session over HTTP instead. +async function connect() { + const giveUp = Date.now() + 120_000; + for (;;) { + try { + return await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + } catch { + remaining(); + if (Date.now() > giveUp) { + say("the window's DevTools port did not open"); + process.exit(3); + } + await sleep(1000); + } + } +} + +const browser = await connect(); +say("connected to the window"); +while (!browser.contexts()[0]?.pages().length) { + remaining(); + await sleep(500); +} +const page = browser.contexts()[0].pages()[0]; + +// Setup ends with the window moving to the backend's own address. +let settingsReady = false; +while (!settingsReady) { + remaining(); + if (page.url().startsWith("http://127.0.0.1:")) { + settingsReady = await page.locator("#settingsBtn").isVisible().catch(() => false); + } else { + for (const id of setupButtons) { + const button = page.locator(id); + if ((await button.isVisible().catch(() => false)) && (await button.isEnabled().catch(() => false))) { + say(`setup: pressing ${id}`); + await button.click().catch(() => {}); + } + } + if (await page.locator("#retry").isVisible().catch(() => false)) { + const status = await page.locator("#status").textContent().catch(() => ""); + throw new Error(`setup failed: ${status}`); + } + } + await sleep(1000); +} +say(`studio open at ${page.url()}`); + +await page.locator("#settingsBtn").click(); +await page.locator(".settings-tab").first().waitFor({ state: "visible", timeout: remaining() }); +say("Settings opened"); + +// Export logs: the desktop app answers with its native save dialog, which is +// closed here so nothing is saved. A build that lets the window download the +// zip by itself leaves it in Playwright's folder, where the leak check finds it. +await page.locator('.settings-tab[data-tab="logs"]').click(); +await page.locator(".settings-export-logs").click(); +let closed = 0; +for (const giveUp = Date.now() + 15_000; closed === 0 && Date.now() < giveUp; ) { + await sleep(1000); + closed = closeSaveDialogs(appPid); +} +// No dialog means the zip went some other way: the leak check then finds it +// in Playwright's folder if the window downloaded it. +say(closed ? "Export logs: save dialog shown and closed" : "WARNING: Export logs showed no save dialog"); +await page.keyboard.press("Escape"); + +const bytes = [...readFileSync(wav)]; +const jobId = await page.evaluate( + async ({ bytes, name }) => { + const form = new FormData(); + form.append("file", new Blob([new Uint8Array(bytes)], { type: "audio/wav" }), name); + const response = await fetch("/api/jobs", { method: "POST", body: form }); + if (!response.ok) throw new Error(`import refused: ${response.status} ${await response.text()}`); + return (await response.json()).job_id; + }, + { bytes, name: basename(wav) }, +); +say(`imported ${basename(wav)} as job ${jobId}`); + +const terminal = new Set(["done", "error", "failed", "cancelled", "stopped"]); +let status = ""; +while (!terminal.has(status)) { + remaining(); + await sleep(3000); + const job = await page.evaluate(async (id) => (await fetch(`/api/jobs/${id}`)).json(), jobId); + if (job.status !== status) say(`job ${jobId}: ${job.status} ${job.stage ?? ""}`.trim()); + status = job.status; +} +// No browser.close(): the window stays up for the leak check to close. +process.exit(status === "done" ? 0 : 1); diff --git a/static/js/catalog.js b/static/js/catalog.js index 225621cd..2389a0a0 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1,5 +1,5 @@ // catalog.js — library panel: folders, tracks, collapse, drag-and-drop -import { wireUpAudio } from "./player.js"; +import { triggerDownload, wireUpAudio } from "./player.js"; import { prepareLayout, shownTracks } from "./modeStems.js"; import { applyServerVersions, deleteVersion, openVersion, versionLabel, versionStemsText } from "./versions.js"; import { activeVersionId, songVersions } from "./versionScope.js"; @@ -5007,23 +5007,34 @@ async function loadLogTail(overlay, view) { /** Download every log file as one zip, for attaching to a bug report. */ async function exportLogs(btn) { - if (btn) { btn.disabled = true; btn.textContent = i18nT("settings.exportLogs.preparing"); } + const preparing = () => { if (btn) btn.textContent = i18nT("settings.exportLogs.preparing"); }; + if (btn) btn.disabled = true; + const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); + const filename = `stemdeck-logs-${stamp}.zip`; try { - const r = await fetch("/api/logs.zip", { cache: "no-store" }); - if (!r.ok) throw new Error(`status ${r.status}`); - const blob = await r.blob(); - const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `stemdeck-logs-${stamp}.zip`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + // False when the user cancels the save dialog. + let saved = true; + if (window.__TAURI__?.core?.invoke) { + // Through the save dialog, like every other desktop export: a download + // inside the window is saved by WebView2 to the Downloads folder without + // asking, outside a portable StemDeck's folder. + saved = await triggerDownload("/api/logs.zip", filename, preparing); + } else { + preparing(); + const r = await fetch("/api/logs.zip", { cache: "no-store" }); + if (!r.ok) throw new Error(`status ${r.status}`); + const url = URL.createObjectURL(await r.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } // Log export isn't tied to a track/job, so a plain by-kind dismiss is the // right granularity — it never touches a per-track export failure (#401). - dismissFailuresByKind("export"); + if (saved) dismissFailuresByKind("export"); } catch (e) { console.warn("[settings] log export failed:", e); showError(i18nT("settings.exportLogs.error"), null, { retry: false }); diff --git a/tests/e2e/export-logs.spec.mjs b/tests/e2e/export-logs.spec.mjs new file mode 100644 index 00000000..31534d01 --- /dev/null +++ b/tests/e2e/export-logs.spec.mjs @@ -0,0 +1,80 @@ +// Settings > Logs > Export logs. +// +// In the desktop app the zip goes through the native save dialog, like every +// other export. A download inside the window is saved by WebView2 straight to +// the user's Downloads folder without asking, which puts a file outside a +// portable StemDeck's own folder in a place the user never chose. + +import { test, expect } from "@playwright/test"; +import { seedLibrary, stubTauri } from "./helpers.mjs"; + +async function openLogs(page) { + await seedLibrary(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#settingsBtn").click(); + await page.locator('.settings-tab[data-tab="logs"]').click(); + await expect(page.locator(".settings-export-logs")).toBeVisible(); +} + +test("desktop: the logs zip is saved where the user picks, never by the window", async ({ page }) => { + await stubTauri(page); + await openLogs(page); + const downloads = []; + page.on("download", (download) => downloads.push(download.suggestedFilename())); + + const button = page.locator(".settings-export-logs"); + await button.click(); + await expect.poll(() => page.evaluate(() => window.__e2e.pickPending())).toBe(true); + const [pick] = await page.evaluate(() => window.__e2e.callsFor("pick_export_destination")); + expect(pick.args.filename).toMatch(/^stemdeck-logs-\d{14}\.zip$/); + // Nothing is being exported while the dialog is open. + await expect(button).toHaveText("Export logs"); + + await page.evaluate(() => window.__e2e.choosePath()); + await expect.poll(() => page.evaluate(() => window.__e2e.savePending())).toBe(true); + const [transfer] = await page.evaluate(() => window.__e2e.callsFor("download_to_path")); + expect(transfer.args).toEqual({ token: "token-1", url: new URL("/api/logs.zip", page.url()).href }); + await expect(button).toBeDisabled(); + await expect(button).toHaveText("Preparing…"); + + await page.evaluate(() => window.__e2e.finishSave()); + await expect(button).toBeEnabled(); + await expect(button).toHaveText("Export logs"); + expect(downloads).toEqual([]); +}); + +test("desktop: a cancelled save dialog exports nothing", async ({ page }) => { + await stubTauri(page); + await openLogs(page); + const button = page.locator(".settings-export-logs"); + await button.click(); + await expect.poll(() => page.evaluate(() => window.__e2e.pickPending())).toBe(true); + await page.evaluate(() => window.__e2e.cancelPick()); + await expect(button).toBeEnabled(); + await expect(button).toHaveText("Export logs"); + expect(await page.evaluate(() => window.__e2e.callsFor("download_to_path"))).toEqual([]); + await expect(page.getByText("Could not export the logs.")).toHaveCount(0); +}); + +test("desktop: a failed save says so and gives the button back", async ({ page }) => { + await stubTauri(page); + await openLogs(page); + const button = page.locator(".settings-export-logs"); + await button.click(); + await expect.poll(() => page.evaluate(() => window.__e2e.pickPending())).toBe(true); + await page.evaluate(() => window.__e2e.choosePath()); + await expect.poll(() => page.evaluate(() => window.__e2e.savePending())).toBe(true); + await page.evaluate(() => window.__e2e.failSave("backend returned HTTP 500")); + await expect(button).toBeEnabled(); + await expect(button).toHaveText("Export logs"); + await expect(page.getByText("Could not export the logs.").first()).toBeVisible(); +}); + +test("browser: the logs zip is a browser download", async ({ page }) => { + await openLogs(page); + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.locator(".settings-export-logs").click(), + ]); + expect(download.suggestedFilename()).toMatch(/^stemdeck-logs-\d{14}\.zip$/); +});