From 7a582b343207db4e46f62573da9b1a27ee11140b Mon Sep 17 00:00:00 2001 From: Thales <> Date: Tue, 3 Nov 2026 21:01:06 +0000 Subject: [PATCH 1/4] fix(desktop): save exported logs through the save dialog, not the window Settings > Logs > Export logs let the window download the zip by itself. WebView2 saves such a download straight to the user's Downloads folder without asking, so a portable StemDeck wrote a file outside its own folder in a place the user never chose. The desktop app now saves the zip through the native save dialog, the same path every other export takes. The browser keeps its own download. Adds scripts/windows/check-portable-leaks.ps1 (with drive-portable.mjs), a Windows-only check that runs an extracted portable package through one session and lists everything it wrote outside its folder: profile folders, registry, files beside the package, downloads the window made by itself, and with -RedirectProfile everything that follows the profile environment. --- scripts/windows/check-portable-leaks.ps1 | 360 +++++++++++++++++++++++ scripts/windows/drive-portable.mjs | 149 ++++++++++ static/js/catalog.js | 41 ++- tests/e2e/export-logs.spec.mjs | 63 ++++ 4 files changed, 598 insertions(+), 15 deletions(-) create mode 100644 scripts/windows/check-portable-leaks.ps1 create mode 100644 scripts/windows/drive-portable.mjs create mode 100644 tests/e2e/export-logs.spec.mjs diff --git a/scripts/windows/check-portable-leaks.ps1 b/scripts/windows/check-portable-leaks.ps1 new file mode 100644 index 00000000..c9752747 --- /dev/null +++ b/scripts/windows/check-portable-leaks.ps1 @@ -0,0 +1,360 @@ +<# +.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" +} + +$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, or this package folder in Windows' own records of it. +$ownPattern = '(?i)(\\StemDeck(\\|$)|\\app\.stemdeck\.desktop(\\|$)|\\StemDeck[^\\]*\.[a-z0-9]{2,5}$|' + + [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 { + $driver = Join-Path $PSScriptRoot "drive-portable.mjs" + # 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 + $env:TEMP = $driverTemp + $env:TMP = $driverTemp + $models = if ($WithModels) { "warmup" } else { "skip" } + & node $driver $DevToolsPort $app.Id (Resolve-Path $Wav).Path $Minutes $models 2>&1 | + Tee-Object -FilePath (Join-Path $WorkDir "drive.log") | Write-Host + $driverExit = $LASTEXITCODE + $env:TEMP, $env:TMP = $userTemp + $driven = "drive-portable.mjs exit code $driverExit" + 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 + $job = (& curl.exe -s -F "file=@$((Resolve-Path $Wav).Path)" "$base/api/jobs" | ConvertFrom-Json).job_id + if (-not $job) { throw "the import over HTTP was refused" } + 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" + } + 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..59a35c1c --- /dev/null +++ b/scripts/windows/drive-portable.mjs @@ -0,0 +1,149 @@ +// 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 result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { 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"); +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(); +await sleep(5000); +say(`Export logs pressed, save dialogs closed: ${closeSaveDialogs(appPid)}`); +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..b4758f05 --- /dev/null +++ b/tests/e2e/export-logs.spec.mjs @@ -0,0 +1,63 @@ +// 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 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(); + expect(await page.evaluate(() => window.__e2e.callsFor("download_to_path"))).toEqual([]); +}); + +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$/); +}); From 3e3afa0b46c6cf4f9740c001ccd1bd6335d18914 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Tue, 3 Nov 2026 21:14:00 +0000 Subject: [PATCH 2/4] test(desktop): cover a failed and a cancelled log save; harden the leak check The Export logs tests now also check the label during the transfer, a failed save and a cancelled one. The leak check refuses a package whose library points outside it and a DevTools port that is already taken, always closes the app and writes the report when the session fails, keeps the driver's stderr out of PowerShell's error handling, waits for the save dialog instead of a fixed pause, and runs PowerShell by its full path with an encoded command. --- scripts/windows/check-portable-leaks.ps1 | 41 +++++++++++++++++++----- scripts/windows/drive-portable.mjs | 20 ++++++++++-- tests/e2e/export-logs.spec.mjs | 17 ++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/scripts/windows/check-portable-leaks.ps1 b/scripts/windows/check-portable-leaks.ps1 index c9752747..85e737b7 100644 --- a/scripts/windows/check-portable-leaks.ps1 +++ b/scripts/windows/check-portable-leaks.ps1 @@ -78,6 +78,19 @@ if (-not $Manual -and -not $Wav) { 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 = @( @@ -209,20 +222,26 @@ 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" - # 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 $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 | - Tee-Object -FilePath (Join-Path $WorkDir "drive.log") | Write-Host + ForEach-Object { "$_" } | Tee-Object -FilePath (Join-Path $WorkDir "drive.log") | Write-Host $driverExit = $LASTEXITCODE - $env:TEMP, $env:TMP = $userTemp + $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 @@ -242,7 +261,7 @@ if ($Manual) { try { Invoke-RestMethod "$base/api/health" -TimeoutSec 5 | Out-Null; break } catch { Start-Sleep -Seconds 2 } } Invoke-RestMethod "$base/api/settings" | Out-Null - $job = (& curl.exe -s -F "file=@$((Resolve-Path $Wav).Path)" "$base/api/jobs" | ConvertFrom-Json).job_id + $job = (& curl.exe -s -F "file=@`"$((Resolve-Path $Wav).Path)`"" "$base/api/jobs" | ConvertFrom-Json).job_id if (-not $job) { throw "the import over HTTP was refused" } do { Start-Sleep -Seconds 3 @@ -250,6 +269,11 @@ if ($Manual) { } 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)) { @@ -257,6 +281,7 @@ if ($Manual) { & 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 diff --git a/scripts/windows/drive-portable.mjs b/scripts/windows/drive-portable.mjs index 59a35c1c..c53eca35 100644 --- a/scripts/windows/drive-portable.mjs +++ b/scripts/windows/drive-portable.mjs @@ -49,7 +49,11 @@ public static class SaveDialogs { } '@ [SaveDialogs]::Close(${Number(pid)})`; - const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8" }); + 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()); } @@ -85,6 +89,10 @@ async function connect() { 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. @@ -119,8 +127,14 @@ say("Settings opened"); // 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(); -await sleep(5000); -say(`Export logs pressed, save dialogs closed: ${closeSaveDialogs(appPid)}`); +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)]; diff --git a/tests/e2e/export-logs.spec.mjs b/tests/e2e/export-logs.spec.mjs index b4758f05..31534d01 100644 --- a/tests/e2e/export-logs.spec.mjs +++ b/tests/e2e/export-logs.spec.mjs @@ -35,6 +35,7 @@ test("desktop: the logs zip is saved where the user picks, never by the window", 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(); @@ -50,7 +51,23 @@ test("desktop: a cancelled save dialog exports nothing", async ({ page }) => { 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 }) => { From 8ea25a9adfec8896e6864729caae84e3e3dc1c05 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Tue, 3 Nov 2026 22:16:35 +0000 Subject: [PATCH 3/4] fix(scripts): send the leak check's HTTP import with HttpClient Windows PowerShell 5.1 mangles embedded quotes in a native argument, so the curl form field carried a broken path and the import was refused. --- scripts/windows/check-portable-leaks.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/windows/check-portable-leaks.ps1 b/scripts/windows/check-portable-leaks.ps1 index 85e737b7..22e0c84a 100644 --- a/scripts/windows/check-portable-leaks.ps1 +++ b/scripts/windows/check-portable-leaks.ps1 @@ -261,8 +261,15 @@ if ($Manual) { try { Invoke-RestMethod "$base/api/health" -TimeoutSec 5 | Out-Null; break } catch { Start-Sleep -Seconds 2 } } Invoke-RestMethod "$base/api/settings" | Out-Null - $job = (& curl.exe -s -F "file=@`"$((Resolve-Path $Wav).Path)`"" "$base/api/jobs" | ConvertFrom-Json).job_id - if (-not $job) { throw "the import over HTTP was refused" } + 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 From c815e196be7fb5ce928550d61537bf81482e89ca Mon Sep 17 00:00:00 2001 From: Thales <> Date: Tue, 3 Nov 2026 22:44:23 +0000 Subject: [PATCH 4/4] fix(scripts): count only StemDeck folders and Downloads files as the package's A test server's stemdeck.log in the temp folder was counted as a leak of the package under test. --- scripts/windows/check-portable-leaks.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/windows/check-portable-leaks.ps1 b/scripts/windows/check-portable-leaks.ps1 index 22e0c84a..2052b394 100644 --- a/scripts/windows/check-portable-leaks.ps1 +++ b/scripts/windows/check-portable-leaks.ps1 @@ -149,8 +149,9 @@ function Get-ProfileState { } # A path the package's own processes would write to: StemDeck by name, its -# Tauri identifier, or this package folder in Windows' own records of it. -$ownPattern = '(?i)(\\StemDeck(\\|$)|\\app\.stemdeck\.desktop(\\|$)|\\StemDeck[^\\]*\.[a-z0-9]{2,5}$|' + +# 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.