diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05f9261ebe..cbe4c00cec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: run: node --test "src/jsHelper/**/*.test.mts" - name: Run classmap tooling tests - run: python3 -m unittest scripts.test_classmap_capture + run: node --test "scripts/*.test.mts" "scripts/*.test.mjs" - name: Format run: | diff --git a/.github/workflows/classmap-tooling.yml b/.github/workflows/classmap-tooling.yml new file mode 100644 index 0000000000..5dc32bd44c --- /dev/null +++ b/.github/workflows/classmap-tooling.yml @@ -0,0 +1,51 @@ +name: Classmap tooling + +on: + pull_request: + branches: + - "main" + - "v3-beta" + - "*/main/*/**" + paths: + - "scripts/classmap-*" + - "css-map.json" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/classmap-tooling.yml" + push: + branches: + - "main" + - "v3-beta" + - "*/main/*/**" + paths: + - "scripts/classmap-*" + - "css-map.json" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/classmap-tooling.yml" + +permissions: + contents: read + +jobs: + test: + name: Test classmap tooling + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node and pnpm + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + + - name: Install Node dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm exec oxlint scripts/classmap-capture.ts scripts/classmap-capture.test.mts scripts/classmap-cdp-verify.mjs scripts/classmap-cdp-verify.test.mjs + + - name: Check formatting + run: pnpm exec oxfmt --check scripts/classmap-capture.ts scripts/classmap-capture.test.mts scripts/classmap-cdp-verify.mjs scripts/classmap-cdp-verify.test.mjs + + - name: Test + run: node --test "scripts/classmap-*.test.mts" "scripts/classmap-*.test.mjs" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1d73b6617c..fb18ecd61a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -38,6 +38,10 @@ jobs: target: x86_64-pc-windows-msvc asset_os: windows asset_arch: x86_64 + - os: windows-11-arm + target: aarch64-pc-windows-msvc + asset_os: windows + asset_arch: aarch64 steps: # A dispatch-supplied tag reaches checkout's ref, so it is constrained to # the shape this workflow publishes before anything acts on it. @@ -57,6 +61,12 @@ jobs: with: ref: ${{ inputs.tag || github.ref }} + - name: Configure native Windows ARM64 toolchain + if: runner.os == 'Windows' && runner.arch == 'ARM64' + shell: pwsh + working-directory: . + run: ./scripts/setup-windows-arm64.ps1 + - name: Setup Node and pnpm uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 74eabac913..77417570ef 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -11,7 +11,10 @@ on: - "src/jsHelper/**" - "install.sh" - "install.ps1" - - "scripts/test-install-completions.*" + - "scripts/test-install-*.ps1" + - "scripts/test-install-completions.sh" + - "scripts/setup-windows-arm64.ps1" + - ".github/workflows/rust-release.yml" - "scripts/build-payload.mjs" - "package.json" - "pnpm-lock.yaml" @@ -26,7 +29,10 @@ on: - "src/jsHelper/**" - "install.sh" - "install.ps1" - - "scripts/test-install-completions.*" + - "scripts/test-install-*.ps1" + - "scripts/test-install-completions.sh" + - "scripts/setup-windows-arm64.ps1" + - ".github/workflows/rust-release.yml" - "scripts/build-payload.mjs" - "package.json" - "pnpm-lock.yaml" @@ -42,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: [macos-latest, ubuntu-latest, windows-latest, windows-11-arm] defaults: run: working-directory: rust @@ -50,6 +56,12 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Configure native Windows ARM64 toolchain + if: runner.os == 'Windows' && runner.arch == 'ARM64' + shell: pwsh + working-directory: . + run: ./scripts/setup-windows-arm64.ps1 + - name: Show toolchain run: rustup show active-toolchain @@ -62,7 +74,13 @@ jobs: if: runner.os == 'Windows' working-directory: . shell: pwsh - run: ./scripts/test-install-completions.ps1 + run: | + ./scripts/test-install-completions.ps1 + ./scripts/test-install-architecture.ps1 + & "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File ./scripts/test-install-architecture.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "$env:WINDIR\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File ./scripts/test-install-architecture.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Setup Node and pnpm uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 diff --git a/docs/supported-versions.md b/docs/supported-versions.md index 327d03126a..7ad715968d 100644 --- a/docs/supported-versions.md +++ b/docs/supported-versions.md @@ -83,9 +83,13 @@ modifies the client. match a verified entry in the consumed index. - `supportedSpotify` is the newest verified Spotify version in that index. - `classmapFallback` reports whether selection used an older patch. -- `updatesBlocked` reports the installed updater protection at apply time. +- `updatesBlocked` reports native updater protection at apply time. It is + omitted when protection cannot be determined; `false` means the native + updater is known to be unblocked. Managed package updates are separate. +- `managedSpotify` identifies a Spicetify-owned Linux installation and its + package channel, `stable` or `testing`. -Manager combines these local facts with the availability feed. Its +For native installations, Manager combines these local facts with the availability feed. Its **supported** badge comes from `supportedSpotify`; its **available** badge comes from the observed-version feed. @@ -125,9 +129,29 @@ spicetify spotify-updates unblock spicetify spotify-updates status ``` -Current Windows clients protect the updater staging directory. macOS and Linux -patch the update endpoint in Spotify's binary; macOS also signs the changed app -bundle and applies a secondary update-cache lock. +Current Windows desktop clients protect the updater staging directory. +Microsoft Store updates must be managed through Microsoft Store. macOS patches +the update endpoint in Spotify's binary, signs the changed app bundle, and +applies a secondary update-cache lock. + +On Linux, the binary block only works when its expected endpoint is present. +An unrecognized endpoint leaves protection unknown. The Linux managed installer +offers a separate path: `spicetify spotify install` installs a user-owned copy, +and `spicetify spotify update` explicitly downloads and applies a verified +package. System package managers do not own that copy. This does not establish +native updater protection or freeze other Spotify installations. + +For managed installations, Manager checks Spotify's Linux package feed and +offers **Update Spotify & Apply** when a newer package has an exact verified +classmap. The daemon owns the job, so closing or restarting the renderer does +not cancel it. It prepares and patches a separate copy before switching the +configuration, desktop entry, and terminal launcher together. Update progress +and the final result remain available after Spotify restarts. + +If the daemon itself stops during an update, the next start reports the +interrupted job. Run `spicetify spotify install` to prepare a fresh copy using +the installation's existing channel, then retry. Updates requested from the +terminal use the same installer. `block` and `unblock` store the user's intent in `config.toml`. A successful Spotify update can replace the installed protection, so `apply` reasserts a diff --git a/docs/v3-modules.md b/docs/v3-modules.md index be01d0f7f9..713c51aa54 100644 --- a/docs/v3-modules.md +++ b/docs/v3-modules.md @@ -147,6 +147,23 @@ deliberate act rather than a source the CLI consults on its own: spicetify pkg install my-module https://example.com/my-module@1.0.0.zip ``` +### Updating installed modules + +`spicetify pkg update` moves every installed module to the version the +registry currently serves, and `spicetify pkg update ` does the same for +one module. It fetches the registry fresh and changes only modules the store +manages. Run `spicetify apply` afterwards; Spotify loads the new versions +then. A bulk update leaves these modules as they are and says why: + +- A module you disabled, or pinned by enabling an older version while a newer + one is installed. Naming the module overrides a pin, but not a disable. +- A developer's own directory or linked build. +- A module the registry doesn't carry, or a version installed from an + explicit artifact. + +The command fails when any module could not be updated, or when you name a +module it has to leave alone, so a script notices. + `localStorage["spicetify:defaultVaultUrl"]` repoints the store at another vault. That is a development lever for previewing a catalog before submitting it, not a distribution channel: it replaces the registry rather than adding diff --git a/docs/windows-arm64-verification.md b/docs/windows-arm64-verification.md new file mode 100644 index 0000000000..1335b64995 --- /dev/null +++ b/docs/windows-arm64-verification.md @@ -0,0 +1,77 @@ +# Windows ARM64 installation verification, September 29, 2026 + +The installer selects a matching CLI and daemon for native ARM64 Spotify and +preserves x64 Spotify installations on ARM64 Windows. The native Windows +installation and daemon recovery checks passed with a local release package. +Published ARM64 release downloads remain unverified. + +## Environment and artifacts + +The native runtime checks used this configuration: + +- Windows 11 Pro ARM64, build 26100, in VMware Fusion. +- Desktop Spotify 1.3.1.234, executable machine `0xAA64`. +- Source `2409117615efa9c82341c5aadac6753a60122ee4`, based on beta.19 + (`b50800b2d225ca4cc09d83b9e785723c88b9d8af`). +- Rust 1.98.1, native ARM64 MSVC toolchain, Visual Studio Build Tools + 17.14.41, MSVC 14.44.35207, and clang-cl 19.1.5. +- Optimized CLI and daemon built together after rebuilding the embedded payload. +- Installed CLI: `C:\Users\test\AppData\Local\spicetify\spicetify.exe`. +- Both installed binaries have PE machine `0xAA64` and report beta.19. + The running daemon version was read from `/health`. + +The installed CLI SHA256 is +`A66F66D9CE6515D4835BAC5411D75A83ACED2273CAC739120E1277F0F6D9A0E4`. +The daemon SHA256 is +`452D5AEAC67E5DFF80D4DAD28BB54C7E52B3CEE92D5B1F3A2B8E53E21BF23FD5`. +These are local builds, not published beta.19 assets. + +## Installation and lifecycle results + +The full installer ran in the regular interactive desktop session. Only the +release archive and checksum responses were replaced with local fixtures. +Checksum verification, extraction, architecture selection, daemon shutdown, +binary replacement, shell completion, and Apply ran through the installer. + +| Check | Result | +| --- | --- | +| Native Windows PowerShell 5.1 and 32-bit PowerShell | ARM64 host detected in both; installer regression tests passed | +| Native Spotify | Selected ARM64; installed pair hashes match the optimized build | +| Configured x64 executable fixture | Actual staged ARM64 CLI selected x64; installer fetched the same version's x64 archive and installed both matching binaries | +| Store alias fixture | Unreadable-as-PE alias fell back to the configured data directory's real x64 executable | +| Missing second binary during replacement | Original CLI and daemon restored | +| Locked installed CLI | Replacement rejected; original pair preserved | +| Invalid download checksum | Installation rejected before replacing binaries | +| Enabled daemon stopped before same-version self-update | Real `self-update` restarted it and preserved HKCU Run registration | +| Intentionally disabled daemon | Real `self-update` left it stopped and unregistered | +| Final Apply | Restored normal autostart and URL registration; Spotify restarted normally | + +Installer replacement changed the interactive daemon from PID 1904 to 7460. +After the stop/recovery tests and final Apply, PID 4712 ran in session 2 with +both watchers active. The HKCU Run entry points to the installed daemon; +`spicetify://` points to the installed CLI. + +Native caption buttons were hidden in the running client. The subsequent +visual pass found that the module still reserved space at the right edge on +Spotify 1.3.1. That CSS selector issue belongs to the modules repository and is +separate from native helper architecture compatibility. + +## Build checks and remaining coverage + +The macOS workspace suite passed 180 tests, with three ignored. The repository's +CI lint command, `cargo clippy --workspace --locked -- -D warnings`, passed. +A broader `--all-targets` lint run reports test-only lint failures and does not +pass. Nine native Windows update tests and the optimized CLI/daemon build +passed. The ARM64 setup script successfully exported MSVC build variables and +selected the native Rust host in the VM. + +The live VM retained a local CSS-map override for the independent fallback +styling fix. Spotify 1.3.1 used the published 1.3.0 classmap fallback. These +substitutions do not establish full classmap compatibility. + +The x64 compatibility checks use real x64 PE binaries as fixtures; an actual +x64 Spotify installation was not run. The same-version release-selection test +covers architecture migration, but a complete old published x64 updater to new +published ARM64 release transaction still requires the release assets to exist. +Hosted ARM64 CI and release packaging must pass before release readiness can be +claimed. This work does not enable experimental Windows Spotify updates. diff --git a/expose.json b/expose.json index e10398fba3..d87a2e77c6 100644 --- a/expose.json +++ b/expose.json @@ -1,94 +1,130 @@ { - "$comment": "Apply-time source patches that expose Spotify internals on the Spicetify global, applied to the xpui-modules.js extracted from the v8 snapshot. Fetched by the CLI through index.json; the CLI embeds a copy as its offline baseline. `replace` is a capture-group template (${0} whole match, ${N} group N; `$$` for a literal dollar). `once` stops after the first match. `onMiss` is `warn` (a miss means a Spotify update moved the code) or `quiet` (a miss is expected on some supported builds). `note` records the measured hit count per build.", "version": 1, "patches": [ { "name": "Expose PlatformAPI", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "((?:setTitlebarHeight|registerFactory)[\\w(){}<>:.,&$!=;\"\"?!#%/\\- ]+)(\\{version:[a-zA-Z_\\$][\\w\\$]*,)", "replace": "${1}Spicetify._platform=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Redux store", - "note": "1.2.84: 0, 1.2.96: 1. The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet.", "pattern": "return(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\{(?s:.{0,1500}?)\\)\\}\\(\\{session:[\\w$]+,features:[\\w$]+,seoExperiment:[\\w$]+\\},\\{platform:)", "replace": "return Spicetify.Platform.ReduxStore=${1}", "once": true, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 1 + }, + "note": "The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet." }, { "name": "React Component: Platform Provider", - "note": "1.2.84: 0, 1.2.96: 2.", "pattern": "(,[$\\w]+=)((function\\([\\w$]{1}\\)\\{var [\\w$]+=[\\w$]+\\.platform,[\\w$]+=[\\w$]+\\.children,)|(\\(\\{platform:[\\w$]+,children:[\\w$]+\\}\\)=>\\{))", "replace": "${1}Spicetify.ReactComponent.PlatformProvider=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 2 + } }, { "name": "Spotify Custom Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\b\\w\\s*\\(\\)\\s*[^;,]*enqueueCustomSnackbar:\\s*(\\w)\\s*[^;]*;", "replace": "${0}Spicetify.Snackbar.enqueueCustomSnackbar=${1};", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Spotify Image Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1. The enqueue arrow is returned through useCallback rather than assigned.", "pattern": "return(\\(0,[\\w$]+\\.useCallback\\)\\(\\(\\{message:[\\w$]+,\\s*imageSrc)", "replace": "return Spicetify.Snackbar.enqueueImageSnackbar=${1}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + }, + "note": "The enqueue arrow is returned through useCallback rather than assigned." }, { "name": "React Component: Navigation for navLinks", - "note": "1.2.84: 1, 1.2.96: 0. The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered.", "pattern": "(;const [\\w\\d]+=)((?:\\(0,[\\w\\d]+\\.memo\\))[\\(\\d,\\w\\.\\){:}=]+=[\\d\\w]+\\.[\\d\\w]+\\.getLocaleForURLPath\\(\\))", "replace": "${1}Spicetify.ReactComponent.Navigation=${2}", "once": true, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered." }, { "name": "Context Menu V2", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\"Menu\".+?children:)([\\w$][\\w$\\d]*)", "replace": "${1}[Spicetify.ContextMenuV2.renderItems(),${2}].flat()", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Map styled-components classes", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\\w+ [\\w$_]+)=[\\w$_]+\\([\\w$_]+>>>0\\)", "replace": "${1}=Spicetify._getStyledClassName(arguments,this)", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Tippy.js", - "note": "1.2.84: 2, 1.2.96: 2.", "pattern": "([\\w\\$_]+)\\.setDefaultProps=", "replace": "Spicetify.Tippy=${1};${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 2, + "1.2.96": 2 + } }, { "name": "Flipper components", - "note": "1.2.84: 1, 1.2.96: 0. react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there.", "pattern": "([\\w$]+)=((?:function|\\()([\\w$.,{}()= ]+(?:springConfig|overshootClamping)){2})", "replace": "${1}=Spicetify.ReactFlipToolkit.spring=${2}", "once": false, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there." }, { "name": "Snackbar", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\w+\\s*=\\s*\\w\\.call\\(this,[^)]+\\)\\s*\\|\\|\\s*this\\)\\.enqueueSnackbar", "replace": "Spicetify.Snackbar=${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } } ] } diff --git a/install.ps1 b/install.ps1 index ee6368688b..d200ad36e2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -60,20 +60,47 @@ function Move-OldSpicetifyFolder { } } +function Get-SpicetifyNativeMachine { + if (-not ('Spicetify.NativeArchitecture' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +namespace Spicetify { + public static class NativeArchitecture { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWow64Process2(IntPtr process, out ushort processMachine, out ushort nativeMachine); + } +} +'@ + } + [System.UInt16]$processMachine = 0 + [System.UInt16]$nativeMachine = 0 + if (-not [Spicetify.NativeArchitecture]::IsWow64Process2( + [System.Diagnostics.Process]::GetCurrentProcess().Handle, + [ref]$processMachine, [ref]$nativeMachine)) { + throw 'Cannot determine the native Windows architecture.' + } + $nativeMachine +} + +function Get-SpicetifyArchitecture { + switch (Get-SpicetifyNativeMachine) { + 0xAA64 { return 'aarch64' } + 0x8664 { return 'x86_64' } + default { throw 'Spicetify v3 requires Windows x64 or ARM64.' } + } +} + function Get-Spicetify { [CmdletBinding()] - param () + param ( + [ValidateSet('', 'aarch64', 'x86_64')][string]$RequestedArchitecture = '', + [string]$RequestedVersion = '' + ) begin { if ($v3) { - # v3 asset names come from the Rust CLI's own target triple, so that - # `spicetify self-update` resolves the same file this script installs. - # Only x86_64 is built today. - if ($env:PROCESSOR_ARCHITECTURE -ne 'AMD64') { - Write-Warning -Message "v3 has no build for $env:PROCESSOR_ARCHITECTURE yet. Windows x86_64 is available." - Pause - exit - } - $architecture = 'x86_64' + $architecture = if ($RequestedArchitecture) { $RequestedArchitecture } else { Get-SpicetifyArchitecture } } elseif ($env:PROCESSOR_ARCHITECTURE -eq 'AMD64') { $architecture = 'x64' @@ -84,7 +111,10 @@ function Get-Spicetify { else { $architecture = 'x32' } - if ($v) { + if ($RequestedVersion) { + $targetVersion = $RequestedVersion + } + elseif ($v) { if ($v -match '^\d+\.\d+\.\d+') { $targetVersion = $v } @@ -125,6 +155,13 @@ function Get-Spicetify { OutFile = $archivePath } Invoke-WebRequest @Parameters + if ($v3) { + $checksum = (Invoke-RestMethod -Uri "$($Parameters.Uri).sha256").Trim().Split()[0] + if ($checksum -notmatch '^[0-9a-fA-F]{64}$' -or + (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $checksum) { + throw 'The downloaded Spicetify archive failed checksum verification.' + } + } Write-Success } end { @@ -187,6 +224,56 @@ function Add-SpicetifyCompletion { } } +function Expand-SpicetifyPackage { + param([string]$Archive, [string]$Destination) + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + Remove-Item -LiteralPath (Join-Path $Destination $name) -Force -ErrorAction SilentlyContinue + } + Expand-Archive -Path $Archive -DestinationPath $Destination -Force + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $Destination $name) -PathType Leaf)) { + throw "The release archive is missing $name." + } + } +} + +function Install-SpicetifyBinaries { + param([string]$Source, [string]$Destination) + $names = @('spicetify.exe', 'spicetify-daemon.exe') + $backup = Join-Path $Destination ".install-backup-$([guid]::NewGuid())" + $saved = @() + $installed = @() + New-Item -ItemType Directory -Path $backup -Force | Out-Null + try { + foreach ($name in $names) { + $target = Join-Path $Destination $name + if (Test-Path -LiteralPath $target) { + Move-Item -LiteralPath $target -Destination (Join-Path $backup $name) + $saved += $name + } + # Record the target before copying, so rollback also removes a partial copy. + $installed += $name + Copy-Item -LiteralPath (Join-Path $Source $name) -Destination $target + } + } + catch { + foreach ($name in $installed) { + Remove-Item -LiteralPath (Join-Path $Destination $name) -Force -ErrorAction SilentlyContinue + } + foreach ($name in $saved) { + Move-Item -LiteralPath (Join-Path $backup $name) -Destination (Join-Path $Destination $name) -Force + } + throw + } + finally { + # Preserve originals if any rollback operation could not restore them. + if (-not (Get-ChildItem -LiteralPath $backup -ErrorAction SilentlyContinue)) { + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + } + } + Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue +} + function Install-Spicetify { [CmdletBinding()] param () @@ -196,7 +283,57 @@ function Install-Spicetify { process { $archivePath = Get-Spicetify Write-Host -Object 'Extracting spicetify...' -NoNewline - Expand-Archive -Path $archivePath -DestinationPath $spicetifyFolderPath -Force + if ($v3) { + $staging = Join-Path ([System.IO.Path]::GetTempPath()) "spicetify-install-$([guid]::NewGuid())" + try { + Expand-SpicetifyPackage -Archive $archivePath -Destination $staging + if ((Get-SpicetifyArchitecture) -eq 'aarch64') { + # Let the staged CLI resolve configured, desktop, and Store Spotify paths. + $stagedCli = Join-Path $staging 'spicetify.exe' + $wanted = (& $stagedCli --print-install-architecture | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $wanted -notin @('aarch64', 'x86_64')) { + throw 'Cannot determine the Spicetify build required by Spotify.' + } + if ($wanted -eq 'x86_64') { + $versionOutput = (& $stagedCli --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $versionOutput -notmatch '^spicetify (\S+)$') { + throw 'Cannot determine the staged Spicetify version.' + } + $archivePath = Get-Spicetify -RequestedArchitecture $wanted -RequestedVersion $Matches[1] + Expand-SpicetifyPackage -Archive $archivePath -Destination $staging + } + } + $daemonPath = Join-Path $spicetifyFolderPath 'spicetify-daemon.exe' + # 32-bit PowerShell cannot read Get-Process.Path for a 64-bit daemon. + $running = @(Get-CimInstance Win32_Process -Filter "Name='spicetify-daemon.exe'" | + Where-Object { $_.ExecutablePath -eq $daemonPath } | + ForEach-Object { Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue }) + if ($running.Count) { + $token = (Get-Content -LiteralPath (Join-Path $spicetifyFolderPath 'daemon-token') -Raw).Trim() + Invoke-RestMethod -Uri 'http://127.0.0.1:7967/shutdown' -Method Post -TimeoutSec 5 -Headers @{ + 'x-spicetify-token' = $token + } | Out-Null + $running | Wait-Process -Timeout 10 + } + New-Item -ItemType Directory -Path $spicetifyFolderPath -Force | Out-Null + try { + Install-SpicetifyBinaries -Source $staging -Destination $spicetifyFolderPath + } + catch { + if ($running.Count) { Start-Process -FilePath $daemonPath } + throw + } + if ($running.Count) { + Start-Process -FilePath $daemonPath + } + } + finally { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + } + } + else { + Expand-Archive -Path $archivePath -DestinationPath $spicetifyFolderPath -Force + } Write-Success Add-SpicetifyToPath } diff --git a/install.sh b/install.sh index 1977903f1d..8ede01662e 100755 --- a/install.sh +++ b/install.sh @@ -261,17 +261,20 @@ log "Run 'spicetify --help' to get started" if [ "$channel" = "v3" ]; then log "This is a v3 preview. Modules are managed in-app through the Module Store, so there is no Marketplace step." # Apply now as a convenience. This patches Spotify and restarts it, and it - # seeds the store so it is waiting in the sidebar. A soft step on purpose: + # seeds the store so it is waiting in the top bar. A soft step on purpose: # Spotify may not be installed or logged in yet, so a failure here leaves # the CLI installed and tells the user to apply once that is sorted. Not # 'init', which is a destructive reset (it deletes installed modules) and # apply does not need it. log "Patching Spotify (this restarts it)..." if "$exe" apply; then - log "Done. Open Spotify and click Module Store in the sidebar." + log "Done. Return to Spotify and click Module Store in the top bar." else log "Install finished, but 'spicetify apply' did not complete. Fix the reported cause, then run: spicetify apply" log "If it cannot find Spotify, 'spicetify config' shows the paths it resolved." + if [ "$target" = "linux-x86_64" ]; then + log "For a system-owned Spotify installation, run 'spicetify spotify install' as your normal user. It installs and patches a user-owned copy when a verified classmap is available." + fi fi exit 0 fi diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ca105ea2ba..66e5a48b10 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,7 +433,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "3.0.0-beta.17" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "clap", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "daemon" -version = "3.0.0-beta.17" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "axum", @@ -3117,7 +3117,7 @@ dependencies = [ [[package]] name = "spicetify" -version = "3.0.0-beta.17" +version = "3.0.0-beta.20" dependencies = [ "aho-corasick", "anyhow", @@ -3717,7 +3717,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "3.0.0-beta.17" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "crossterm", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e5c9647f1a..f6811bb4f7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/spicetify", "crates/cli", "crates/tui", "crates/daemon"] resolver = "2" [workspace.package] -version = "3.0.0-beta.17" +version = "3.0.0-beta.20" edition = "2024" rust-version = "1.95" repository = "https://github.com/veryboringhwl/app" diff --git a/rust/README.md b/rust/README.md index 0599f54a2a..ac30bb33f3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -66,8 +66,8 @@ or change system packages. Installation requires a verified classmap for the exact Spotify version line. Spicetify patches the candidate before switching its configuration and the -Spotify desktop launcher to it, then restarts Spotify and the daemon. A failed -activation restores the previous configuration and launcher. Previous client +Spotify desktop and terminal launchers to it, then restarts Spotify. A failed +activation restores the previous configuration and launchers. Previous client files remain available; the candidate also retains `config-before.toml` and `desktop-before.desktop` when those files existed. @@ -75,19 +75,28 @@ Client files live under `$XDG_DATA_HOME/spicetify/spotify/versions`, normally `~/.local/share/spicetify/spotify/versions`. Verified downloads are cached under `$XDG_CACHE_HOME/spicetify/spotify`. These commands neither replace `/usr/bin/spotify` nor manage installations owned by apt, pacman, Snap, or Flatpak. The desktop -launcher and Spicetify configuration select the managed client. +launcher and Spicetify configuration select the managed client. A symlink at +`~/.local/bin/spotify` selects the same executable from a terminal. Keep +`~/.local/bin` before system directories in `PATH`; the installer warns when +another executable takes precedence. Existing regular files at that path are +preserved, and installation stops with instructions to move them aside. Stable is the default channel. Use `spotify install --channel testing` to opt into Spotify's testing feed, or `spotify update --channel testing` to switch -an existing managed install. Subsequent updates retain that channel. Downgrades +an existing managed install. Subsequent updates and reinstalls retain that channel. Downgrades are refused, including when switching back to an older stable release. An explicit `spotify install` prepares a fresh patched copy even when the package version is unchanged, so it can restore Spicetify after `spicetify restore`. Package updates run only when requested. This does not prove that Spotify's native self-updater is blocked. `spotify status` reports native block detection -separately; an unrecognized endpoint remains **unknown**. The daemon's in-client -**Update & Apply** transaction still uses Spotify's native updater. +separately; an unrecognized endpoint remains **unknown**. + +Manager offers **Update Spotify & Apply** for managed installations. It checks +the selected Linux package feed rather than the global Spotify availability +feed. The daemon runs the same installer and keeps progress through renderer +restarts. A second request joins the running job. If the daemon itself stops, +the next start reports the interruption and offers the reinstall command. ## Restart the daemon after local changes diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 66f4381eba..671c89fd4a 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -25,6 +25,8 @@ struct SpicetifyCli { #[arg(long, global = true)] offline_bnk_dir: Option, + #[arg(long, hide = true)] + print_install_architecture: bool, #[command(subcommand)] command: Option, } @@ -91,8 +93,12 @@ enum CliCommand { enum CliSpotifyAction { #[command(about = "Download Spotify, apply Spicetify, and add a desktop launcher")] Install { - #[arg(long, value_enum, default_value = "stable")] - channel: SpotifyChannel, + #[arg( + long, + value_enum, + help = "Keep the installed channel; new installations default to stable" + )] + channel: Option, }, #[command(about = "Update the Spotify installation managed by Spicetify")] Update { @@ -164,6 +170,11 @@ enum CliPkgAction { Delete { id: String }, #[command(about = "Enable a package")] Enable { id: String }, + #[command(about = "Update installed modules to the registry's versions (run apply afterwards)")] + Update { + #[arg(help = "Module identifier to update (updates all if omitted)")] + id: Option, + }, } impl From for Command { @@ -196,7 +207,9 @@ impl From for Command { CliCommand::Spotify { action } => { use spicetify::commands::spotify::Action; Command::Spotify(match action { - CliSpotifyAction::Install { channel } => Action::Install(channel.into()), + CliSpotifyAction::Install { channel } => { + Action::Install(channel.map(Into::into)) + } CliSpotifyAction::Update { channel } => Action::Update(channel.map(Into::into)), CliSpotifyAction::Status => Action::Status, }) @@ -224,6 +237,7 @@ impl From for PkgAction { CliPkgAction::Install { id, url } => PkgAction::Install { id, url }, CliPkgAction::Delete { id } => PkgAction::Delete { id }, CliPkgAction::Enable { id } => PkgAction::Enable { id }, + CliPkgAction::Update { id } => PkgAction::Update { id }, } } } @@ -244,6 +258,17 @@ fn main() { fn run() -> Result<()> { let cli = SpicetifyCli::parse(); + if cli.print_install_architecture { + let ctx = spicetify::context::build_context( + cli.mirror, + cli.spotify_data_dir.as_deref(), + cli.spotify_exec.as_deref(), + cli.offline_bnk_dir.as_deref(), + )?; + println!("{}", spicetify::update::release::platform_arch(&ctx)); + return Ok(()); + } + spicetify::update::startup_cleanup(); match cli.command { diff --git a/rust/crates/daemon/src/main.rs b/rust/crates/daemon/src/main.rs index b04dbb46f1..16b3f47acd 100644 --- a/rust/crates/daemon/src/main.rs +++ b/rust/crates/daemon/src/main.rs @@ -4,6 +4,8 @@ use i18n_embed_fl as _; pub mod error; pub mod health; +#[cfg(target_os = "linux")] +pub mod managed_spotify; pub mod proxy; pub mod routes; pub mod server; diff --git a/rust/crates/daemon/src/managed_spotify.rs b/rust/crates/daemon/src/managed_spotify.rs new file mode 100644 index 0000000000..0420d458d7 --- /dev/null +++ b/rust/crates/daemon/src/managed_spotify.rs @@ -0,0 +1,178 @@ +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::update_job::{Admission, AdmissionDisposition}; +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use spicetify::commands::{guard, spotify}; +use spicetify::context::{Config, SharedContext}; + +const STATE_FILE: &str = "managed-spotify-job.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", rename_all_fields = "camelCase")] +pub enum Job { + Idle, + Running { job_id: String, phase: spotify::Phase }, + Complete { job_id: String }, + Failed { job_id: String, message: String }, +} + +impl Job { + pub fn running(&self) -> bool { + matches!(self, Self::Running { .. }) + } +} + +#[derive(Debug, Clone)] +pub struct Handle { + job: Arc>, + shared: Arc, +} + +#[derive(Debug, Serialize)] +pub struct Snapshot { + installation: spotify::InstallationStatus, + job: Job, +} + +impl Handle { + pub fn new(shared: Arc) -> anyhow::Result { + let root = shared.load().config_root.clone(); + let job = match std::fs::read(root.join(STATE_FILE)) { + Ok(bytes) => recover_job(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Job::Idle, + Err(error) => return Err(error.into()), + }; + persist(&root, &job)?; + Ok(Self { job: Arc::new(Mutex::new(job)), shared }) + } + + pub fn snapshot(&self) -> anyhow::Result { + Ok(Snapshot { + installation: spotify::installation_status(&self.shared.load_full()).unwrap_or_else( + |error| spotify::InstallationStatus::Unavailable { + message: format!("Cannot inspect the current Spotify installation: {error:#}"), + }, + ), + job: self + .job + .lock() + .map_err(|_| anyhow::anyhow!("managed update lock poisoned"))? + .clone(), + }) + } + + pub fn running(&self) -> bool { + self.job.lock().is_ok_and(|job| job.running()) + } + + pub fn admit(&self) -> anyhow::Result { + let mut job = + self.job.lock().map_err(|_| anyhow::anyhow!("managed update lock poisoned"))?; + if let Job::Running { job_id, .. } = &*job { + return Ok(Admission { + job_id: job_id.clone(), + disposition: AdmissionDisposition::Joined, + }); + } + let ctx = self.shared.load_full(); + let guard = guard::try_acquire(&ctx.config_root)?; + anyhow::ensure!( + matches!( + spotify::installation_status(&ctx)?, + spotify::InstallationStatus::Managed { .. } + ), + "Spotify is not managed by Spicetify; run `spicetify spotify install` first" + ); + let job_id = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos() + ); + let accepted = Job::Running { job_id: job_id.clone(), phase: spotify::Phase::Checking }; + persist(&ctx.config_root, &accepted)?; + *job = accepted; + let handle = self.clone(); + let id = job_id.clone(); + let spawned = std::thread::Builder::new().name("managed-spotify-update".into()).spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + spotify::update_managed(&ctx, &guard, |phase| { + handle.publish(Job::Running { job_id: id.clone(), phase }) + }) + })).unwrap_or_else(|_| Err(anyhow::anyhow!("managed update worker panicked; run `spicetify spotify install` to repair the installation"))); + // Activation changes the configured installation. Refresh even after + // an error: recovery may have restored the previous configuration. + let refreshed = Config::load(&ctx.config_file) + .and_then(|config| spicetify::context::AppContext::from_config(ctx.config_root.clone(), &config)); + let result = result.and_then(|()| refreshed.as_ref().map(|_| ()).map_err(|error| anyhow::anyhow!("cannot reload Spotify configuration: {error:#}"))); + if let Ok(next) = refreshed { handle.shared.store(next); } + let terminal = match result { + Ok(()) => Job::Complete { job_id: id }, + Err(error) => Job::Failed { job_id: id, message: format!("{error:#}") }, + }; + if let Err(error) = handle.publish(terminal) { + tracing::error!(%error, "could not save managed Spotify update result"); + } + drop(guard); + }); + if let Err(error) = spawned { + *job = Job::Failed { job_id, message: error.to_string() }; + persist(&self.shared.load().config_root, &job)?; + return Err(error.into()); + } + Ok(Admission { job_id, disposition: AdmissionDisposition::Accepted }) + } + + fn publish(&self, next: Job) -> anyhow::Result<()> { + let mut job = + self.job.lock().map_err(|_| anyhow::anyhow!("managed update lock poisoned"))?; + let saved = persist(&self.shared.load().config_root, &next); + *job = next; + saved + } +} + +fn recover_job(job: Job) -> Job { + match job { + Job::Running { job_id, .. } => Job::Failed { + job_id, + message: "The daemon stopped during this update. Run `spicetify spotify install` to repair the installation, then retry.".into(), + }, + other => other, + } +} + +fn persist(root: &Path, job: &Job) -> anyhow::Result<()> { + use std::io::Write; + let temporary = root.join("managed-spotify-job.json.tmp"); + let mut file = std::fs::File::create(&temporary)?; + file.write_all(&serde_json::to_vec(job)?)?; + file.sync_all()?; + std::fs::rename(&temporary, root.join(STATE_FILE)) + .context("cannot save managed update progress")?; + std::fs::File::open(root)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restart_never_reports_an_interrupted_update_as_completed() { + for phase in [ + spotify::Phase::Checking, + spotify::Phase::Downloading, + spotify::Phase::Preparing, + spotify::Phase::Activating, + ] { + let restored = recover_job(Job::Running { job_id: "one".into(), phase }); + assert!(matches!(restored, Job::Failed { job_id, .. } if job_id == "one")); + } + assert!(matches!( + recover_job(Job::Complete { job_id: "one".into() }), + Job::Complete { .. } + )); + } +} diff --git a/rust/crates/daemon/src/routes.rs b/rust/crates/daemon/src/routes.rs index 7b92615fbd..8df20c33a5 100644 --- a/rust/crates/daemon/src/routes.rs +++ b/rust/crates/daemon/src/routes.rs @@ -108,6 +108,9 @@ pub fn build(state: Arc) -> Router { .route("/shutdown", post(shutdown_handler)) .route("/jobs/update-and-apply", get(update_job_status).post(update_job_admit)) .route("/jobs/update-and-apply/event", post(update_job_event)) + .route("/spotify", get(managed_spotify_status)) + .route("/spotify/check", post(managed_spotify_check)) + .route("/spotify/update", post(managed_spotify_update)) .route("/proxy", get(proxy::status)) .route("/proxy/", get(proxy::status)) .route("/proxy/{*url}", any(proxy::handler)) @@ -115,6 +118,72 @@ pub fn build(state: Arc) -> Router { .with_state(state) } +async fn managed_spotify_status( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || state.managed_spotify.snapshot()).await { + Ok(Ok(snapshot)) => Json(snapshot).into_response(), + Ok(Err(error)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("cannot read managed Spotify status: {error:#}"), + ) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + Json(serde_json::json!({"installation":{"kind":"external"},"job":{"kind":"idle"}})) + .into_response() +} + +async fn managed_spotify_check( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || { + let ctx = state.ctx.load_full(); + let _guard = spicetify::commands::guard::try_acquire(&ctx.config_root)?; + spicetify::commands::spotify::check_update(&ctx) + }) + .await + { + Ok(Ok(available)) => Json(available).into_response(), + Ok(Err(error)) => { + (StatusCode::CONFLICT, format!("cannot check Spotify packages: {error:#}")) + .into_response() + } + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + (StatusCode::NOT_IMPLEMENTED, "managed Spotify packages require Linux").into_response() +} + +async fn managed_spotify_update( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || state.managed_spotify.admit()).await { + Ok(Ok(admission)) => (StatusCode::ACCEPTED, Json(admission)).into_response(), + Ok(Err(error)) => (StatusCode::CONFLICT, format!("cannot start Spotify update: {error:#}")) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + (StatusCode::NOT_IMPLEMENTED, "managed Spotify packages require Linux").into_response() +} + async fn update_job_status( State(state): State>, headers: HeaderMap, @@ -246,6 +315,11 @@ async fn shutdown_handler( tracing::warn!("shutdown request rejected: missing or invalid daemon token"); return (StatusCode::FORBIDDEN, "invalid daemon token".to_string()).into_response(); } + #[cfg(target_os = "linux")] + if state.managed_spotify.running() { + return (StatusCode::CONFLICT, "a managed Spotify update is in progress".to_string()) + .into_response(); + } tracing::info!("{}", spicetify::fl!("shutdown-requested")); state.shutdown.notify_waiters(); (StatusCode::ACCEPTED, spicetify::fl!("daemon-stopping-resp")).into_response() diff --git a/rust/crates/daemon/src/server.rs b/rust/crates/daemon/src/server.rs index e6bcfc3394..19dca6b4d4 100644 --- a/rust/crates/daemon/src/server.rs +++ b/rust/crates/daemon/src/server.rs @@ -26,6 +26,8 @@ pub struct DaemonState { pub apps_watcher_active: Arc, pub config_watcher_active: Arc, pub update_job: update_job::UpdateJobHandle, + #[cfg(target_os = "linux")] + pub managed_spotify: crate::managed_spotify::Handle, } pub fn run() -> anyhow::Result<()> { @@ -48,6 +50,8 @@ fn start(ctx: AppContext) -> anyhow::Result<()> { let config_watcher_active = Arc::new(AtomicBool::new(false)); let update_job = update_job::spawn(Arc::clone(&shared)); let state = Arc::new(DaemonState { + #[cfg(target_os = "linux")] + managed_spotify: crate::managed_spotify::Handle::new(Arc::clone(&shared))?, ctx: Arc::clone(&shared), client: spicetify::http::proxy_client()?, shutdown: Arc::clone(&shutdown), diff --git a/rust/crates/daemon/src/update_job_tests.rs b/rust/crates/daemon/src/update_job_tests.rs index 8d9ee37642..b46b0fa627 100644 --- a/rust/crates/daemon/src/update_job_tests.rs +++ b/rust/crates/daemon/src/update_job_tests.rs @@ -50,14 +50,6 @@ fn spotify_lines_ignore_the_build_component() { assert!(version_line("UNKNOWN").is_none()); } -#[test] -fn update_and_apply_support_matches_the_compiled_platform() { - assert_eq!( - supported_on_this_platform(), - cfg!(target_os = "macos") || cfg!(all(windows, feature = "experimental-windows-updates")) - ); -} - #[cfg(not(any(target_os = "macos", all(windows, feature = "experimental-windows-updates"))))] #[test] fn unsupported_platform_refuses_admission_before_mutating_state() { diff --git a/rust/crates/daemon/src/watcher.rs b/rust/crates/daemon/src/watcher.rs index e28501229b..e4efe9c412 100644 --- a/rust/crates/daemon/src/watcher.rs +++ b/rust/crates/daemon/src/watcher.rs @@ -34,6 +34,49 @@ pub fn spawn_apps_watcher( shutdown: Arc, active: Arc, update_job: UpdateJobHandle, +) -> Option> { + Some(tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + loop { + let path = shared.load().spotify_apps_path(); + let stop = Arc::new(Notify::new()); + let cancelled = Arc::new(AtomicBool::new(false)); + let task = spawn_apps_watcher_at( + Arc::clone(&shared), + Arc::clone(&stop), + Arc::clone(&active), + update_job.clone(), + Arc::clone(&cancelled), + ); + let quitting = loop { + tokio::select! { + () = shutdown.notified() => break true, + _ = tick.tick() => { + if shared.load().spotify_apps_path() != path || task.as_ref().is_none_or(tokio::task::JoinHandle::is_finished) { + break false; + } + } + } + }; + cancelled.store(true, Ordering::Release); + stop.notify_one(); + if let Some(task) = task { + let _ = task.await; + } + active.store(false, Ordering::Release); + if quitting { + break; + } + } + })) +} + +fn spawn_apps_watcher_at( + shared: Arc, + shutdown: Arc, + active: Arc, + update_job: UpdateJobHandle, + cancelled: Arc, ) -> Option> { let apps = (*shared.load_full()).spotify_apps_path(); @@ -64,12 +107,15 @@ pub fn spawn_apps_watcher( let nth = applies; let update_job = update_job.clone(); let ctx = shared.load_full(); + let cancelled = Arc::clone(&cancelled); async move { if update_job.status().owns_recovery() { update_job.nudge_apps_changed(); return; } - let joined = tokio::task::spawn_blocking(move || auto_apply(&ctx, nth)).await; + let joined = + tokio::task::spawn_blocking(move || auto_apply(&ctx, nth, &cancelled)) + .await; if joined.is_err() { tracing::error!("auto-apply task panicked"); } @@ -83,7 +129,7 @@ pub fn spawn_apps_watcher( } /// One auto-apply attempt, ordered after the updater's own restart cycle. -fn auto_apply(ctx: &AppContext, nth: u32) { +fn auto_apply(ctx: &AppContext, nth: u32, cancelled: &AtomicBool) { // A stock archive means an update already landed. Repair is needed even // when update protection is blocked or cannot be determined. if !ctx.spotify_apps_path().join("xpui.spa").is_file() { @@ -109,6 +155,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { CLIENT_EXIT_CEILING, || ctx.spotify_apps_path().join("xpui.spa").is_file(), || spicetify::lifecycle::is_running(ctx), + cancelled, ) { Ok(Some(guard)) => guard, Ok(None) => { @@ -120,7 +167,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { return; } }; - if !ctx.spotify_apps_path().join("xpui.spa").is_file() { + if cancelled.load(Ordering::Acquire) || !ctx.spotify_apps_path().join("xpui.spa").is_file() { tracing::info!("stock xpui.spa is no longer present; skipping auto-apply"); return; } @@ -134,10 +181,15 @@ fn wait_for_idle_guard( timeout: Duration, mut repair_pending: impl FnMut() -> bool, mut is_running: impl FnMut() -> bool, + cancelled: &AtomicBool, ) -> anyhow::Result> { let deadline = std::time::Instant::now() + timeout; let mut waited = false; loop { + anyhow::ensure!( + !cancelled.load(Ordering::Acquire), + "Spotify watch path changed; cancelling the old repair" + ); if !repair_pending() { return Ok(None); } @@ -187,7 +239,10 @@ pub fn spawn_config_watcher( tracing::warn!("failed to create config watcher"); return None; }; - if watcher.watch(&config_file, RecursiveMode::NonRecursive).is_err() { + // Atomic config replacement removes the watched inode. Watch its parent + // so later updates are still observed after an installer activation. + let config_parent = config_file.parent()?; + if watcher.watch(config_parent, RecursiveMode::NonRecursive).is_err() { tracing::warn!("failed to watch config file"); return None; } @@ -321,6 +376,7 @@ mod tests { } false }, + &AtomicBool::new(false), ); drop(competing); drop(result?.expect("repair remains pending")); @@ -350,6 +406,7 @@ mod tests { _ => false, } }, + &AtomicBool::new(false), ); drop(result?.expect("repair remains pending")); assert!(checks >= 5, "a restart must resume waiting, not abandon the repair"); @@ -361,19 +418,82 @@ mod tests { fn auto_apply_wait_has_one_deadline_and_preserves_filesystem_errors() -> anyhow::Result<()> { let root = scratch("deadline")?; let guard = commands::guard::try_acquire(&root)?; - let error = wait_for_idle_guard(&root, Duration::from_millis(20), || true, || false) - .expect_err("operation remains locked"); + let error = wait_for_idle_guard( + &root, + Duration::from_millis(20), + || true, + || false, + &AtomicBool::new(false), + ) + .expect_err("the operation guard is still held"); assert!(error.to_string().contains("still busy")); drop(guard); let file = root.join("not-a-directory"); std::fs::write(&file, "sentinel")?; - let error = wait_for_idle_guard(&file, Duration::from_mins(1), || true, || false) - .expect_err("lock path is not a directory"); + let error = wait_for_idle_guard( + &file, + Duration::from_secs(60), + || true, + || false, + &AtomicBool::new(false), + ) + .expect_err("the config root is a file"); assert!(error.downcast_ref::().is_some()); std::fs::remove_dir_all(root)?; Ok(()) } + #[test] + fn path_change_cancels_a_wait_even_with_a_running_client() -> anyhow::Result<()> { + let root = scratch("cancel")?; + let result = wait_for_idle_guard( + &root, + Duration::from_secs(1800), + || true, + || true, + &AtomicBool::new(true), + ); + assert!(result.expect_err("cancelled repair").to_string().contains("watch path changed")); + drop(commands::guard::try_acquire(&root)?); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn private_stop_is_retained_while_a_trigger_is_busy() { + let (tx, rx) = mpsc::unbounded_channel(); + let stop = Arc::new(Notify::new()); + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let task = tokio::spawn(run_loop( + rx, + |_| true, + { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + move || { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + } + } + }, + Arc::clone(&stop), + )); + tx.send(event()).expect("trigger"); + tokio::time::timeout(Duration::from_secs(3), entered.notified()) + .await + .expect("trigger started"); + stop.notify_one(); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(3), task) + .await + .expect("watcher stopped") + .expect("watcher task"); + } + #[test] fn auto_apply_stops_polling_when_another_apply_consumes_the_archive() -> anyhow::Result<()> { let root = scratch("completed-elsewhere")?; @@ -389,6 +509,7 @@ mod tests { std::fs::remove_file(&archive).expect("another apply consumes the archive"); true }, + &AtomicBool::new(false), )?; assert!(result.is_none(), "completed repair must not wait for Spotify to exit"); assert_eq!(process_checks, 1, "polling must stop even though Spotify is still running"); diff --git a/rust/crates/spicetify/Cargo.toml b/rust/crates/spicetify/Cargo.toml index ff8a39fa7b..63b3e602db 100644 --- a/rust/crates/spicetify/Cargo.toml +++ b/rust/crates/spicetify/Cargo.toml @@ -48,5 +48,5 @@ flate2 = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] junction = { workspace = true } -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_System_SystemInformation"] } windows-registry = { workspace = true } diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index e487fa146e..b98904cc1a 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -103,6 +103,10 @@ fn run_inner(ctx: &AppContext, mode: ApplyMode, activate: bool) -> Result<()> { refresh_classmap(ctx, &version.to_string(), no_cache)?; } + // Creating an existing Apps directory does not prove it is writable. + // Prepare the actual staging directory before stopping a working client. + let tmp = prepare_staging_dir(&dest_apps)?; + if activate { crate::lifecycle::stop(ctx)?; } @@ -113,26 +117,11 @@ fn run_inner(ctx: &AppContext, mode: ApplyMode, activate: bool) -> Result<()> { .map_err(fs_err("restoring xpui.spa from the backup at", &spa))?; } - // Names the path on failure: a bare "Permission denied" here means the - // resolved Spotify directory is not writable (often a system-wide install, - // or a bundle detection that missed the real location), and the errno - // alone does not say which directory to look at. - std::fs::create_dir_all(&dest_apps).map_err(|e| { - anyhow::anyhow!( - "cannot prepare {}: {e}. If Spotify is installed elsewhere, set spotify_data_dir in config.toml", - dest_apps.display() - ) - })?; tracing::info!( "{}", fl!("extracting-spa", src = spa.to_string_lossy(), dest = dest_xpui.to_string_lossy()) ); - let tmp = dest_apps.join("xpui.tmp"); - if tmp.exists() { - cleanup_tmp(&tmp); - } - if let Err(e) = extract_into(&spa, &tmp) { cleanup_tmp(&tmp); return Err(e); @@ -334,6 +323,33 @@ fn cleanup_tmp(tmp: &Path) { } } +fn prepare_staging_dir(apps: &Path) -> Result { + let tmp = apps.join("xpui.tmp"); + let prepare = || -> std::io::Result<()> { + std::fs::create_dir_all(apps)?; + if let Err(error) = std::fs::remove_dir_all(&tmp) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error); + } + std::fs::create_dir(&tmp) + }; + prepare().map_err(|error| { + let guidance = "If Spotify is installed elsewhere, set spotify_data_dir in config.toml."; + #[cfg(target_os = "linux")] + let guidance = if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ) { + "Run 'spicetify spotify install' as your normal user to install and patch a user-owned Spotify copy. This requires a verified classmap for that Spotify version. Do not run Spicetify with sudo." + } else { + guidance + }; + anyhow::anyhow!("cannot prepare {}: {error}. {guidance}", tmp.display()) + })?; + Ok(tmp) +} + fn extract_into(spa: &Path, dest: &Path) -> Result<()> { if !spa.exists() { return Err(anyhow::anyhow!(fl!("xpui-not-found", path = spa.to_string_lossy()))); @@ -524,7 +540,12 @@ fn stage_modules(ctx: &AppContext, dest: &Path) -> Result<()> { // here rather than leaving them silently unprotected. super::updates::reassert_block(ctx); - let updates_blocked = super::updates::is_blocked(ctx).unwrap_or(false); + let updates_blocked = super::updates::is_blocked(ctx).ok(); + #[cfg(target_os = "linux")] + let managed_spotify = + super::spotify::managed_channel(ctx)?.map(|channel| channel.name().to_string()); + #[cfg(not(target_os = "linux"))] + let managed_spotify = None; match crate::module::stage::stage_modules( &ctx.config_root, @@ -533,6 +554,7 @@ fn stage_modules(ctx: &AppContext, dest: &Path) -> Result<()> { &version, env!("CARGO_PKG_VERSION"), updates_blocked, + managed_spotify, ) { Ok(0) => { tracing::warn!("no modules staged: the client will boot without them"); @@ -693,6 +715,72 @@ fn patch_index_html( mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn unwritable_installation_does_not_stop_spotify_or_change_its_archive() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir() + .join(format!("spicetify-apply-permissions-{}", std::process::id())); + let apps = root.join("Spotify/Apps"); + std::fs::create_dir_all(&apps).expect("temporary Spotify installation"); + let spa = apps.join("xpui.spa"); + let mut archive = zip::ZipWriter::new(std::fs::File::create(&spa).expect("stock archive")); + archive + .start_file("index.html", zip::write::SimpleFileOptions::default()) + .expect("stock index entry"); + archive.write_all(b"").expect("stock index contents"); + drop(archive.finish().expect("finish stock archive")); + let original = std::fs::read(&spa).expect("original archive bytes"); + let executable = root.join(format!("spa{}", std::process::id())); + let _ = std::fs::copy("/bin/sleep", &executable).expect("isolated client executable"); + let mut client = std::process::Command::new(&executable) + .arg("60") + .spawn() + .expect("start simulated client"); + let ctx = AppContext { + config_file: root.join("config/config.toml"), + config_root: root.join("config"), + mirror: false, + daemon: false, + spotify_data_dir: root.join("Spotify"), + spotify_exec: executable, + offline_bnk_dir: root.clone(), + block_spotify_updates: None, + }; + // Wait until process-name lookup can see the simulated client. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !crate::lifecycle::is_running(&ctx) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let client_visible = crate::lifecycle::is_running(&ctx); + std::fs::set_permissions(&apps, std::fs::Permissions::from_mode(0o555)) + .expect("protect Spotify Apps"); + + let result = if client_visible { + run_inner(&ctx, ApplyMode::Cli { no_cache: false }, true) + } else { + Err(anyhow::anyhow!("simulated client did not become visible")) + }; + let client_status = client.try_wait().expect("client process state"); + let archive_after = std::fs::read(&spa).expect("archive after failed Apply"); + let backup_exists = apps.join("xpui.spa.backup").exists(); + std::fs::set_permissions(&apps, std::fs::Permissions::from_mode(0o755)) + .expect("restore fixture permissions"); + let _ = client.kill(); + let _ = client.wait().expect("reap simulated client"); + std::fs::remove_dir_all(root).expect("remove permission fixture"); + + assert!(client_visible, "process lookup must observe the simulated client before Apply"); + assert!(client_status.is_none(), "client exited: {client_status:?}; Apply: {result:?}"); + assert_eq!(archive_after, original); + assert!(!backup_exists); + let error = result.expect_err("read-only Apps must refuse Apply").to_string(); + assert!(error.contains("xpui.tmp"), "error must name the unwritable path: {error}"); + assert!(error.contains("spicetify spotify install"), "missing recovery: {error}"); + } + const STOCK: &str = r#"Spotify
"#; const STOCK_DIRECT: &str = r#"Spotify
"#; diff --git a/rust/crates/spicetify/src/commands/mod.rs b/rust/crates/spicetify/src/commands/mod.rs index d236b9de4f..05040a7d42 100644 --- a/rust/crates/spicetify/src/commands/mod.rs +++ b/rust/crates/spicetify/src/commands/mod.rs @@ -65,6 +65,7 @@ pub enum PkgAction { Install { id: String, url: Option }, Delete { id: String }, Enable { id: String }, + Update { id: Option }, } pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { @@ -112,6 +113,7 @@ pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { ), PkgAction::Delete { id } => crate::module::delete_module(&ctx.config_root, id), PkgAction::Enable { id } => crate::module::enable_module(&ctx.config_root, id), + PkgAction::Update { id } => pkg::update(ctx, id.as_deref()), } } Command::Protocol(uri) => protocol::run(ctx, uri), @@ -132,7 +134,7 @@ pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { let _guard = guard::try_acquire(&ctx.config_root)?; crate::lifecycle::restart(ctx) } - Command::SelfUpdate => self_update::run(), + Command::SelfUpdate => self_update::run(ctx), } } @@ -155,6 +157,7 @@ mod tests { PkgAction::Install { id: "module@1".to_string(), url: None }, PkgAction::Delete { id: "module@1".to_string() }, PkgAction::Enable { id: "module@1".to_string() }, + PkgAction::Update { id: Some("module@1".to_string()) }, ] { let error = dispatch(&Command::Pkg(action), &ctx).expect_err("competing mutation"); assert!(error.to_string().contains("already in progress"), "{error}"); diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index d1b9bbd0f6..da274907de 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -191,64 +191,191 @@ pub(crate) fn ensure_system_modules(ctx: &AppContext) { return; } }; - let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_default(); + let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_else(|e| { + tracing::warn!( + "cannot read {}: {e}; refreshing without local intent", + paths.vault_path.display() + ); + crate::module::vault::Vault::default() + }); for id in SYSTEM_MODULES { - if intent.modules.get(*id).is_some_and(local_intent_blocks) { - continue; - } - let present = paths.modules_root.join(id).exists(); - let installed = store_managed_version(&paths.modules_root, &paths.store_root, id); - let Some(module) = vault.modules.get(*id) else { - tracing::warn!("system module {id} is not in the registry"); - continue; - }; - let target = match resolve_version(module) { - Ok(version) => version, - Err(e) => { - tracing::warn!("cannot resolve system module {id}: {e}"); - continue; + match plan_refresh(&paths, &intent, &vault, id, RefreshPolicy::Recover) { + Ok(Refresh::Stage { target, superseded }) => { + if let Err(e) = + stage_refresh(ctx, &paths, id, &target, superseded.as_deref(), &vault) + { + tracing::warn!("could not stage system module {id}@{target}: {e}"); + } } - }; - if !should_stage(present, installed.as_deref(), &target, !module.enabled.is_empty()) { - continue; + Ok(Refresh::Skip(_)) => {} + Err(e) => tracing::warn!("cannot refresh system module {id}: {e}"), } - let Some(entry) = module.v.get(&target) else { - tracing::warn!("system module {id}@{target} is not in the registry"); - continue; - }; - if let Err(e) = seed_system_module(ctx, id, &target, entry) { - tracing::warn!("could not stage system module {id}@{target}: {e}"); - continue; + } +} + +/// Which caller a refresh serves; each treats missing modules, local intent and +/// registry gaps its own way. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RefreshPolicy { + /// `apply`: seed absent system modules and never override local intent. + Recover, + /// `pkg update`: every installed module, skipping anything it can't judge. + UpdateAll, + /// `pkg update `: the user named this module, so an inferred pin gives way. + UpdateNamed, +} + +/// Why a refresh leaves a module as it is. +#[derive(Debug, PartialEq, Eq)] +enum Skip { + /// Installed but not enabled. + Disabled, + /// Enabled at an older version while a newer one is installed. + HandPinned { enabled: String }, + /// A developer's real directory or a link outside the store tree. + NotStoreManaged, + /// The registry doesn't carry this module. + NotInRegistry, + /// The installed version came from an explicit artifact, not the registry. + ExplicitArtifact, + /// Already at the registry's version. + Current, +} + +impl std::fmt::Display for Skip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => f.write_str("disabled"), + Self::HandPinned { enabled } => write!(f, "pinned to {enabled}"), + Self::NotStoreManaged => f.write_str("not managed by the store"), + Self::NotInRegistry => f.write_str("not in the registry"), + Self::ExplicitArtifact => f.write_str("installed from an explicit artifact"), + Self::Current => f.write_str("already up to date"), } - // The version this refresh just superseded would otherwise sit in - // the store tree forever, a megabyte per release. - if let Some(old) = installed.filter(|old| old != &target) { - let superseded = - match crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old}")) { - Ok(id) => id, - Err(e) => { - tracing::warn!("cannot collect invalid superseded package {id}@{old}: {e}"); - continue; - } - }; - if let Err(e) = crate::module::delete(&paths, &superseded) { - tracing::warn!("could not collect superseded {superseded}: {e}"); + } +} + +/// What a registry refresh does with one module. +#[derive(Debug, PartialEq, Eq)] +enum Refresh { + /// Install `target`, then collect the store copy of `superseded`. + Stage { target: String, superseded: Option }, + /// Leave the module as it is. + Skip(Skip), +} + +/// Decides one module's refresh against the registry. Only store-managed +/// installs are ever replaced: a developer's real directory or a link into +/// their own build output is never judged against the registry. +fn plan_refresh( + paths: &crate::module::ModulePaths, + intent: &crate::module::vault::Vault, + vault: &Vault, + id: &str, + policy: RefreshPolicy, +) -> Result { + let local = intent.modules.get(id); + let present = paths.modules_root.join(id).exists(); + match local.and_then(local_intent) { + Some(Skip::Disabled) => return Ok(Refresh::Skip(Skip::Disabled)), + Some(pinned @ Skip::HandPinned { .. }) if policy != RefreshPolicy::UpdateNamed => { + return Ok(Refresh::Skip(pinned)); + } + _ => {} + } + if !present && policy != RefreshPolicy::Recover { + anyhow::bail!("{id} is not installed"); + } + let installed = store_managed_version(&paths.modules_root, &paths.store_root, id); + if present && installed.is_none() { + return Ok(Refresh::Skip(Skip::NotStoreManaged)); + } + let Some(module) = vault.modules.get(id) else { + if policy == RefreshPolicy::UpdateAll { + return Ok(Refresh::Skip(Skip::NotInRegistry)); + } + anyhow::bail!("{id} is not in the registry"); + }; + if policy == RefreshPolicy::UpdateAll + && let Some(version) = installed.as_deref() + && from_explicit_artifact(local, module, version) + { + return Ok(Refresh::Skip(Skip::ExplicitArtifact)); + } + let target = resolve_version(module)?; + if !should_stage(present, installed.as_deref(), &target, !module.enabled.is_empty()) { + return Ok(Refresh::Skip(Skip::Current)); + } + Ok(Refresh::Stage { superseded: installed.filter(|old| old != &target), target }) +} + +/// Whether the installed `version` came from bytes other than the registry's, +/// which a bulk update must not overwrite. Registry and store installs record +/// the registry's checksum; an install from an explicit URL records none. A +/// version the registry lists without a checksum is not evidence of either, +/// and one it doesn't list is judged by whether a checksum was recorded. +fn from_explicit_artifact( + local: Option<&crate::module::vault::Module>, + module: &VaultModule, + version: &str, +) -> bool { + let Some(store) = local.and_then(|local| local.versions.get(version)) else { return false }; + match module.v.get(version) { + Some(entry) => { + !entry.checksum.is_empty() + && crate::module::normalize_checksum(&entry.checksum) + != crate::module::normalize_checksum(&store.checksum) + } + None => store.checksum.is_empty(), + } +} + +/// Installs and enables `id@target`, then collects the superseded store copy, +/// which would otherwise sit in the store tree forever, a megabyte per release. +fn stage_refresh( + ctx: &AppContext, + paths: &crate::module::ModulePaths, + id: &str, + target: &str, + superseded: Option<&str>, + vault: &Vault, +) -> Result<()> { + let Some(entry) = vault.modules.get(id).and_then(|module| module.v.get(target)) else { + anyhow::bail!("{id}@{target} is not in the registry"); + }; + stage_module_version(ctx, id, target, entry)?; + if let Some(old) = superseded { + match crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old}")) { + Ok(old) => { + if let Err(e) = crate::module::delete(paths, &old) { + tracing::warn!("could not collect superseded {old}: {e}"); + } } + Err(e) => tracing::warn!("cannot collect invalid superseded package {id}@{old}: {e}"), } } + Ok(()) } -/// Whether the local vault records intent this refresh must not override: -/// disabled while still installed, or pinned to an enabled version below one -/// that is still installed (the only way a downgrade comes about by hand). -/// A deleted module has no installed versions left and is fair game. -fn local_intent_blocks(module: &crate::module::vault::Module) -> bool { +/// The intent the local vault records for a module, if any refresh must honour +/// it: disabled while still installed, or enabled at a version below one that +/// is still installed (the only way a downgrade comes about by hand). Versions +/// a refresh already collected are no longer installed and don't count, so a +/// registry rollback doesn't leave a module looking pinned. A deleted module +/// has no installed versions left and is fair game. +fn local_intent(module: &crate::module::vault::Module) -> Option { let has_installed = module.versions.values().any(|store| store.installed); match &module.enabled { - None => has_installed, + None => has_installed.then_some(Skip::Disabled), Some(pin) => { - let Ok(pin) = semver::Version::parse(pin) else { return false }; - module.versions.keys().filter_map(|v| semver::Version::parse(v).ok()).any(|v| v > pin) + let parsed = semver::Version::parse(pin).ok()?; + module + .versions + .iter() + .filter(|(_, store)| store.installed) + .filter_map(|(v, _)| semver::Version::parse(v).ok()) + .any(|v| v > parsed) + .then(|| Skip::HandPinned { enabled: pin.clone() }) } } } @@ -289,7 +416,7 @@ fn should_stage(present: bool, installed: Option<&str>, target: &str, target_pin } } -fn seed_system_module( +fn stage_module_version( ctx: &AppContext, id: &str, version: &str, @@ -306,7 +433,7 @@ fn seed_system_module( entry.checksum.clone(), )?; crate::module::enable_module(&ctx.config_root, &tag)?; - tracing::info!("staged system module {tag}"); + tracing::info!("staged {tag}"); Ok(()) } @@ -356,6 +483,108 @@ pub(crate) fn install(ctx: &AppContext, identifier: &str) -> Result<()> { ) } +/// `pkg update [id]`: refreshes one installed module, or every store-managed +/// one, to the registry's current version. The registry is fetched fresh, +/// falling back to the cache only when offline. Fails when any module could +/// not be updated, or when a named module was left as it is for a reason the +/// user has to act on. +pub(crate) fn update(ctx: &AppContext, target_id: Option<&str>) -> Result<()> { + if let Some(id) = target_id + && let Some((name, _)) = id.split_once('@') + { + anyhow::bail!( + "pkg update takes a module id; to switch {name} to a specific version, run `spicetify pkg enable {id}`" + ); + } + let paths = crate::module::ModulePaths::from_config_root(&ctx.config_root); + let intent = crate::module::vault::load(&paths.vault_path) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", paths.vault_path.display()))?; + let (ids, policy) = match target_id { + Some(id) => (vec![id.to_string()], RefreshPolicy::UpdateNamed), + None => ( + installed(&ctx.config_root).into_iter().map(|(id, _)| id).collect(), + RefreshPolicy::UpdateAll, + ), + }; + if ids.is_empty() { + tracing::info!("no modules installed"); + return Ok(()); + } + let vault = vault_via_cache( + &vault_cache_path(&ctx.config_root), + std::time::Duration::ZERO, + std::time::SystemTime::now(), + || fetch_vault_body(DEFAULT_VAULT), + )?; + let report = update_modules(&paths, &intent, &vault, &ids, policy, |id, target, superseded| { + stage_refresh(ctx, &paths, id, target, superseded, &vault) + }); + if report.updated > 0 { + tracing::info!( + "updated {} module(s); run `spicetify apply` to load them in Spotify", + report.updated + ); + } else if report.failures.is_empty() { + tracing::info!("all modules are up to date"); + } + if report.failures.is_empty() { + return Ok(()); + } + anyhow::bail!("could not update {}", report.failures.join("; ")) +} + +/// The outcome of an update run. +#[derive(Debug, Default, PartialEq, Eq)] +struct UpdateReport { + updated: usize, + failures: Vec, +} + +/// Plans and stages each module, logging what happened. A named module left +/// as it is for a reason the user has to act on counts as a failure. +fn update_modules( + paths: &crate::module::ModulePaths, + intent: &crate::module::vault::Vault, + vault: &Vault, + ids: &[String], + policy: RefreshPolicy, + mut stage: impl FnMut(&str, &str, Option<&str>) -> Result<()>, +) -> UpdateReport { + let mut report = UpdateReport::default(); + for id in ids { + match plan_refresh(paths, intent, vault, id, policy) { + Ok(Refresh::Stage { target, superseded }) => { + match stage(id, &target, superseded.as_deref()) { + Ok(()) => { + report.updated += 1; + if let Some(old) = &superseded { + tracing::info!("updated {id}: {old} -> {target}"); + } else { + tracing::info!("updated {id} to {target}"); + } + } + Err(e) => report.failures.push(format!("{id}@{target}: {e}")), + } + } + Ok(Refresh::Skip(Skip::Current)) => tracing::info!("{id}: {}", Skip::Current), + Ok(Refresh::Skip(Skip::Disabled)) if policy == RefreshPolicy::UpdateNamed => { + report.failures.push(format!( + "{id}: disabled; run `spicetify pkg enable {id}@` to turn it back on" + )); + } + Ok(Refresh::Skip(Skip::NotStoreManaged)) if policy == RefreshPolicy::UpdateNamed => { + report.failures.push(format!( + "{id}: {}, so it is never replaced from the registry", + Skip::NotStoreManaged + )); + } + Ok(Refresh::Skip(reason)) => tracing::info!("{id}: {reason}, left as is"), + Err(e) => report.failures.push(format!("{id}: {e}")), + } + } + report +} + #[cfg(test)] mod tests { use super::*; @@ -484,24 +713,255 @@ mod tests { } } + /// A config root with a store-managed `lyrics@1.0.0`, a disabled + /// store-managed `off@1.0.0` and a developer's real `dev` directory. + fn update_fixture( + name: &str, + ) -> (std::path::PathBuf, crate::module::ModulePaths, crate::module::vault::Vault, Vault) { + let root = + std::env::temp_dir().join(format!("spicetify-update-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let paths = crate::module::ModulePaths::from_config_root(&root); + for id in [ + "lyrics", + "off", + "orphan", + "url", + "pinned", + "yanked", + "mirror", + "fork", + "unchecksummed", + ] { + std::fs::create_dir_all(paths.store_root.join(id).join("1.0.0")).expect("store copy"); + std::fs::create_dir_all(&paths.modules_root).expect("modules root"); + crate::util::link::create_dir_link( + &paths.store_root.join(id).join("1.0.0"), + &paths.modules_root.join(id), + ) + .expect("store link"); + } + std::fs::create_dir_all(paths.modules_root.join("dev")).expect("developer build"); + let mut local = crate::module::vault::Vault::default(); + assert!( + local.modules.insert("off".to_string(), intent(None, &[("1.0.0", true)])).is_none() + ); + let mut url = intent(Some("1.0.0"), &[("1.0.0", true)]); + url.versions.get_mut("1.0.0").expect("version").checksum = String::new(); + url.versions.get_mut("1.0.0").expect("version").artifacts = + vec!["https://example.invalid/fork.zip".to_string()]; + assert!(local.modules.insert("url".to_string(), url).is_none()); + let mut mirror = intent(Some("1.0.0"), &[("1.0.0", true)]); + let store = mirror.versions.get_mut("1.0.0").expect("version"); + store.checksum = "MIRROR".to_string(); + store.artifacts = vec!["https://mirror.invalid/m.zip".to_string()]; + assert!(local.modules.insert("mirror".to_string(), mirror).is_none()); + for (id, checksum) in + [("yanked", "sha256:gone"), ("fork", ""), ("unchecksummed", "sha256:old")] + { + let mut module = intent(Some("1.0.0"), &[("1.0.0", true)]); + module.versions.get_mut("1.0.0").expect("version").checksum = checksum.to_string(); + assert!(local.modules.insert(id.to_string(), module).is_none()); + } + let pinned = intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", true)]); + assert!(local.modules.insert("pinned".to_string(), pinned).is_none()); + let entry = || VaultVersion { + artifacts: vec!["https://example.invalid/m.zip".to_string()], + checksum: String::new(), + }; + let module = |versions: &[&str]| VaultModule { + enabled: String::new(), + v: versions.iter().map(|v| ((*v).to_string(), entry())).collect(), + }; + let vault = Vault { + modules: BTreeMap::from([ + ("lyrics".to_string(), module(&["1.0.0", "1.1.0"])), + ("off".to_string(), module(&["1.1.0"])), + ("dev".to_string(), module(&["2.0.0"])), + ("url".to_string(), module(&["1.0.0", "1.1.0"])), + ("absent".to_string(), module(&["1.0.0"])), + ("pinned".to_string(), module(&["1.0.0", "1.1.0", "1.2.0"])), + ]), + }; + let mut vault = vault; + let checksummed = |checksum: &str| VaultVersion { + artifacts: vec![ + "https://example.invalid/m.zip".to_string(), + "https://mirror.invalid/m.zip".to_string(), + ], + checksum: checksum.to_string(), + }; + assert!( + vault + .modules + .get_mut("url") + .expect("url") + .v + .insert("1.0.0".to_string(), checksummed("sha256:registry")) + .is_some() + ); + let mirror = VaultModule { + enabled: String::new(), + v: BTreeMap::from([ + ("1.0.0".to_string(), checksummed("sha256:mirror")), + ("1.1.0".to_string(), checksummed("sha256:next")), + ]), + }; + assert!(vault.modules.insert("mirror".to_string(), mirror).is_none()); + assert!(vault.modules.insert("yanked".to_string(), module(&["1.1.0"])).is_none()); + assert!(vault.modules.insert("fork".to_string(), module(&["1.1.0"])).is_none()); + let unchecksummed = module(&["1.0.0", "1.1.0"]); + assert!(vault.modules.insert("unchecksummed".to_string(), unchecksummed).is_none()); + (root, paths, local, vault) + } + + #[test] + fn each_refresh_policy_judges_modules_its_own_way() { + let (root, paths, intent, vault) = update_fixture("plan"); + let plan = |id: &str, policy| plan_refresh(&paths, &intent, &vault, id, policy); + let stage = |target: &str, superseded: Option<&str>| Refresh::Stage { + target: target.to_string(), + superseded: superseded.map(str::to_string), + }; + use RefreshPolicy::{Recover, UpdateAll, UpdateNamed}; + + assert_eq!(plan("lyrics", UpdateAll).expect("plan"), stage("1.1.0", Some("1.0.0"))); + assert_eq!(plan("dev", UpdateAll).expect("plan"), Refresh::Skip(Skip::NotStoreManaged)); + assert_eq!(plan("off", UpdateNamed).expect("plan"), Refresh::Skip(Skip::Disabled)); + + assert_eq!(plan("orphan", UpdateAll).expect("plan"), Refresh::Skip(Skip::NotInRegistry)); + let error = + plan("orphan", UpdateNamed).expect_err("named module missing from the registry"); + assert!(error.to_string().contains("orphan is not in the registry"), "{error}"); + + assert_eq!(plan("url", UpdateAll).expect("plan"), Refresh::Skip(Skip::ExplicitArtifact)); + assert_eq!(plan("url", UpdateNamed).expect("plan"), stage("1.1.0", Some("1.0.0"))); + assert_eq!( + plan("mirror", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a registry install with other artifacts and checksum spelling updates" + ); + assert_eq!( + plan("yanked", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a registry install the registry no longer lists updates" + ); + assert_eq!( + plan("fork", UpdateAll).expect("plan"), + Refresh::Skip(Skip::ExplicitArtifact), + "a URL install of a version the registry doesn't list stays" + ); + assert_eq!( + plan("unchecksummed", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a registry entry without a checksum is no evidence of an explicit install" + ); + + assert_eq!( + plan("pinned", UpdateAll).expect("plan"), + Refresh::Skip(Skip::HandPinned { enabled: "1.0.0".to_string() }) + ); + assert_eq!( + plan("pinned", UpdateNamed).expect("plan"), + stage("1.2.0", Some("1.0.0")), + "naming the module overrides an inferred pin" + ); + assert_eq!( + plan("pinned", Recover).expect("plan"), + Refresh::Skip(Skip::HandPinned { enabled: "1.0.0".to_string() }) + ); + + assert_eq!( + plan("absent", Recover).expect("plan"), + stage("1.0.0", None), + "apply seeds absent modules" + ); + let error = plan("absent", UpdateNamed).expect_err("not installed"); + assert!(error.to_string().contains("absent is not installed"), "{error}"); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn a_bulk_update_skips_what_it_cannot_judge_and_reports_real_failures() { + let (root, paths, intent, vault) = update_fixture("bulk"); + let ids: Vec = ["dev", "lyrics", "orphan", "pinned", "url"] + .iter() + .map(|id| (*id).to_string()) + .collect(); + let mut staged = Vec::new(); + let report = update_modules( + &paths, + &intent, + &vault, + &ids, + RefreshPolicy::UpdateAll, + |id, target, superseded| { + staged.push(format!("{id}@{target} replacing {superseded:?}")); + anyhow::bail!("download failed") + }, + ); + assert_eq!(staged, ["lyrics@1.1.0 replacing Some(\"1.0.0\")"]); + assert_eq!( + report, + UpdateReport { + updated: 0, + failures: vec!["lyrics@1.1.0: download failed".to_string()] + } + ); + + let report = + update_modules(&paths, &intent, &vault, &ids, RefreshPolicy::UpdateAll, |_, _, _| { + Ok(()) + }); + assert_eq!(report, UpdateReport { updated: 1, failures: Vec::new() }); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn a_named_update_fails_when_the_user_has_to_act() { + let (root, paths, intent, vault) = update_fixture("named"); + let named = |id: &str| { + update_modules( + &paths, + &intent, + &vault, + &[id.to_string()], + RefreshPolicy::UpdateNamed, + |_, _, _| Ok(()), + ) + }; + assert!(named("off").failures[0].contains("run `spicetify pkg enable off@`")); + assert!(named("dev").failures[0].contains("never replaced from the registry")); + assert!(named("ghost").failures[0].contains("ghost is not installed")); + assert_eq!(named("pinned"), UpdateReport { updated: 1, failures: Vec::new() }); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + #[test] fn honours_disable_and_hand_pins_but_not_deletion() { assert!( - local_intent_blocks(&intent(None, &[("1.10.0", true)])), + local_intent(&intent(None, &[("1.10.0", true)])) == Some(Skip::Disabled), "disabled while installed stays off" ); assert!( - !local_intent_blocks(&intent(None, &[("1.10.0", false)])), + local_intent(&intent(None, &[("1.10.0", false)])).is_none(), "a deleted module is recovery, not intent" ); assert!( - local_intent_blocks(&intent(Some("1.7.0"), &[("1.7.0", true), ("1.10.0", true)])), + matches!( + local_intent(&intent(Some("1.7.0"), &[("1.7.0", true), ("1.10.0", true)])), + Some(Skip::HandPinned { .. }) + ), "an enabled version below an installed newer one is a hand pin" ); assert!( - !local_intent_blocks(&intent(Some("1.10.0"), &[("1.10.0", true)])), + local_intent(&intent(Some("1.10.0"), &[("1.10.0", true)])).is_none(), "enabled at the newest installed version is the normal state" ); + assert!( + local_intent(&intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", false)])).is_none(), + "a version a registry rollback already collected is not a hand pin" + ); } #[test] @@ -532,7 +992,7 @@ mod tests { let module = vault.modules.get(*id).expect("registry carries every system module"); let version = resolve_version(module).expect("resolvable"); let entry = module.v.get(&version).expect("resolved version has an entry"); - let result = seed_system_module(&ctx, id, &version, entry); + let result = stage_module_version(&ctx, id, &version, entry); assert!(result.is_ok(), "seed {id}: {:?}", result.err()); } diff --git a/rust/crates/spicetify/src/commands/self_update.rs b/rust/crates/spicetify/src/commands/self_update.rs index 8f4d78c074..a663b888ac 100644 --- a/rust/crates/spicetify/src/commands/self_update.rs +++ b/rust/crates/spicetify/src/commands/self_update.rs @@ -3,8 +3,9 @@ use anyhow::Context; use crate::error::Result; use crate::{fl, update}; -pub(crate) fn run() -> Result<()> { +pub(crate) fn run(ctx: &crate::context::AppContext) -> Result<()> { let current_version = crate::VERSION; + let arch = update::release::platform_arch(ctx); tracing::info!("{}", fl!("self-update-checking")); let rt = tokio::runtime::Builder::new_current_thread() @@ -12,9 +13,16 @@ pub(crate) fn run() -> Result<()> { .build() .context("failed to create async runtime")?; - let release = rt.block_on(async { update::check_for_update().await })?; + let release = rt.block_on(async { update::check_for_update(arch).await })?; let Some(release) = release else { + #[cfg(windows)] + if crate::daemon::DaemonManager::create().is_installed() + && !crate::daemon::is_daemon_running() + { + crate::daemon::process::spawn() + .context("failed to restart enabled daemon after update")?; + } tracing::info!("{}", fl!("self-update-up-to-date", version = current_version)); return Ok(()); }; @@ -25,7 +33,7 @@ pub(crate) fn run() -> Result<()> { ); rt.block_on(async { - let staged = update::download_update(&release, |downloaded, total| { + let staged = update::download_update(&release, arch, |downloaded, total| { if total > 0 { tracing::info!(downloaded, total, "downloading..."); } diff --git a/rust/crates/spicetify/src/commands/spotify.rs b/rust/crates/spicetify/src/commands/spotify.rs index 701553dadd..8445ed26d1 100644 --- a/rust/crates/spicetify/src/commands/spotify.rs +++ b/rust/crates/spicetify/src/commands/spotify.rs @@ -23,7 +23,7 @@ pub enum Channel { } impl Channel { - fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { Self::Stable => "stable", Self::Testing => "testing", @@ -33,7 +33,7 @@ impl Channel { #[derive(Debug, Clone, Copy)] pub enum Action { - Install(Channel), + Install(Option), Update(Option), Status, } @@ -76,15 +76,49 @@ fn installed(ctx: &AppContext) -> Result> { Ok(Some(serde_json::from_slice(&record)?)) } +pub(crate) fn managed_channel(ctx: &AppContext) -> Result> { + Ok(installed(ctx)?.map(|record| record.channel)) +} + pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { ensure!(std::env::consts::ARCH == "x86_64", "Spotify's Linux package requires x86_64"); if matches!(action, Action::Status) { return status(ctx); } let guard = super::guard::try_acquire(&ctx.config_root)?; + run_inner(ctx, *action, &guard, true, &mut |_| Ok(())) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Phase { + Checking, + Downloading, + Preparing, + Activating, +} + +/// The daemon keeps running while it owns the update and follows the new config. +pub fn update_managed( + ctx: &AppContext, + guard: &super::guard::DisruptiveOperationGuard, + mut progress: impl FnMut(Phase) -> Result<()>, +) -> Result<()> { + run_inner(ctx, Action::Update(None), guard, false, &mut progress) +} + +fn run_inner( + ctx: &AppContext, + action: Action, + guard: &super::guard::DisruptiveOperationGuard, + manage_daemon: bool, + progress: &mut impl FnMut(Phase) -> Result<()>, +) -> Result<()> { + progress(Phase::Checking)?; let current = installed(ctx)?; let channel = match action { - Action::Install(channel) => *channel, + Action::Install(channel) => channel + .unwrap_or_else(|| current.as_ref().map_or(Channel::Stable, |record| record.channel)), Action::Update(channel) => { let current = current.as_ref().context( "Spotify is not managed by Spicetify; run `spicetify spotify install` first", @@ -105,6 +139,7 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { && current.sha256 == package.sha256 && actual_version.as_deref() == Some(package.version.as_str()) { + repair_launchers(ctx)?; tracing::info!("Spotify {} is already installed", current.version); // A channel change can point at exactly the same package. let record = Installation { @@ -116,12 +151,14 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { return Ok(()); } verify_support(ctx, &package.version)?; + progress(Phase::Downloading)?; let archive = download(&package)?; let versions = install_root()?.join("versions"); fs::create_dir_all(&versions)?; let candidate = versions.join(format!("{}-{}", package.version, nonce()?)); fs::create_dir(&candidate)?; let result = (|| { + progress(Phase::Preparing)?; tracing::info!("Preparing Spotify {} from the {} channel", package.version, channel.name()); extract_deb(&archive, &candidate)?; check_dependencies(&candidate)?; @@ -135,7 +172,13 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { config.spotify_data_dir = Some(candidate.clone()); config.mirror = false; let next = AppContext::from_config(ctx.config_root.clone(), &config)?; - super::apply::prepare(&next, &guard)?; + let record = Installation { + version: package.version.clone(), + channel, + sha256: package.sha256.clone(), + }; + fs::write(candidate.join(RECORD), serde_json::to_vec_pretty(&record)?)?; + super::apply::prepare(&next, guard)?; let manifest: serde_json::Value = serde_json::from_slice(&fs::read(candidate.join("Apps/xpui/modules/manifest.json"))?)?; ensure!( @@ -144,10 +187,8 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { == Some(version_line(&package.version)?.as_str()), "prepared client did not use a verified classmap for this Spotify version" ); - let record = - Installation { version: package.version.clone(), channel, sha256: package.sha256 }; - fs::write(candidate.join(RECORD), serde_json::to_vec_pretty(&record)?)?; - activate(ctx, &next, &config, &candidate)?; + progress(Phase::Activating)?; + activate(ctx, &next, &config, &candidate, manage_daemon)?; tracing::info!("Spotify {} installed at {}", package.version, candidate.display()); tracing::info!( "Use `spicetify spotify update` for future package updates. Native updater blocking remains a separate check." @@ -161,6 +202,48 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { result } +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", rename_all_fields = "camelCase")] +pub enum InstallationStatus { + External, + Unavailable { message: String }, + Managed { version: String, channel: Channel, native_blocked: Option }, +} + +pub fn installation_status(ctx: &AppContext) -> Result { + Ok(match installed(ctx)? { + None => InstallationStatus::External, + Some(record) => InstallationStatus::Managed { + version: executable_version(&ctx.spotify_exec)?, + channel: record.channel, + native_blocked: super::updates::is_blocked(ctx).ok(), + }, + }) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AvailableUpdate { + Current { version: String }, + Ready { version: String }, + Unavailable { version: String, message: String }, +} + +pub fn check_update(ctx: &AppContext) -> Result { + let current = installed(ctx)?.context("Spotify is not managed by Spicetify")?; + let package = latest(current.channel)?; + let actual = executable_version(&ctx.spotify_exec)?; + if version_parts(&package.version)? <= version_parts(&actual)? { + return Ok(AvailableUpdate::Current { version: package.version }); + } + Ok(match verify_support(ctx, &package.version) { + Ok(()) => AvailableUpdate::Ready { version: package.version }, + Err(error) => { + AvailableUpdate::Unavailable { version: package.version, message: format!("{error:#}") } + } + }) +} + fn status(ctx: &AppContext) -> Result<()> { let current = installed(ctx)?; if let Some(current) = ¤t { @@ -541,10 +624,18 @@ fn recovery_error( } } -fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Path) -> Result<()> { +fn activate( + old: &AppContext, + next: &AppContext, + config: &Config, + candidate: &Path, + manage_daemon: bool, +) -> Result<()> { let launcher = base_dirs()?.data_dir().join("applications/spotify.desktop"); let previous_config = read_optional(&old.config_file)?; let previous_launcher = read_optional(&launcher)?; + let terminal = terminal_launcher()?; + let previous_terminal = read_launcher_link(&terminal)?; let desktop = desktop_entry(&next.spotify_exec, &candidate.join("icons/spotify-linux-128.png"))?; let config_bytes = toml::to_string_pretty(config)?.into_bytes(); @@ -557,10 +648,12 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa let was_running = crate::lifecycle::is_running(old); let daemon_running = crate::daemon::is_daemon_running(); let daemon_installed = crate::daemon::DaemonManager::create().is_installed(); - super::daemon::stop()?; + if manage_daemon { + super::daemon::stop()?; + } let result = (|| { ensure!( - !crate::daemon::is_daemon_running(), + !manage_daemon || !crate::daemon::is_daemon_running(), "daemon did not stop before changing Spotify paths" ); crate::lifecycle::stop(old)?; @@ -568,6 +661,7 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa replace_and_activate( &[(&old.config_file, &config_bytes), (&launcher, desktop.as_bytes())], || { + replace_launcher_link(&terminal, Some(&next.spotify_exec))?; super::updates::reassert_block(next); if let Ok(blocked) = super::updates::is_blocked(next) { ensure!( @@ -591,17 +685,21 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa ("stop candidate", crate::lifecycle::stop(next)), ("restore configuration", restore_file(&old.config_file, previous_config.as_deref())), ("restore launcher", restore_file(&launcher, previous_launcher.as_deref())), + ( + "restore terminal launcher", + replace_launcher_link(&terminal, previous_terminal.as_deref()), + ), ( "restart previous client", if was_running { crate::lifecycle::start(old) } else { Ok(()) }, ), ( "restore daemon service", - if daemon_installed { super::daemon::install() } else { Ok(()) }, + if manage_daemon && daemon_installed { super::daemon::install() } else { Ok(()) }, ), ( "restart daemon", - if daemon_running && !crate::daemon::is_daemon_running() { + if manage_daemon && daemon_running && !crate::daemon::is_daemon_running() { super::daemon::start() } else { Ok(()) @@ -610,18 +708,133 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa ]; return Err(recovery_error(error, recovery)); } - super::apply::ensure_daemon(next); - crate::platform::register_url_scheme(); + if manage_daemon { + super::apply::ensure_daemon(next); + crate::platform::register_url_scheme(); + } if let Some(directory) = launcher.parent() { let _ = Command::new("update-desktop-database").arg(directory).output(); } + check_terminal_path(next); + Ok(()) +} + +fn terminal_launcher() -> Result { + Ok(base_dirs()?.home_dir().join(".local/bin/spotify")) +} + +fn read_launcher_link(path: &Path) -> Result> { + match fs::read_link(path) { + Ok(target) => Ok(Some(target)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("cannot replace {}; move the existing file aside to use the managed Spotify launcher", path.display())), + } +} + +fn replace_launcher_link(path: &Path, target: Option<&Path>) -> Result<()> { + let Some(target) = target else { return restore_file(path, None) }; + let parent = path.parent().context("launcher has no parent")?; + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".spotify-{}", nonce()?)); + std::os::unix::fs::symlink(target, &temporary)?; + if let Err(error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(temporary); + return Err(error.into()); + } + Ok(()) +} + +fn repair_launchers(ctx: &AppContext) -> Result<()> { + let terminal = terminal_launcher()?; + let previous = read_launcher_link(&terminal)?; + let desktop = base_dirs()?.data_dir().join("applications/spotify.desktop"); + let content = desktop_entry( + &ctx.spotify_exec, + &ctx.spotify_data_dir.join("icons/spotify-linux-128.png"), + )?; + let result = replace_and_activate(&[(&desktop, content.as_bytes())], || { + replace_launcher_link(&terminal, Some(&ctx.spotify_exec)) + }); + if let Err(error) = result { + return Err(recovery_error( + error, + [("restore terminal launcher", replace_launcher_link(&terminal, previous.as_deref()))], + )); + } + check_terminal_path(ctx); Ok(()) } +fn terminal_path_selects(path: &std::ffi::OsStr, executable: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let Ok(expected) = executable.canonicalize() else { return false }; + let selected = + std::env::split_paths(path).map(|directory| directory.join("spotify")).find(|candidate| { + fs::metadata(candidate).is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }); + selected.and_then(|candidate| candidate.canonicalize().ok()) == Some(expected) +} + +fn check_terminal_path(ctx: &AppContext) { + if !std::env::var_os("PATH").is_some_and(|path| terminal_path_selects(&path, &ctx.spotify_exec)) + { + tracing::warn!( + "The terminal command does not select managed Spotify. Put ~/.local/bin before other Spotify directories in your shell's PATH and reopen your terminal. The desktop launcher already selects the managed copy." + ); + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn terminal_launcher_switch_and_rollback_preserve_the_previous_target() { + let dir = Fixture::new(); + let launcher = dir.0.join("bin/spotify"); + let first = dir.0.join("old client/spotify"); + let second = dir.0.join("new client/spotify"); + assert!(read_launcher_link(&launcher).expect("missing launcher").is_none()); + replace_launcher_link(&launcher, Some(&first)).expect("initial launcher"); + let previous = read_launcher_link(&launcher).expect("snapshot"); + replace_launcher_link(&launcher, Some(&second)).expect("switch launcher"); + assert_eq!(fs::read_link(&launcher).expect("target"), second); + replace_launcher_link(&launcher, previous.as_deref()).expect("restore launcher"); + assert_eq!(fs::read_link(&launcher).expect("restored target"), first); + replace_launcher_link(&launcher, None).expect("remove new launcher"); + fs::write(&launcher, b"user script").expect("user launcher"); + assert!(read_launcher_link(&launcher).is_err()); + assert_eq!(fs::read(&launcher).expect("user file preserved"), b"user script"); + } + + #[test] + fn terminal_path_check_detects_missing_or_shadowed_managed_launchers() { + use std::os::unix::fs::PermissionsExt; + let dir = Fixture::new(); + let user = dir.0.join("user-bin"); + let system = dir.0.join("system-bin"); + fs::create_dir_all(&user).expect("user bin"); + fs::create_dir_all(&system).expect("system bin"); + for parent in [&user, &system] { + let executable = parent.join("spotify"); + fs::write(&executable, b"#!/bin/sh\nexit 0\n").expect("executable"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)) + .expect("permissions"); + } + let target = user.join("spotify"); + assert!(terminal_path_selects( + &std::env::join_paths([&user, &system]).expect("PATH"), + &target + )); + assert!(!terminal_path_selects( + &std::env::join_paths([&system, &user]).expect("PATH"), + &target + )); + assert!(!terminal_path_selects(&std::env::join_paths([&system]).expect("PATH"), &target)); + } + struct Fixture(PathBuf); impl Fixture { fn new() -> Self { diff --git a/rust/crates/spicetify/src/module/cssmap.rs b/rust/crates/spicetify/src/module/cssmap.rs index 0d0cbc15bf..a1c949c432 100644 --- a/rust/crates/spicetify/src/module/cssmap.rs +++ b/rust/crates/spicetify/src/module/cssmap.rs @@ -50,15 +50,14 @@ impl CssMap { let mut map: BTreeMap = serde_json::from_str(&raw).ok()?; tracing::info!("using css map {source} ({} entries)", map.len()); - if let Some(overlay) = find_overlay(config_root, classmap_key) + let overlay_key = super::stage::resolve_classmap_key(config_root, classmap_key) + .map_or_else(|| classmap_key.to_owned(), |(key, _)| key); + if let Some(overlay) = find_overlay(config_root, &overlay_key) .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|raw| serde_json::from_str::>(&raw).ok()) && !overlay.is_empty() { - tracing::info!( - "applied css-map overlay for {classmap_key} ({} entries)", - overlay.len() - ); + tracing::info!("applied css-map overlay for {overlay_key} ({} entries)", overlay.len()); map.extend(overlay); } @@ -265,6 +264,54 @@ mod tests { assert_eq!(m.apply_css(src), src); } + #[test] + fn fallback_classmap_keeps_its_css_overlay_for_client_and_modules() { + let root = std::env::temp_dir().join(format!( + "spicetify-fallback-css-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let fallback = root.join("classmaps/1030000"); + std::fs::create_dir_all(&fallback).expect("fallback directory"); + std::fs::write(root.join("css-map.json"), "{}").expect("base css map"); + std::fs::write(fallback.join("classmap.json"), "{}").expect("classmap"); + std::fs::write( + fallback.join("css-map.json"), + r#"{"nqqkID2Vj36sqqr4GPmw":"main-globalNav-historyButtonsWrapper","fmmygDM45MTS7I35OyLh":"main-contextMenu-menuItemButton"}"#, + ) + .expect("fallback overlay"); + + let map = CssMap::load(&root, "1030001").expect("css map"); + let client = root.join("xpui"); + let modules = client.join("modules/manager"); + std::fs::create_dir_all(&modules).expect("staged modules"); + std::fs::write( + client.join("xpui-modules.js"), + r#"const classes={nav:"nqqkID2Vj36sqqr4GPmw",menu:"fmmygDM45MTS7I35OyLh"};"#, + ) + .expect("client bundle"); + std::fs::write( + client.join("xpui-snapshot.css"), + ".nqqkID2Vj36sqqr4GPmw{display:flex}.fmmygDM45MTS7I35OyLh:hover{color:red}", + ) + .expect("client css"); + std::fs::write(modules.join("index.js"), r#"const cls="fmmygDM45MTS7I35OyLh";"#) + .expect("staged module"); + + let touched = apply_to_tree(&map, &client).expect("rewrite tree"); + let css = std::fs::read_to_string(client.join("xpui-snapshot.css")).expect("read css"); + let module = std::fs::read_to_string(modules.join("index.js")).expect("read module"); + std::fs::remove_dir_all(root).expect("cleanup"); + + assert_eq!(touched, 3, "a fallback must rewrite the client JS, CSS, and staged modules"); + assert!(css.contains(".main-globalNav-historyButtonsWrapper{display:flex}")); + assert!(css.contains(".main-contextMenu-menuItemButton:hover")); + assert!(module.contains("main-contextMenu-menuItemButton")); + } + #[test] fn does_not_search_for_a_legacy_map_beside_the_executable() { let config_root = PathBuf::from("configured-css-map-root"); diff --git a/rust/crates/spicetify/src/module/mod.rs b/rust/crates/spicetify/src/module/mod.rs index e10f98c021..a22290f667 100644 --- a/rust/crates/spicetify/src/module/mod.rs +++ b/rust/crates/spicetify/src/module/mod.rs @@ -76,13 +76,18 @@ pub(crate) fn add_store(paths: &ModulePaths, id: &StoreIdentifier, store: Store) Ok(()) } +/// The bare lowercase hex of a `sha256:` prefixed or bare checksum, either case. +pub(crate) fn normalize_checksum(checksum: &str) -> String { + // Lowercase before stripping: an upper-case prefix is the same claim, + // and stripping first would leave it in the compared value. + let checksum = checksum.trim().to_ascii_lowercase(); + checksum.trim_start_matches("sha256:").to_string() +} + /// Compares a downloaded artifact against the checksum the vault recorded /// for it. `sha256:` prefixed or bare, either case. fn verify_checksum(expected: &str, bytes: &[u8]) -> Result<()> { - // Lowercase before stripping: an upper-case prefix is the same claim, - // and stripping first would leave it in the compared value. - let want = expected.trim().to_ascii_lowercase(); - let want = want.trim_start_matches("sha256:"); + let want = normalize_checksum(expected); let got = remote::digest(bytes); if want != got { anyhow::bail!( diff --git a/rust/crates/spicetify/src/module/remote.rs b/rust/crates/spicetify/src/module/remote.rs index 28cd3fe713..3b4db04056 100644 --- a/rust/crates/spicetify/src/module/remote.rs +++ b/rust/crates/spicetify/src/module/remote.rs @@ -372,6 +372,7 @@ mod tests { Err(e) => unreachable!("fixture accept failed: {e}"), } }; + stream.set_nonblocking(false).expect("blocking fixture stream"); stream.set_read_timeout(Some(Duration::from_secs(5))).expect("read timeout"); let mut request = Vec::new(); while !request.ends_with(b"\r\n\r\n") { diff --git a/rust/crates/spicetify/src/module/stage.rs b/rust/crates/spicetify/src/module/stage.rs index a331e365fd..7b1e846cc5 100644 --- a/rust/crates/spicetify/src/module/stage.rs +++ b/rust/crates/spicetify/src/module/stage.rs @@ -81,8 +81,11 @@ pub(crate) struct ModulesManifest { #[serde(skip_serializing_if = "Option::is_none")] pub supported_spotify: Option, pub cli_version: String, - /// Read by the manager module's Updates panel. - pub updates_blocked: bool, + /// None means native updater protection could not be determined. + #[serde(skip_serializing_if = "Option::is_none")] + pub updates_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_spotify: Option, pub classmap_fallback: bool, pub classmap: serde_json::Value, pub modules: Vec, @@ -137,7 +140,7 @@ pub(crate) fn classmap_search_dirs(config_root: &Path) -> Vec { /// crossing a minor is not assumed to be safe. /// /// Returns the resolved key and whether it is a fallback. -fn resolve_classmap_key(config_root: &Path, key: &str) -> Option<(String, bool)> { +pub(crate) fn resolve_classmap_key(config_root: &Path, key: &str) -> Option<(String, bool)> { if find_classmap_file(config_root, key).is_some() { return Some((key.to_string(), false)); } @@ -323,7 +326,8 @@ pub(crate) fn stage_modules( xpui: &Path, spotify_version: &str, cli_version: &str, - updates_blocked: bool, + updates_blocked: Option, + managed_spotify: Option, ) -> Result { if !modules_root.is_dir() { tracing::info!("no modules directory at {}: nothing to stage", modules_root.display()); @@ -419,6 +423,7 @@ pub(crate) fn stage_modules( supported_spotify: support.latest_spotify, cli_version: cli_version.to_string(), updates_blocked, + managed_spotify, classmap_fallback, classmap, modules: staged, @@ -522,7 +527,8 @@ mod tests { classmap_verified: true, supported_spotify: Some("1.2.97".to_string()), cli_version: "3.0.0-beta.6".to_string(), - updates_blocked: false, + updates_blocked: Some(false), + managed_spotify: None, classmap_fallback: false, classmap: serde_json::json!({}), modules: Vec::new(), @@ -533,6 +539,31 @@ mod tests { assert_eq!(json["supportedSpotify"], "1.2.97"); } + #[test] + fn manifest_preserves_unknown_allowed_and_blocked_update_states() { + for state in [None, Some(false), Some(true)] { + let manifest = ModulesManifest { + spotify_version: "1.2.96".to_string(), + classmap_key: "1020096".to_string(), + classmap_spotify: None, + classmap_verified: false, + supported_spotify: None, + cli_version: "3.0.0-beta.17".to_string(), + updates_blocked: state, + managed_spotify: Some("testing".into()), + classmap_fallback: false, + classmap: serde_json::json!({}), + modules: Vec::new(), + }; + let json = serde_json::to_value(manifest).expect("manifest serializes"); + assert_eq!(json.get("managedSpotify"), Some(&serde_json::json!("testing"))); + match state { + Some(blocked) => assert_eq!(json.get("updatesBlocked"), Some(&blocked.into())), + None => assert!(json.get("updatesBlocked").is_none()), + } + } + } + #[test] fn rewrites_references_to_quoted_leaves() { let out = remap_source("const a = MAP.main.navbar.link;", &classmap(), &BTreeSet::new()) diff --git a/rust/crates/spicetify/src/process.rs b/rust/crates/spicetify/src/process.rs index 0a570f99da..1576fe2035 100644 --- a/rust/crates/spicetify/src/process.rs +++ b/rust/crates/spicetify/src/process.rs @@ -54,15 +54,29 @@ pub(crate) fn process_running(name: &str) -> bool { #[cfg(target_os = "linux")] fn linux_process_alive(pid: u32) -> bool { // pgrep includes unreaped children. They cannot exit again or serve a client. - std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { + let alive = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { stat.rsplit_once(')') .and_then(|(_, rest)| rest.split_whitespace().next()) .is_some_and(|state| state != "Z" && state != "X") - }) + }); + alive + && std::fs::read(format!("/proc/{pid}/cmdline")).is_ok_and(|args| !is_version_probe(&args)) +} + +#[cfg(target_os = "linux")] +fn is_version_probe(args: &[u8]) -> bool { + args.split(|byte| *byte == 0).skip(1).any(|arg| arg == b"--version") } #[cfg(all(test, target_os = "linux"))] mod linux_tests { + #[test] + fn version_probes_do_not_count_as_a_running_client() { + assert!(super::is_version_probe(b"/opt/spotify/spotify\0--version\0")); + assert!(!super::is_version_probe(b"/opt/spotify/spotify\0--remote-debugging-port=9229\0")); + assert!(!super::is_version_probe(b"/opt/spotify/spotify\0spotify:track:version\0")); + } + #[test] fn an_unreaped_child_is_not_a_running_client() { let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap(); diff --git a/rust/crates/spicetify/src/update/mod.rs b/rust/crates/spicetify/src/update/mod.rs index 07a0b93231..ce36fd1bbc 100644 --- a/rust/crates/spicetify/src/update/mod.rs +++ b/rust/crates/spicetify/src/update/mod.rs @@ -18,7 +18,7 @@ use crate::error::Result; // "already up to date". install.sh lists releases for the same reason. const GITHUB_API: &str = "https://api.github.com/repos/spicetify/cli/releases?per_page=30"; -pub async fn check_for_update() -> Result> { +pub async fn check_for_update(arch: &'static str) -> Result> { let client = crate::http::github_client()?; let response = client .get(GITHUB_API) @@ -30,7 +30,7 @@ pub async fn check_for_update() -> Result> { let releases: Vec = response.json().await.context("failed to parse release JSON")?; let current = Version::parse(crate::VERSION).context("invalid current version")?; - Ok(newest_release(releases, ¤t)) + Ok(newest_release(releases, ¤t, arch != std::env::consts::ARCH, arch)) } /// The newest release by semver precedence when it is newer than `current`. @@ -38,18 +38,29 @@ pub async fn check_for_update() -> Result> { /// semver already ranks `3.0.0` above `3.0.0-beta.*`, so once a stable v3 /// exists it wins naturally, and the v2 era's tags lose on major. /// Unparsable tags are skipped. -fn newest_release(releases: Vec, current: &Version) -> Option { +fn newest_release( + releases: Vec, + current: &Version, + migrate_arch: bool, + arch: &str, +) -> Option { releases .into_iter() .filter_map(|release| Version::parse(&release.version()).ok().map(|v| (v, release))) .max_by(|(a, _), (b, _)| a.cmp(b)) - .filter(|(version, _)| version > current) + .filter(|(version, release)| { + version > current + || (migrate_arch + && version == current + && release.find_platform_asset(arch).is_some()) + }) .map(|(_, release)| release) } #[derive(Debug)] pub struct StagedUpdate { pub version: String, + arch: &'static str, new_binary: PathBuf, daemon_binary: Option, staging_dir: PathBuf, @@ -57,10 +68,11 @@ pub struct StagedUpdate { pub async fn download_update( release: &ReleaseInfo, + arch: &'static str, on_progress: impl Fn(u64, u64) + Send + 'static, ) -> Result { - let asset = release.find_platform_asset().ok_or_else(|| { - let names = release::candidate_asset_names(&release.version()); + let asset = release.find_platform_asset(arch).ok_or_else(|| { + let names = release::candidate_asset_names(&release.version(), arch); anyhow::anyhow!("no release asset found for platform; tried: {}", names.join(", ")) })?; @@ -97,13 +109,18 @@ pub async fn download_update( if p.exists() { Some(p) } else { None } }; - Ok(StagedUpdate { version, new_binary, daemon_binary, staging_dir }) + Ok(StagedUpdate { version, arch, new_binary, daemon_binary, staging_dir }) } pub fn install_update(staged: &StagedUpdate) -> Result<()> { let current_exe = std::env::current_exe()?; let install_dir = current_exe.parent().context("exe has no parent dir")?; + // Older x64 updaters stop the daemon before launching this migration. + let restart_daemon = staged.daemon_binary.is_some() + && (crate::daemon::is_daemon_running() + || (staged.arch != std::env::consts::ARCH + && crate::daemon::DaemonManager::create().is_installed())); if let Some(daemon) = &staged.daemon_binary { let daemon_path = install_dir.join(crate::daemon::daemon_binary_name()); if daemon_path.exists() { @@ -121,6 +138,9 @@ pub fn install_update(staged: &StagedUpdate) -> Result<()> { } replace_binary(&staged.new_binary, ¤t_exe).context("failed to replace main binary")?; + if restart_daemon { + crate::daemon::process::spawn().context("failed to restart updated daemon")?; + } if let Err(e) = std::fs::remove_dir_all(&staged.staging_dir) { tracing::warn!(error = %e, "failed to remove staging dir"); @@ -354,6 +374,31 @@ mod tests { } } + #[test] + fn emulated_cli_can_migrate_at_the_current_version() { + let current = Version::parse("3.0.0-beta.20").unwrap(); + let mut published = release("v3.0.0-beta.20"); + published.assets.push(release::ReleaseAsset { + name: release::candidate_asset_names(&published.version(), std::env::consts::ARCH)[0] + .clone(), + browser_download_url: "https://example.com/native.zip".into(), + size: 1, + }); + assert!( + newest_release(vec![published.clone()], ¤t, true, std::env::consts::ARCH) + .is_some() + ); + assert!(newest_release(vec![published], ¤t, false, std::env::consts::ARCH).is_none()); + assert!( + newest_release(vec![release("v3.0.0-beta.20")], ¤t, true, std::env::consts::ARCH) + .is_none() + ); + assert!( + newest_release(vec![release("v3.0.0-beta.19")], ¤t, true, std::env::consts::ARCH) + .is_none() + ); + } + #[test] fn offers_the_newest_prerelease_over_a_stable_v2() { // The /releases/latest endpoint served v2.44.0 to v3 betas, which @@ -363,6 +408,8 @@ mod tests { let picked = newest_release( vec![release("v3.0.0-beta.7"), release("v2.44.0"), release("v3.0.0-beta.6")], ¤t, + false, + std::env::consts::ARCH, ) .expect("an update is offered"); assert_eq!(picked.tag_name, "v3.0.0-beta.7"); @@ -372,16 +419,30 @@ mod tests { fn stays_put_when_nothing_newer_exists() { let current = Version::parse("3.0.0-beta.7").expect("current"); assert!( - newest_release(vec![release("v3.0.0-beta.7"), release("v2.44.0")], ¤t).is_none() + newest_release( + vec![release("v3.0.0-beta.7"), release("v2.44.0")], + ¤t, + false, + std::env::consts::ARCH + ) + .is_none() + ); + assert!( + newest_release(vec![release("not-a-version")], ¤t, false, std::env::consts::ARCH) + .is_none() ); - assert!(newest_release(vec![release("not-a-version")], ¤t).is_none()); } #[test] fn a_stable_v3_wins_over_its_own_prereleases() { let current = Version::parse("3.0.0-beta.7").expect("current"); - let picked = newest_release(vec![release("v3.0.0"), release("v3.0.0-beta.9")], ¤t) - .expect("stable outranks beta"); + let picked = newest_release( + vec![release("v3.0.0"), release("v3.0.0-beta.9")], + ¤t, + false, + std::env::consts::ARCH, + ) + .expect("stable outranks beta"); assert_eq!(picked.tag_name, "v3.0.0"); } } diff --git a/rust/crates/spicetify/src/update/release.rs b/rust/crates/spicetify/src/update/release.rs index 4b58968889..deaada5105 100644 --- a/rust/crates/spicetify/src/update/release.rs +++ b/rust/crates/spicetify/src/update/release.rs @@ -34,8 +34,8 @@ impl ReleaseInfo { } #[must_use] - pub fn find_platform_asset(&self) -> Option<&ReleaseAsset> { - let candidates = candidate_asset_names(&self.version()); + pub fn find_platform_asset(&self, arch: &str) -> Option<&ReleaseAsset> { + let candidates = candidate_asset_names(&self.version(), arch); for name in &candidates { if let Some(asset) = self.assets.iter().find(|a| a.name == *name) { return Some(asset); @@ -57,10 +57,12 @@ impl ReleaseInfo { } #[must_use] -pub fn candidate_asset_names(version: &str) -> Vec { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let ext = if cfg!(windows) { "zip" } else { "tar.zst" }; +pub fn candidate_asset_names(version: &str, arch: &str) -> Vec { + candidate_asset_names_for(version, std::env::consts::OS, arch) +} + +fn candidate_asset_names_for(version: &str, os: &str, arch: &str) -> Vec { + let ext = if os == "windows" { "zip" } else { "tar.zst" }; let mut names = Vec::new(); names.push(format!("spicetify-{version}-{os}-{arch}.{ext}")); @@ -75,6 +77,78 @@ pub fn candidate_asset_names(version: &str) -> Vec { names } +fn preferred_arch( + os: &str, + process_arch: &'static str, + native_machine: Option, + spotify_machine: Option, +) -> &'static str { + if os == "windows" && native_machine == Some(0xaa64) { + if spotify_machine == Some(0x8664) { "x86_64" } else { "aarch64" } + } else { + process_arch + } +} + +#[must_use] +pub fn platform_arch(ctx: &crate::context::AppContext) -> &'static str { + #[cfg(not(windows))] + let _ = ctx; + #[cfg(windows)] + let native_machine = { + use windows::Win32::System::SystemInformation::IMAGE_FILE_MACHINE; + use windows::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + let mut process = IMAGE_FILE_MACHINE::default(); + let mut native = IMAGE_FILE_MACHINE::default(); + // GetNativeSystemInfo reports the emulated architecture on Windows ARM64. + // SAFETY: the current-process pseudo-handle is valid and both outputs are writable. + #[allow(unsafe_code)] + match unsafe { + IsWow64Process2(GetCurrentProcess(), &raw mut process, Some(&raw mut native)) + } { + Ok(()) => Some(native.0), + Err(error) => { + tracing::warn!(%error, "could not detect native Windows architecture"); + None + } + } + }; + #[cfg(not(windows))] + let native_machine = None; + #[cfg(windows)] + let spotify_machine = if native_machine == Some(0xaa64) { + // Store's launch alias is not the PE file; its data directory holds the real binary. + [ctx.spotify_exec.clone(), ctx.spotify_data_dir.join("Spotify.exe")].iter().find_map( + |path| { + let mut file = std::fs::File::open(path).ok()?; + pe_machine(&mut file).ok() + }, + ) + } else { + None + }; + #[cfg(not(windows))] + let spotify_machine = None; + preferred_arch(std::env::consts::OS, std::env::consts::ARCH, native_machine, spotify_machine) +} + +#[cfg(any(windows, test))] +fn pe_machine(reader: &mut (impl std::io::Read + std::io::Seek)) -> std::io::Result { + let mut dos = [0; 64]; + reader.read_exact(&mut dos)?; + if &dos[..2] != b"MZ" { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing DOS header")); + } + let offset = u32::from_le_bytes(dos[60..64].try_into().expect("four-byte PE offset")); + let _ = reader.seek(std::io::SeekFrom::Start(u64::from(offset)))?; + let mut pe = [0; 6]; + reader.read_exact(&mut pe)?; + if &pe[..4] != b"PE\0\0" { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing PE header")); + } + Ok(u16::from_le_bytes([pe[4], pe[5]])) +} + #[must_use] pub fn short_arch_name(arch: &str) -> &str { match arch { @@ -116,6 +190,52 @@ pub enum ChecksumError { mod tests { use super::*; + #[test] + fn emulated_windows_selects_native_arm64_release_assets() { + let arch = preferred_arch("windows", "x86_64", Some(0xaa64), Some(0xaa64)); + let names = candidate_asset_names_for("3.0.0-beta.20", "windows", arch); + assert_eq!(names[0], "spicetify-3.0.0-beta.20-windows-aarch64.zip"); + assert!(!names.iter().any(|name| name.contains("x86_64") || name.contains("x64"))); + } + + #[test] + fn native_windows_and_other_platforms_keep_their_architecture() { + assert_eq!(preferred_arch("windows", "x86_64", Some(0x8664), Some(0x8664)), "x86_64"); + assert_eq!(preferred_arch("windows", "aarch64", Some(0xaa64), Some(0xaa64)), "aarch64"); + assert_eq!(preferred_arch("macos", "x86_64", None, None), "x86_64"); + assert_eq!(preferred_arch("linux", "aarch64", None, None), "aarch64"); + } + + #[test] + fn arm64_windows_preserves_x64_spotify_and_defaults_to_native_before_install() { + for process_arch in ["x86_64", "aarch64"] { + assert_eq!( + preferred_arch("windows", process_arch, Some(0xaa64), Some(0x8664)), + "x86_64" + ); + assert_eq!( + preferred_arch("windows", process_arch, Some(0xaa64), Some(0xaa64)), + "aarch64" + ); + assert_eq!(preferred_arch("windows", process_arch, Some(0xaa64), None), "aarch64"); + } + } + + #[test] + fn spotify_architecture_comes_from_the_pe_header() { + for machine in [0xaa64u16, 0x8664] { + let mut bytes = vec![0; 134]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[60..64].copy_from_slice(&128u32.to_le_bytes()); + bytes[128..132].copy_from_slice(b"PE\0\0"); + bytes[132..134].copy_from_slice(&machine.to_le_bytes()); + assert_eq!(pe_machine(&mut std::io::Cursor::new(&bytes)).unwrap(), machine); + bytes[128] = 0; + assert!(pe_machine(&mut std::io::Cursor::new(&bytes)).is_err()); + } + assert!(pe_machine(&mut std::io::Cursor::new(b"not an executable")).is_err()); + } + #[test] fn deserializes_the_real_release_list_shape() { // Trimmed from the live GitHub API: workflow-cut releases carry diff --git a/scripts/classmap-capture.test.mts b/scripts/classmap-capture.test.mts new file mode 100644 index 0000000000..275e6dfdc4 --- /dev/null +++ b/scripts/classmap-capture.test.mts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { describe, test } from "node:test"; + +import { + allRules, + classSignatures, + confidenceLabel, + flattenClassmap, + isHashLike, + migrateClassmap, + pyRound, + semanticFit, + splitRules, + verifyClassmap, + versionToKey, +} from "./classmap-capture.ts"; + +const map = (entries: Record) => new Map(Object.entries(entries)); + +describe("isHashLike", () => { + test("accepts real hashes", () => { + // Short real hash from the 1.2.45 fixture (play button). + for (const token of ["cLkUmr", "AbCdEfGh", "Xy1_abC", "aB3d"]) assert.ok(isHashLike(token), token); + }); + + test("rejects semantic and Encore classes", () => { + for (const token of ["main-topbar", "spotify-play-button", "encore-text", "abc", "alllower", "A".repeat(26)]) { + assert.ok(!isHashLike(token), token); + } + }); +}); + +describe("rules and signatures", () => { + const css = ` + .cLkUmr, .other_Class { color: red; padding: 4px; } + /* comment { ignored } */ + .main-topbar { --custom: 1; background: blue; } + @media (min-width: 100px) { .ignoredAtRule { color: green; } } + `; + + test("splits rules and skips at-rules", () => { + const selectors = splitRules(css).map(([selector]) => selector); + assert.ok(selectors.some((s) => s.includes("cLkUmr"))); + assert.ok(!selectors.some((s) => s.startsWith("@"))); + }); + + test("finds rules nested in grouping at-rules, but not in keyframes", () => { + const layered = + "@layer encore;@layer encore{.e-1-button{color:red}@media (min-width:1px){.e-1-icon{margin:0}}}@keyframes spin{from{opacity:0}}.after{color:blue}"; + assert.deepEqual( + allRules(layered).map(([selector]) => selector), + [".e-1-button", ".e-1-icon", ".after"], + ); + }); + + test("verify finds a class that only appears inside @layer", () => { + const { rows } = verifyClassmap({ settings: { text_input: "e-10860-form-input" } }, "@layer encore{.e-10860-form-input{color:red}}", new Map()); + assert.equal(rows[0].in_target_css, true); + }); + + test("signatures skip CSS variables", () => { + const sigs = classSignatures(".foo { --x: 1; color: red; }"); + const props = [...(sigs.get("foo")?.values() ?? [])].flatMap((sig) => [...sig].map((pair) => pair.split("\0")[0])); + assert.deepEqual(props, ["color"]); + }); +}); + +describe("semanticFit false friends", () => { + test("rejects actionBar for the playbar", () => assert.equal(semanticFit(["main", "playbar", "controls"], "actionBar"), 0)); + test("accepts nowPlayingBar for the playbar", () => assert.ok(semanticFit(["main", "playbar", "controls"], "nowPlayingBar") > 0)); + test("a close leaf requires close", () => { + assert.equal(semanticFit(["main", "topbar", "close"], "topBarButton"), 0); + assert.ok(semanticFit(["main", "topbar", "close"], "topBarCloseBtn") > 0); + }); + test("an empty semantic scores zero", () => assert.equal(semanticFit(["a", "b"], ""), 0)); +}); + +test("versionToKey pads minor and patch", () => { + assert.equal(versionToKey("1.2.45"), "1020045"); + assert.equal(versionToKey("1.2.93"), "1020093"); + assert.equal(versionToKey("1.2.8"), "1020008"); + assert.equal(versionToKey("1.3.1.234"), "1030001"); +}); + +test("pyRound matches Python's round on exact binary halves", () => { + assert.equal(pyRound(0.03125, 4), 0.0312); + assert.equal(pyRound(0.09375, 4), 0.0938); + assert.equal(pyRound(1 / 3, 4), 0.3333); + assert.equal(pyRound(0.00015, 4), 0.0001); +}); + +describe("verifyClassmap", () => { + test("a multi-class leaf is present when every token exists", () => { + const { rows } = verifyClassmap( + { settings: { text_input: "formInputAA formControlBB encore-text-body-medium" } }, + ".formInputAA { color: red; } .formControlBB { color: blue; } .encore-text-body-medium { font-size: 1rem; }", + new Map(), + ); + assert.equal(rows[0].in_target_css, true); + assert.deepEqual(rows[0].missing_classes, []); + assert.equal(rows[0].selector_hits, 3); + }); + + test("a multi-class leaf reports only the missing tokens", () => { + const { rows } = verifyClassmap({ settings: { text_input: "formInputAA missingControlBB" } }, ".formInputAA { color: red; }", new Map()); + assert.equal(rows[0].in_target_css, false); + assert.deepEqual(rows[0].missing_classes, ["missingControlBB"]); + assert.equal(rows[0].verdict, "missing_in_css"); + }); + + test("a multi-class leaf reports each token's semantic", () => { + const { rows } = verifyClassmap( + { settings: { text_input: "formInputAA formControlBB" } }, + ".formInputAA { color: red; } .formControlBB { color: blue; }", + map({ formInputAA: "x-settings-input", formControlBB: "x-settings-control" }), + ); + assert.equal(rows[0].in_css_map, true); + assert.deepEqual(rows[0].semantics, ["x-settings-input", "x-settings-control"]); + }); + + test("the static report records the target build and CSS digest", () => { + const css = ".topbarHashAA { color: red; }"; + const { target } = verifyClassmap({ main: { topbar: { wrapper: "topbarHashAA" } } }, css, new Map(), null, "1.2.96.518"); + assert.equal(target.spotify_version, "1.2.96.518"); + assert.equal(target.css_sha256, crypto.createHash("sha256").update(css).digest("hex")); + }); +}); + +describe("migrateClassmap", () => { + const baseCss = ".oldHashAA { color: red; padding: 4px; } .keepMe99 { margin: 0; }"; + const targetCss = ".newHashBB { color: red; padding: 4px; } .keepMe99 { margin: 0; }"; + const migrate = (baseMap: Record, cssMap: Map) => + migrateClassmap(baseMap, classSignatures(baseCss), classSignatures(targetCss), cssMap, 0.5); + + test("keeps a class that survived unchanged", () => { + const [out, report] = migrate({ main: { widget: "keepMe99" } }, new Map()); + assert.deepEqual(out, { main: { widget: "keepMe99" } }); + assert.equal(report.stats.identity, 1); + assert.equal(report.identity[0].method, "identity"); + assert.equal(confidenceLabel(report.identity[0]), "high"); + }); + + test("follows CSS evidence to the renamed class", () => { + const [out, report] = migrate({ main: { widget: "oldHashAA" } }, new Map()); + assert.deepEqual(out, { main: { widget: "newHashBB" } }); + assert.equal(report.stats.matched, 1); + }); + + test("marks an unmatched leaf stale and keeps its old hash", () => { + const [out, report] = migrate({ main: { widget: "notInBase1" } }, new Map()); + // Old hash kept for incremental re-runs, but explicitly marked stale. + assert.deepEqual(out, { main: { widget: "notInBase1" } }); + assert.equal(report.stats.unmatched, 1); + assert.equal(report.stats.stale_kept, 1); + assert.equal(report.unmatched[0].stale, true); + assert.equal(report.unmatched[0].kept, "notInBase1"); + }); + + test("a semantic-only match requires the class in the target CSS", () => { + // css-map claims a great semantic fit, but the class does not exist in the target CSS at all. + const [out, report] = migrate({ main: { playbar: { controls: "notInBase1" } } }, map({ ghostHash1: "nowPlayingBarControls" })); + assert.deepEqual(out, { main: { playbar: { controls: "notInBase1" } } }); + assert.equal(report.stats.matched, 0); + }); + + test("rejects a CSS match that ties exactly with another class", () => { + const [out, report] = migrateClassmap( + { main: { widget: "oldHashAA" } }, + classSignatures(baseCss), + classSignatures(".newHashBB { color: red; padding: 4px; } .twinHashCC { color: red; padding: 4px; }"), + new Map(), + 0.5, + ); + assert.deepEqual(out, { main: { widget: "oldHashAA" } }); + const best = report.unmatched[0].best as Record; + assert.equal(best.reason, "tied with 1 other candidate(s)"); + assert.deepEqual(best.tied, ["twinHashCC"]); + }); + + test("rejects a semantic-only match that ties with another class", () => { + const [out, report] = migrateClassmap( + { main: { playbar: { controls: "notInBase1" } } }, + new Map(), + classSignatures(".newHashBB { color: red; } .twinHashCC { margin: 0; }"), + map({ newHashBB: "nowPlayingBarControls", twinHashCC: "nowPlayingBarControlsAlt" }), + 0.5, + ); + assert.deepEqual(out, { main: { playbar: { controls: "notInBase1" } } }); + assert.deepEqual(report.unmatched[0].tied, ["newHashBB", "twinHashCC"]); + assert.equal(report.unmatched[0].stale, true); + }); + + test("a semantic-only match has low confidence", () => { + const [out, report] = migrateClassmap( + { main: { playbar: { controls: "notInBase1" } } }, + new Map(), + classSignatures(".newHashBB { color: red; }"), + map({ newHashBB: "nowPlayingBarControls" }), + 0.5, + ); + assert.deepEqual(out, { main: { playbar: { controls: "newHashBB" } } }); + assert.equal(report.matched[0].method, "semantic-only"); + assert.equal(confidenceLabel(report.matched[0]), "low"); + }); +}); + +describe("flattenClassmap", () => { + const base = { main: { topbar: { wrapper: "oldHashAA11" } }, settings: { button: { wrapper: "oldHashBB22" } } }; + const cssMap = map({ oldHashAA11: "main-topBar-topbarContent", newHashCC33: "x-settings-button" }); + const report = { + matched: [{ path: "settings.button.wrapper", new: "newHashCC33", semantic: "x-settings-button" }], + unmatched: [{ path: "main.topbar.icon", old: "staleHash99", stale: true }], + identity: [], + }; + + test("the base hash's css-map name wins", () => { + const [overlay, skipped] = flattenClassmap({ main: { topbar: { wrapper: "newHashAA44" } } }, base, cssMap, null, null); + assert.deepEqual(overlay, { newHashAA44: "main-topBar-topbarContent" }); + assert.deepEqual(skipped, []); + }); + + test("skips a hash the global css-map already renames", () => { + const [overlay] = flattenClassmap({ settings: { button: { wrapper: "newHashCC33" } } }, base, cssMap, report, null); + assert.deepEqual(overlay, {}); + }); + + test("falls back to the report's semantic for hand-edited leaves", () => { + const handEdited = { matched: [{ path: "settings.header.container", semantic: "x-settings-header" }], unmatched: [], identity: [] }; + const [overlay] = flattenClassmap({ settings: { header: { container: "newHashHH77" } } }, base, cssMap, handEdited, null); + assert.deepEqual(overlay, { newHashHH77: "x-settings-header" }); + }); + + test("excludes stale leaves from the report and META", () => { + assert.deepEqual(flattenClassmap({ main: { topbar: { icon: "staleHash99" } } }, base, cssMap, report, null)[0], {}); + const meta = { stale_leaves: ["main.topbar.wrapper"] }; + assert.deepEqual(flattenClassmap({ main: { topbar: { wrapper: "newHashAA44" } } }, base, cssMap, null, meta)[0], {}); + }); + + test("skips an unresolvable leaf with a warning", () => { + const [overlay, skipped] = flattenClassmap({ main: { mystery: { leaf: "newHashZZ99" } } }, base, cssMap, null, null); + assert.deepEqual(overlay, {}); + assert.equal(skipped.length, 1); + }); + + test("excludes unverified leaves and warns about an unverified required path", () => { + const meta = { unverified_leaves: ["settings.button.wrapper"], required_paths: { "settings.button.wrapper": "unverified" } }; + const semantic = { matched: [{ path: "settings.button.wrapper", semantic: "x-settings-button" }], unmatched: [], identity: [] }; + const [overlay, skipped] = flattenClassmap({ settings: { button: { wrapper: "newHashUU55" } } }, base, cssMap, semantic, meta); + assert.deepEqual(overlay, {}); + assert.ok(skipped.some((s) => s.includes("required path"))); + }); + + test("keeps the first of two conflicting names and warns", () => { + const [overlay, skipped] = flattenClassmap( + { a: { leaf: "sharedHash1" }, b: { leaf: "sharedHash1" } }, + { a: { leaf: "oldA1" }, b: { leaf: "oldB1" } }, + map({ oldA1: "name-a", oldB1: "name-b" }), + null, + null, + ); + assert.deepEqual(overlay, { sharedHash1: "name-a" }); + assert.ok(skipped.some((s) => s.includes("conflicting"))); + }); +}); diff --git a/scripts/classmap-capture.ts b/scripts/classmap-capture.ts new file mode 100644 index 0000000000..fb420491b4 --- /dev/null +++ b/scripts/classmap-capture.ts @@ -0,0 +1,1061 @@ +#!/usr/bin/env node +/** + * Capture / migrate / verify Spicetify classmaps between Spotify CSS builds. + * + * Requires Node.js >= 24 (runs as TypeScript through type stripping). + * + * Commands + * -------- + * inventory List class tokens from CSS/spa + * migrate Migrate a classmap (CSS signatures + optional css-map.json) + * verify Score a classmap or migrate report against css-map + live CSS + * flatten Bridge a classmap into a flat css-map overlay + * key Print classmap folder key for a version + * devtools Print DevTools snippets to manually verify matched paths + * + * css-map.json signal + * ------------------- + * The CLI css-map maps *current* hashed classes -> stable semantic names + * (e.g. main-topBar-container). During migrate we: + * 1. Prefer target candidates that appear as keys in css-map + * 2. Boost candidates whose semantic name token-overlaps the classmap path + * 3. Penalize / reject high CSS similarity when semantic tokens conflict + * + * Examples + * -------- + * node scripts/classmap-capture.ts migrate \ + * --base-classmap ../classmaps/1030001/classmap.json \ + * --base-spa /path/to/1.3.1/xpui.spa \ + * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ + * --css-map css-map.json \ + * --out classmaps/1020092/classmap.json \ + * --report classmaps/1020092/report.json \ + * --allow-partial + * + * node scripts/classmap-capture.ts verify \ + * --classmap classmaps/1020092/classmap.json \ + * --report classmaps/1020092/report.json \ + * --css-map css-map.json \ + * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ + * --target-version 1.2.92.148 \ + * --out classmaps/1020092/verify.json + * + * node scripts/classmap-capture.ts devtools --report classmaps/1020092/report.json + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import zlib from "node:zlib"; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type Classmap = { [key: string]: Classmap | string }; +type Row = Record; +type Report = { matched?: Row[]; identity?: Row[]; unmatched?: Row[]; stats?: Row }; +/** A rule body as a set of `prop\0value` pairs. */ +type Signature = Set; +/** Class -> its distinct signatures, keyed by their canonical form. */ +type Signatures = Map>; + +const PROP_RE = /([a-zA-Z-]+)\s*:\s*([^;]+);/g; +const CLASS_RE = /\.([A-Za-z0-9_-]+)/g; +const SKIPPED_PROPS = new Set(["content", "src", "animation-name"]); + +/** Python's round(): the exact binary value, halves to even. */ +export function pyRound(x: number, digits: number): number { + // toFixed accepts up to 100 digits, enough for the exact decimal expansion that decides a half. + // oxlint-disable-next-line number-arg-out-of-range + const [int, frac] = Math.abs(x).toFixed(100).split("."); + let n = BigInt(int + frac.slice(0, digits)); + const rest = frac.slice(digits); + const half = "5".padEnd(rest.length, "0"); + if (rest > half || (rest === half && n % 2n === 1n)) n += 1n; + const out = Number(n) / 10 ** digits; + return x < 0 ? -out : out; +} + +function isLowerCased(token: string): boolean { + return token.toLowerCase() === token && token.toUpperCase() !== token; +} + +export function isHashLike(token: string): boolean { + // Min length 4: real Spotify hashes can be short (e.g. the 1.2.45 + // play button class "cLkUmr" is 6 chars). Semantic/Encore classes are + // filtered by the lowercase-dash and prefix rules below, and matchers + // still require CSS or semantic evidence, so a low bound is safe here. + const length = Array.from(token).length; + if (length < 4 || length > 25) return false; + if (token.includes("-") && isLowerCased(token)) return false; + if (token.startsWith("spotify") || token.startsWith("encore")) return false; + const hasUpper = /\p{Lu}/u.test(token); + const hasLower = /\p{Ll}/u.test(token); + const hasDigit = /\p{Nd}/u.test(token); + if (hasUpper && hasLower) return true; + return token.includes("_") && (hasDigit || hasUpper); +} + +/** Decodes UTF-8 dropping invalid bytes, as Python's errors="ignore" does. */ +function decodeIgnoringErrors(buf: Buffer): string { + const text = buf.toString("utf8"); + return buf.includes(Buffer.from([0xef, 0xbf, 0xbd])) ? text : text.replaceAll("�", ""); +} + +/** Reads every `.css` entry of a zip archive in central-directory order. */ +function readZipCss(file: string): string[] { + const zip = fs.readFileSync(file); + let end = zip.length - 22; + while (end >= 0 && zip.readUInt32LE(end) !== 0x06054b50) end--; + if (end < 0) throw new Error(`${file}: not a zip archive`); + const count = zip.readUInt16LE(end + 10); + let offset = zip.readUInt32LE(end + 16); + const chunks: string[] = []; + for (let i = 0; i < count; i++) { + if (zip.readUInt32LE(offset) !== 0x02014b50) throw new Error(`${file}: corrupt central directory`); + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localOffset = zip.readUInt32LE(offset + 42); + const name = zip.toString("utf8", offset + 46, offset + 46 + nameLength); + offset += 46 + nameLength + extraLength + commentLength; + if (!name.endsWith(".css")) continue; + const dataStart = localOffset + 30 + zip.readUInt16LE(localOffset + 26) + zip.readUInt16LE(localOffset + 28); + const data = zip.subarray(dataStart, dataStart + compressedSize); + if (method === 0) chunks.push(decodeIgnoringErrors(data)); + else if (method === 8) chunks.push(decodeIgnoringErrors(zlib.inflateRawSync(data))); + else throw new Error(`${file}: ${name} uses unsupported compression method ${method}`); + } + return chunks; +} + +/** Orders paths segment by segment, as Python sorts pathlib paths. */ +function comparePaths(a: string, b: string): number { + const pa = a.split(path.sep); + const pb = b.split(path.sep); + for (let i = 0; i < Math.min(pa.length, pb.length); i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return pa.length - pb.length; +} + +function cssFilesUnder(dir: string): string[] { + return (fs.readdirSync(dir, { recursive: true, encoding: "utf8" }) as string[]) + .filter((name) => name.endsWith(".css")) + .sort(comparePaths) + .map((name) => path.join(dir, name)) + .filter((file) => fs.statSync(file).isFile()); +} + +/** Reads a loose CSS file with the newline translation of Python's read_text. */ +function readTextFile(file: string): string { + return decodeIgnoringErrors(fs.readFileSync(file)).replace(/\r\n?/g, "\n"); +} + +export function readCssSources(spa?: string, cssDir?: string, cssFiles: string[] = []): string { + const chunks: string[] = []; + if (spa) chunks.push(...readZipCss(spa)); + if (cssDir) for (const file of cssFilesUnder(cssDir)) chunks.push(readTextFile(file)); + for (const file of cssFiles) chunks.push(readTextFile(file)); + if (!chunks.length) throw new Error("no CSS sources provided (use --spa, --css-dir, or --css)"); + return chunks.join("\n"); +} + +export function splitRules(source: string): [string, string][] { + const css = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const rules: [string, string][] = []; + let i = 0; + const n = css.length; + while (i < n) { + const start = css.indexOf("{", i); + if (start < 0) break; + const selector = css.slice(i, start).trim(); + let depth = 1; + let j = start + 1; + while (j < n && depth) { + if (css[j] === "{") depth++; + else if (css[j] === "}") depth--; + j++; + } + const body = css.slice(start + 1, j - 1); + if (selector && !selector.startsWith("@")) rules.push([selector, body]); + i = j; + } + return rules; +} + +const GROUPING_AT_RULE = /^@(?:layer|media|supports|container|scope|document)\b/; + +/** + * Every style rule, including those nested in grouping at-rules such as + * `@layer encore { ... }`, where Spotify ships its Encore component styles. + * A statement at-rule (`@layer a, b;`) ends at its semicolon. + */ +export function allRules(source: string): [string, string][] { + const rules: [string, string][] = []; + for (const [prelude, body] of splitBlocks(source.replace(/\/\*[\s\S]*?\*\//g, ""))) { + if (GROUPING_AT_RULE.test(prelude)) rules.push(...allRules(body)); + else if (!prelude.startsWith("@")) rules.push([prelude, body]); + } + return rules; +} + +function splitBlocks(css: string): [string, string][] { + const blocks: [string, string][] = []; + let i = 0; + while (i < css.length) { + const start = css.indexOf("{", i); + if (start < 0) break; + const prelude = css.slice(i, start); + let depth = 1; + let j = start + 1; + while (j < css.length && depth) { + if (css[j] === "{") depth++; + else if (css[j] === "}") depth--; + j++; + } + const selector = prelude.slice(prelude.lastIndexOf(";") + 1).trim(); + if (selector) blocks.push([selector, css.slice(start + 1, j - 1)]); + i = j; + } + return blocks; +} + +function normalizeProps(body: string): Signature { + const props: Signature = new Set(); + for (const m of body.matchAll(PROP_RE)) { + const prop = m[1].trim().toLowerCase(); + const value = m[2].trim().toLowerCase().replace(/\s+/g, " "); + if (prop.startsWith("--") || SKIPPED_PROPS.has(prop)) continue; + props.add(`${prop}\0${value}`); + } + return props; +} + +const signatureKey = (sig: Signature) => [...sig].sort().join("\u0001"); + +export function classSignatures(css: string): Signatures { + const out: Signatures = new Map(); + for (const [selector, body] of splitRules(css)) { + const props = normalizeProps(body); + if (!props.size) continue; + const key = signatureKey(props); + for (const m of selector.matchAll(CLASS_RE)) { + let sigs = out.get(m[1]); + if (!sigs) out.set(m[1], (sigs = new Map())); + sigs.set(key, props); + } + } + return out; +} + +export function inventoryClasses(css: string): Map { + const counts = new Map(); + for (const [selector] of allRules(css)) { + for (const m of selector.matchAll(CLASS_RE)) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1); + } + return new Map([...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))); +} + +function jaccard(a: Signature, b: Signature): number { + if (!a.size && !b.size) return 1; + if (!a.size || !b.size) return 0; + let inter = 0; + for (const item of a) if (b.has(item)) inter++; + const union = a.size + b.size - inter; + return union ? inter / union : 0; +} + +export function loadCssMap(file?: string | null): Map { + if (!file) return new Map(); + const data = JSON.parse(fs.readFileSync(file, "utf8")); + if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("css-map must be a JSON object"); + return new Map(Object.entries(data).map(([k, v]) => [String(k), String(v)])); +} + +// Path segment -> semantic tokens expected in css-map values. +const PATH_HINTS: Record = { + topbar: ["topbar", "topbarcontent", "globalnav"], + playbar: ["nowplayingbar", "playercontrols", "playbackbar"], + navbar: ["navbar", "navlink", "navitem", "yourlibraryx"], + search_chips: ["searchcategory", "chip", "filterchips"], + search_box: ["search", "filterbox", "searchinput", "globalnav"], + widget_generator: ["embedwidgetgenerator", "embedwidget"], + track_credits: ["trackcredits"], + settings: ["settings", "desktopsettings"], + sort_box: ["sortbox", "sortdropdown", "sort"], + scrollable_text: ["marquee"], + context_menu: ["contextmenu"], + tracklist: ["tracklist"], + chip: ["chip"], + close: ["closebtn", "close"], + menu_item: ["menuitem", "contextmenu"], + expand_button: ["expandbutton", "searchinput"], + upgrade_button: ["upgradebutton", "upgrade"], + indicator: ["indicator"], +}; + +// Region segment -> required substrings in the lowercased full semantic name. +// Matching is against the full css-map value, not tokenized pieces, so +// "nowPlayingBar" and "actionBar" stay distinct. +const REGION_REQUIRED: Record = { + topbar: ["topbar", "globalnav"], + playbar: ["nowplayingbar", "player-controls", "playercontrols", "playbackbar"], + navbar: ["navbar", "navlink", "navitem", "yourlibraryx", "mainnav"], + track_credits: ["trackcredits"], + widget_generator: ["embedwidget"], + settings: ["settings"], + tracklist: ["tracklist"], + search_chips: ["search", "chip", "filterchip"], + search_box: ["search", "filterbox", "globalnav"], + context_menu: ["contextmenu"], + sort_box: ["sort"], +}; + +// If path contains the region, reject semantics that match these substrings. +const REGION_FORBIDDEN: Record = { + playbar: ["actionbar", "tracklist", "watchfeed", "entityheader", "rowplay", "rowimage"], + topbar: ["actionbar", "entityheader", "watchfeed"], + settings: ["equalizer", "nowplayingview", "actionbar"], + widget_generator: ["watchfeed", "filterbox", "sortbox"], + search_chips: ["nowplaying", "actionbar", "tracklist"], + search_box: ["yourlibrary", "actionbar", "tracklist"], + navbar: ["topbar", "nowplaying", "actionbar"], + tracklist: ["actionbar", "nowplayingbar", "globalnav"], + context_menu: ["settings", "equalizer", "sortbox"], +}; + +// Leaf segment requirements against full semantic string. +const LEAF_REQUIRED: Record = { + close: ["close"], + content: ["content", "body", "iframe", "code"], + header: ["header", "title"], + chip: ["chip"], + indicator: ["indicator", "active", "playing"], + expand_button: ["expand", "search", "filter", "clear"], +}; + +// Leaf forbidden when this leaf is the path tail. +const LEAF_FORBIDDEN: Record = { + content: ["close"], // content container must not resolve to closeBtn only +}; + +function lookup(table: Record, key: string): T | undefined { + return Object.hasOwn(table, key) ? table[key] : undefined; +} + +const GENERIC_SEGMENTS = new Set(["main", "wrapper", "container", "button", "left", "right", "list", "button_t"]); + +/** Lowercase and strip separators so nowPlayingBar -> nowplayingbar. */ +function normalizeSemantic(name: string): string { + return name.replace(/[-_.\s]+/g, "").toLowerCase(); +} + +function pathSegments(parts: string[]): string[] { + return parts.map((seg) => seg.split("__")[0]); +} + +/** + * Return 0..1 agreement between a nested classmap path and a css-map name. + * + * Uses full-string substring checks (not bag-of-tokens) so ActionBar cannot + * satisfy a playbar requirement meant for nowPlayingBar. + */ +export function semanticFit(parts: string[], semantic: string): number { + if (!semantic) return 0; + const segs = pathSegments(parts); + const full = normalizeSemantic(semantic); + const rawLower = semantic.toLowerCase(); + + for (const seg of segs) { + const forbidden = lookup(REGION_FORBIDDEN, seg); + if (forbidden?.some((f) => full.includes(f))) return 0; + const required = lookup(REGION_REQUIRED, seg); + if (required && !required.some((r) => full.includes(r.replaceAll("-", "")) || rawLower.includes(r))) return 0; + } + + for (const [i, seg] of segs.entries()) { + const isTail = i === segs.length - 1; + // For non-tail "content", still require content-ish names + if (seg === "content") { + if (!["content", "body", "iframe", "code"].some((x) => full.includes(x))) return 0; + // pure close button is never a content node + if (full.includes("close") && !full.includes("content")) return 0; + } + if (isTail) { + const required = lookup(LEAF_REQUIRED, seg); + if (required && !required.some((r) => full.includes(r))) return 0; + const forbidden = lookup(LEAF_FORBIDDEN, seg); + if (forbidden?.some((f) => full.includes(f)) && !(required ?? []).some((r) => full.includes(r))) return 0; + } + } + + // Score: count how many specific path segments are reflected in the name. + let specific = segs.filter((s) => !GENERIC_SEGMENTS.has(s)); + if (!specific.length) specific = segs.slice(-1); + + let hits = 0; + for (const s of specific) { + const hints = new Set([...(lookup(PATH_HINTS, s) ?? [s.replaceAll("_", "")]), s.replaceAll("_", ""), s]); + if ([...hints].some((h) => Array.from(h).length >= 3 && (full.includes(normalizeSemantic(h)) || rawLower.includes(h.toLowerCase())))) { + hits++; + } + } + if (!hits) return 0; + return Math.min(1, hits / Math.max(1, specific.length)); +} + +function pathHintTokens(parts: string[]): Set { + const hints = new Set(); + for (const seg of pathSegments(parts)) { + hints.add(seg.toLowerCase().replaceAll("-", "").replaceAll("_", "")); + for (const hint of lookup(PATH_HINTS, seg) ?? []) hints.add(hint); + } + return hints; +} + +function cssSimilarity(src?: Map, tgt?: Map): number { + if (!src?.size || !tgt?.size) return 0; + let best = 0; + for (const s of src.values()) for (const t of tgt.values()) best = Math.max(best, jaccard(s, t)); + return best; +} + +const hasStrictRegion = (parts: string[]) => pathSegments(parts).some((seg) => Object.hasOwn(REGION_REQUIRED, seg)); + +function scoreCandidate(parts: string[], cand: string, src: Map, target: Signatures, cssMap: Map): Row { + const cssScore = cssSimilarity(src, target.get(cand)); + const inMap = cssMap.has(cand); + const semantic = cssMap.get(cand) ?? ""; + const semScore = semantic ? semanticFit(parts, semantic) : 0; + + // Combined score: css similarity is base; css-map presence and semantic agreement boost. + let score = cssScore; + if (inMap && semScore > 0) score += 0.15; + if (semScore > 0) score += 0.45 * semScore; + else { + // css-map hit with zero semantic fit is usually a false friend. + if (inMap && cssScore >= 0.5) score -= 0.35; + // For strict regions (playbar/topbar/...), refuse pure CSS matches + // without semantic agreement — that is how actionBar leaks in. + if (hasStrictRegion(parts)) score = Math.min(score, cssScore * 0.25); + } + + return { + class: cand, + score: pyRound(Math.max(0, Math.min(score, 1.5)), 4), + css_score: pyRound(cssScore, 4), + semantic_score: pyRound(semScore, 4), + in_css_map: inMap, + semantic: semantic || null, + }; +} + +function bestMatch( + parts: string[], + src: Map, + target: Signatures, + targetIndex: Map>, + cssMap: Map, + threshold: number, +): Row | null { + if (!src.size) return null; + + const candidates = new Set(); + for (const key of src.keys()) for (const cls of targetIndex.get(key) ?? []) candidates.add(cls); + + // Always consider css-map keys that have decent semantic overlap as + // candidates, but only when they actually exist in the target CSS: + // a css-map entry with zero target presence has no evidence behind it. + if (pathHintTokens(parts).size && cssMap.size) { + for (const [h, sem] of cssMap) { + if (isHashLike(h) && target.has(h) && semanticFit(parts, sem) >= 0.5) candidates.add(h); + } + } + + // Candidates are visited in name order so ties rank the same on every run. + const pool = [...(candidates.size ? candidates : target.keys())].sort(); + const scored = pool.filter((cand) => isHashLike(cand) && target.has(cand)).map((cand) => scoreCandidate(parts, cand, src, target, cssMap)); + if (!scored.length) return null; + const num = (row: Row, field: string) => row[field] as number; + scored.sort( + (a, b) => num(b, "score") - num(a, "score") || num(b, "semantic_score") - num(a, "semantic_score") || num(b, "css_score") - num(a, "css_score"), + ); + const best = scored[0]; + // Require either solid css match or solid semantic agreement. + // Strict regions need semantic_score > 0 to avoid actionBar/etc. leaks. + if (num(best, "score") < threshold) return { ...best, rejected: true, next: scored.slice(1, 4) }; + if (hasStrictRegion(parts) && num(best, "semantic_score") <= 0) { + return { ...best, rejected: true, reason: "strict region requires semantic agreement", next: scored.slice(1, 4) }; + } + if (num(best, "semantic_score") <= 0 && num(best, "css_score") < 0.75) return { ...best, rejected: true, next: scored.slice(1, 4) }; + // An exact tie gives no evidence for either class, so the leaf is left for a human to decide. + const same = (row: Row) => ["score", "semantic_score", "css_score"].every((field) => row[field] === best[field]); + const tied = scored + .slice(1) + .filter(same) + .map((row) => row.class as string); + if (tied.length) return { ...best, rejected: true, reason: `tied with ${tied.length} other candidate(s)`, tied, next: scored.slice(1, 4) }; + return { ...best, alternatives: scored.slice(1, 4) }; +} + +function buildTargetIndex(target: Signatures): Map> { + const index = new Map>(); + for (const [cls, sigs] of target) { + for (const key of sigs.keys()) { + let classes = index.get(key); + if (!classes) index.set(key, (classes = new Set())); + classes.add(cls); + } + } + return index; +} + +export function iterLeaves(node: unknown, parts: string[] = []): [string[], string][] { + if (typeof node === "string") return [[parts, node]]; + if (typeof node !== "object" || node === null || Array.isArray(node)) return []; + return Object.entries(node).flatMap(([key, value]) => iterLeaves(value, [...parts, key])); +} + +function setLeaf(root: Classmap, parts: string[], value: string): void { + let cur = root; + for (const key of parts.slice(0, -1)) { + const next = cur[key]; + if (typeof next === "object") cur = next; + else cur = cur[key] = {}; + } + cur[parts.at(-1) as string] = value; +} + +export function migrateClassmap( + baseMap: Classmap, + baseSigs: Signatures, + targetSigs: Signatures, + cssMap: Map, + threshold: number, +): [Classmap, Required] { + const targetIndex = buildTargetIndex(targetSigs); + const out: Classmap = structuredClone(baseMap); + const report: Required = { matched: [], unmatched: [], identity: [], stats: {} }; + + const leaves = iterLeaves(baseMap); + let matched = 0; + let identity = 0; + for (const [parts, oldHash] of leaves) { + const dotted = parts.join("."); + + if (targetSigs.has(oldHash) && isHashLike(oldHash)) { + setLeaf(out, parts, oldHash); + report.identity.push({ + path: dotted, + class: oldHash, + in_css_map: cssMap.has(oldHash), + semantic: cssMap.get(oldHash) ?? null, + method: "identity", + }); + identity++; + matched++; + continue; + } + + const src = baseSigs.get(oldHash); + if (!src?.size) { + // No base CSS signature for the old class. Fall back to a + // semantic-only match via css-map, with a higher bar (0.75) + // because there is zero CSS similarity evidence behind it. + let bestSem: [string, string, number] | null = null; + let bestScore = 0; + let tied: string[] = []; + for (const [h, sem] of cssMap) { + if (!targetSigs.has(h) || !isHashLike(h)) continue; + const s = semanticFit(parts, sem); + if (s > bestScore) { + bestScore = s; + bestSem = [h, sem, s]; + tied = []; + } else if (s === bestScore && bestSem) { + tied.push(h); + } + } + if (bestSem && bestScore >= 0.75 && tied.length) { + report.unmatched.push({ + path: dotted, + old: oldHash, + reason: `${tied.length + 1} semantic-only candidates tie`, + tied: [bestSem[0], ...tied], + stale: true, + kept: oldHash, + }); + setLeaf(out, parts, oldHash); + continue; + } + if (bestSem && bestScore >= 0.75) { + const [newCls, sem, s] = bestSem; + setLeaf(out, parts, newCls); + report.matched.push({ + path: dotted, + old: oldHash, + new: newCls, + score: pyRound(0.2 + 0.5 * s, 4), + css_score: 0, + semantic_score: pyRound(s, 4), + in_css_map: true, + semantic: sem, + method: "semantic-only", + css_evidence: false, + }); + matched++; + continue; + } + report.unmatched.push({ path: dotted, old: oldHash, reason: "old class not found in base CSS", stale: true, kept: oldHash }); + setLeaf(out, parts, oldHash); + continue; + } + + const best = bestMatch(parts, src, targetSigs, targetIndex, cssMap, threshold); + if (!best || best.rejected) { + report.unmatched.push({ path: dotted, old: oldHash, reason: "no confident target", best, stale: true, kept: oldHash }); + setLeaf(out, parts, oldHash); + continue; + } + + setLeaf(out, parts, best.class as string); + report.matched.push({ + path: dotted, + old: oldHash, + new: best.class, + score: best.score, + css_score: best.css_score, + semantic_score: best.semantic_score, + in_css_map: best.in_css_map, + semantic: best.semantic, + method: "css+semantic", + alternatives: best.alternatives ?? [], + }); + matched++; + } + + report.stats = { + leaves: leaves.length, + matched, + identity, + migrated: matched - identity, + unmatched: leaves.length - matched, + stale_kept: leaves.length - matched, + match_rate: leaves.length ? pyRound(matched / leaves.length, 4) : 0, + threshold, + css_map_entries: cssMap.size, + matched_in_css_map: report.matched.filter((m) => m.in_css_map).length, + }; + return [out, report]; +} + +export function versionToKey(version: string): string { + const parts = version.trim().split("."); + if (parts.length < 3 || !parts.slice(0, 3).every((p) => /^\d+$/.test(p))) { + throw new Error(`need major.minor.patch, got ${JSON.stringify(version)}`); + } + const [major, minor, patch] = parts.slice(0, 3).map(Number); + return `${major}${String(minor).padStart(2, "0")}${String(patch).padStart(4, "0")}`; +} + +export function confidenceLabel(m: Row): string { + // Class survived unchanged in the target CSS: strongest evidence. + if (m.method === "identity") return "high"; + const sem = (m.semantic_score as number) || 0; + const css = (m.css_score as number) || 0; + const inMap = Boolean(m.in_css_map); + // No CSS similarity evidence at all (semantic-only guess). + if (css === 0) return "low"; + if (inMap && sem >= 0.5 && css >= 0.35) return "high"; + if (inMap && sem >= 0.5) return "medium-semantic"; + if (css >= 0.75 && isHashLike(((m.new || m.class) as string) || "")) return "medium-css"; + if (css >= 0.55) return "low"; + return "reject"; +} + +export function verifyClassmap( + classmap: Classmap, + targetCss: string, + cssMap: Map, + report: Report | null = null, + targetVersion: string | null = null, + classmapSha256: string | null = null, +): { target: Row; summary: Record; rows: Row[] } { + const targetSigs = classSignatures(targetCss); + const inventory = inventoryClasses(targetCss); + const matchedRows = report ? [...(report.matched ?? []), ...(report.identity ?? [])] : []; + + const rows = iterLeaves(classmap).map(([parts, cls]) => { + const dotted = parts.join("."); + const classes = cls.split(/\s+/).filter(Boolean); + const semantics = classes.filter((token) => cssMap.has(token)).map((token) => cssMap.get(token) as string); + const missingClasses = classes.filter((token) => !targetSigs.has(token) && !inventory.has(token)); + const row: Row = { + path: dotted, + class: cls, + classes, + missing_classes: missingClasses, + in_target_css: !missingClasses.length, + css_rule_count: classes.reduce((sum, token) => sum + (targetSigs.get(token)?.size ?? 0), 0), + selector_hits: classes.reduce((sum, token) => sum + (inventory.get(token) ?? 0), 0), + in_css_map: semantics.length > 0, + semantics, + semantic: cssMap.get(cls) || (semantics.length ? semantics.join(" ") : null), + semantic_score: pyRound(Math.max(0, ...semantics.map((name) => semanticFit(parts, name))), 4), + }; + if (report) { + const m = matchedRows.find((entry) => entry.path === dotted); + if (m) { + row.migrate_score = m.score ?? null; + row.migrate_confidence = (m.confidence as string) || confidenceLabel(m); + row.method = m.method ?? null; + } else { + // unmatched leaves keep old hash often + row.migrate_confidence = "unmatched-or-stale"; + } + } + const confidence = row.migrate_confidence as string | undefined; + if (row.in_css_map && (row.semantic_score as number) >= 0.5 && row.in_target_css && (confidence === "high" || confidence === "medium-semantic")) { + row.verdict = "likely_good"; + } else if (row.in_target_css && ["high", "medium-semantic", "medium-css"].includes(confidence ?? "")) { + row.verdict = "plausible"; + } else if (!row.in_target_css) { + row.verdict = "missing_in_css"; + } else { + row.verdict = "needs_manual_check"; + } + return row; + }); + + const summary: Record = {}; + for (const row of rows) summary[row.verdict as string] = (summary[row.verdict as string] ?? 0) + 1; + return { + target: { + spotify_version: targetVersion, + css_sha256: crypto.createHash("sha256").update(targetCss, "utf8").digest("hex"), + classmap_sha256: classmapSha256, + }, + summary, + rows, + }; +} + +/** + * Bridge a nested classmap into a flat css-map overlay (hash -> semantic). + * + * The semantic name for a leaf is the name themes already target, resolved + * in priority order: + * 1. css-map name of the BASE classmap's hash at the same path (the + * established, theme-facing name for that component), + * 2. the migrate report's semantic for the path. + * Leaves whose new hash is already in the global css-map are skipped: the + * global map already rewrites them, so the overlay only carries verified + * per-version corrections the global map lacks. Leaves with no resolvable + * semantic are skipped with a warning. Stale leaves and leaves not yet + * verified (per META.json) are skipped; an unverified required path is a + * loud warning. + */ +export function flattenClassmap( + classmap: Classmap, + baseMap: Classmap | null, + cssMap: Map, + report: Report | null, + meta: Record | null, +): [Record, string[]] { + const stale = new Set((meta?.stale_leaves as string[]) ?? []); + const unverified = new Set((meta?.unverified_leaves as string[]) ?? []); + for (const u of report?.unmatched ?? []) if (u.stale) stale.add((u.path as string) ?? ""); + + const baseAt = new Map(baseMap ? iterLeaves(baseMap).map(([parts, h]) => [parts.join("."), h]) : []); + const reportSemantic = new Map(); + for (const m of [...(report?.matched ?? []), ...(report?.identity ?? [])]) { + if (m.semantic) reportSemantic.set((m.path as string) ?? "", m.semantic as string); + } + + const overlay: Record = {}; + const skipped: string[] = []; + for (const [parts, h] of iterLeaves(classmap)) { + const dotted = parts.join("."); + if (stale.has(dotted) || unverified.has(dotted)) continue; + if (!isHashLike(h)) { + skipped.push(`${dotted}: '${h}' not hash-like`); + continue; + } + if (cssMap.has(h)) continue; + const semantic = cssMap.get(baseAt.get(dotted) ?? "") || reportSemantic.get(dotted); + if (!semantic) { + skipped.push(`${dotted}: no semantic name for ${h}`); + continue; + } + if (Object.hasOwn(overlay, h) && overlay[h] !== semantic) { + skipped.push(`${dotted}: ${h} already named '${overlay[h]}', ignoring conflicting '${semantic}'`); + continue; + } + overlay[h] = semantic; + } + + for (const [leaf, status] of Object.entries((meta?.required_paths as Record) ?? {})) { + if (!String(status).startsWith("verified")) skipped.push(`WARNING: required path ${leaf} is ${String(status)}`); + } + return [overlay, skipped]; +} + +function sortedKeys(value: Json): Json { + if (Array.isArray(value)) return value.map(sortedKeys); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortedKeys(value[key])]), + ); + } + return value; +} + +function writeJson(file: string, value: unknown, sortKeys = false): void { + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(sortKeys ? sortedKeys(value as Json) : value, null, 2)}\n`); +} + +/** Formats a report value for console output. */ +function text(value: Json | undefined): string { + return typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); +} + +const readJson = (file: string) => JSON.parse(fs.readFileSync(file, "utf8")); + +const cssMapPath = (value: string | undefined) => (value === "" ? null : value); + +const COMMANDS: Record number> = { + inventory(argv) { + const { values } = parseArgs({ + args: argv, + options: { + spa: { type: "string" }, + "css-dir": { type: "string" }, + css: { type: "string", multiple: true }, + top: { type: "string", default: "40" }, + out: { type: "string" }, + }, + }); + const counts = inventoryClasses(readCssSources(values.spa, values["css-dir"], values.css)); + const items = [...counts].filter(([cls]) => isHashLike(cls)); + console.log(`hash-like classes: ${items.length} (of ${counts.size} selector tokens)`); + for (const [cls, n] of items.slice(0, Number(values.top))) console.log(`${String(n).padStart(5)} ${cls}`); + if (values.out) { + writeJson(values.out, Object.fromEntries(items)); + console.log(`wrote ${values.out}`); + } + return 0; + }, + + migrate(argv) { + const { values } = parseArgs({ + args: argv, + options: { + "base-classmap": { type: "string" }, + "base-spa": { type: "string" }, + "base-css-dir": { type: "string" }, + "base-css": { type: "string", multiple: true }, + "target-spa": { type: "string" }, + "target-css-dir": { type: "string" }, + "target-css": { type: "string", multiple: true }, + "css-map": { type: "string", default: "css-map.json" }, + out: { type: "string" }, + report: { type: "string" }, + threshold: { type: "string", default: "0.5" }, + "allow-partial": { type: "boolean", default: false }, + }, + }); + if (!values["base-classmap"] || !values.out) throw new Error("migrate requires --base-classmap and --out"); + const baseMap: Classmap = readJson(values["base-classmap"]); + const baseCss = readCssSources(values["base-spa"], values["base-css-dir"], values["base-css"]); + const targetCss = readCssSources(values["target-spa"], values["target-css-dir"], values["target-css"]); + const cssMap = loadCssMap(cssMapPath(values["css-map"])); + + console.log("Parsing base CSS signatures…"); + const baseSigs = classSignatures(baseCss); + console.log(` base classes with signatures: ${baseSigs.size}`); + console.log("Parsing target CSS signatures…"); + const targetSigs = classSignatures(targetCss); + console.log(` target classes with signatures: ${targetSigs.size}`); + console.log(` css-map entries: ${cssMap.size}`); + + const [migrated, report] = migrateClassmap(baseMap, baseSigs, targetSigs, cssMap, Number(values.threshold)); + for (const m of report.matched) m.confidence = confidenceLabel(m); + + writeJson(values.out, migrated, true); + console.log(`wrote classmap ${values.out}`); + if (values.report) { + writeJson(values.report, report); + console.log(`wrote report ${values.report}`); + } + + const stats = report.stats; + console.log( + `stats: leaves=${text(stats.leaves)} matched=${text(stats.matched)} identity=${text(stats.identity)} migrated=${text(stats.migrated)} ` + + `unmatched=${text(stats.unmatched)} rate=${text(stats.match_rate)} in_css_map=${text(stats.matched_in_css_map)}`, + ); + if (stats.stale_kept) { + console.log(`warning: ${text(stats.stale_kept)} leaves kept their old (stale) hash; see report.unmatched entries marked stale=true`); + } + const confidence: Record = {}; + for (const m of report.matched) confidence[m.confidence as string] = (confidence[m.confidence as string] ?? 0) + 1; + console.log("confidence:", JSON.stringify(confidence)); + return stats.unmatched === 0 || values["allow-partial"] ? 0 : 1; + }, + + verify(argv) { + const { values } = parseArgs({ + args: argv, + options: { + classmap: { type: "string" }, + report: { type: "string" }, + "css-map": { type: "string", default: "css-map.json" }, + "target-spa": { type: "string" }, + "target-css-dir": { type: "string" }, + "target-css": { type: "string", multiple: true }, + out: { type: "string" }, + "target-version": { type: "string" }, + }, + }); + if (!values.classmap) throw new Error("verify requires --classmap"); + const cssMap = loadCssMap(cssMapPath(values["css-map"])); + const targetCss = readCssSources(values["target-spa"], values["target-css-dir"], values["target-css"]); + const report: Report | null = values.report ? readJson(values.report) : null; + const classmapBytes = fs.readFileSync(values.classmap); + const out = verifyClassmap( + JSON.parse(classmapBytes.toString("utf8")), + targetCss, + cssMap, + report, + values["target-version"] ?? null, + crypto.createHash("sha256").update(classmapBytes).digest("hex"), + ); + if (values.out) { + writeJson(values.out, out); + console.log(`wrote ${values.out}`); + } + + console.log("verify summary:", JSON.stringify(out.summary)); + console.log(); + console.log(`${"VERDICT".padEnd(20)} ${"CONF".padEnd(16)} ${"PATH".padEnd(45)} CLASS -> semantic`); + const sortKey = (r: Row) => [text(r.verdict), text(r.path)]; + const byVerdictThenPath = (a: Row, b: Row) => { + const [ka, kb] = [sortKey(a), sortKey(b)]; + return ka[0] !== kb[0] ? (ka[0] < kb[0] ? -1 : 1) : ka[1] < kb[1] ? -1 : ka[1] > kb[1] ? 1 : 0; + }; + for (const r of [...out.rows].sort(byVerdictThenPath)) { + console.log( + `${text(r.verdict).padEnd(20)} ${text(r.migrate_confidence ?? "-").padEnd(16)} ${text(r.path).padEnd(45)} ` + + `${text(r.class)} -> ${text(r.semantic || "-")} (sem=${text(r.semantic_score)}, css_hits=${text(r.selector_hits)})`, + ); + } + return 0; + }, + + flatten(argv) { + const { values } = parseArgs({ + args: argv, + options: { + classmap: { type: "string" }, + "base-classmap": { type: "string" }, + "css-map": { type: "string", default: "css-map.json" }, + report: { type: "string" }, + meta: { type: "string" }, + out: { type: "string" }, + "allow-partial": { type: "boolean", default: false }, + }, + }); + if (!values.classmap || !values.out) throw new Error("flatten requires --classmap and --out"); + const [overlay, skipped] = flattenClassmap( + readJson(values.classmap), + values["base-classmap"] ? readJson(values["base-classmap"]) : null, + loadCssMap(cssMapPath(values["css-map"])), + values.report ? readJson(values.report) : null, + values.meta ? readJson(values.meta) : null, + ); + writeJson(values.out, overlay, true); + console.log(`wrote ${values.out} (${Object.keys(overlay).length} overlay entries)`); + for (const s of skipped) console.log(` skipped: ${s}`); + return skipped.length && !values["allow-partial"] ? 1 : 0; + }, + + devtools(argv) { + const { values } = parseArgs({ args: argv, options: { report: { type: "string" } } }); + if (!values.report) throw new Error("devtools requires --report"); + const report: Report = readJson(values.report); + const matched = [...(report.matched ?? []), ...(report.identity ?? [])]; + if (!matched.length) { + console.log("no matched entries in report"); + return 1; + } + console.log("// Paste into Spotify DevTools console (Ctrl+Shift+I / Cmd+Opt+I)"); + console.log("// Requires spicetify enable-devtools or employee/devtools flags."); + console.log("(function verifyClassmapMatches() {"); + console.log(" const checks = ["); + for (const m of matched) { + const cls = JSON.stringify(m.new || m.class); + const confidence = JSON.stringify((m.confidence as string) || confidenceLabel(m)); + console.log(` { path: ${JSON.stringify(m.path)}, cls: ${cls}, confidence: ${confidence} },`); + } + console.log(" ];"); + console.log(` const rows = checks.map(({path, cls, confidence}) => { + const nodes = document.getElementsByClassName(cls); + let sample = null; + if (nodes[0]) { + const el = nodes[0]; + sample = { + tag: el.tagName, + id: el.id || null, + aria: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || null, + text: (el.textContent || '').trim().slice(0, 60), + parent: el.parentElement && el.parentElement.className, + }; + } + return { path, cls, confidence, count: nodes.length, sample }; + }); + console.table(rows.map(r => ({ + path: r.path, + cls: r.cls, + confidence: r.confidence, + count: r.count, + tag: r.sample && r.sample.tag, + text: r.sample && r.sample.text, + }))); + console.log('Full detail', rows); + return rows; +})();`); + console.log(); + console.log("// Tip: count===0 means the class is not in the current DOM (may be route-specific)."); + console.log("// Tip: open Home / Now Playing / Settings / a modal before running."); + return 0; + }, + + key(argv) { + const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} }); + if (positionals.length !== 1) throw new Error("key requires one Spotify version"); + console.log(versionToKey(positionals[0])); + return 0; + }, +}; + +export function main(argv: string[]): number { + const [command, ...rest] = argv; + const run = command ? COMMANDS[command] : undefined; + if (!run) { + const header = fs.readFileSync(import.meta.filename, "utf8").split("*/")[0]; + console.log(header.replace(/^#!.*\n\/\*\*\n/, "").replace(/^ \* ?/gm, "")); + return command && command !== "--help" && command !== "-h" ? 2 : 0; + } + try { + return run(rest); + } catch (e) { + console.error(`error: ${(e as Error).message}`); + return 1; + } +} + +if (import.meta.main) process.exitCode = main(process.argv.slice(2)); diff --git a/scripts/classmap_cdp_verify.mjs b/scripts/classmap-cdp-verify.mjs similarity index 70% rename from scripts/classmap_cdp_verify.mjs rename to scripts/classmap-cdp-verify.mjs index 35363885b2..427d5c830c 100644 --- a/scripts/classmap_cdp_verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -16,16 +16,19 @@ * * Usage * ----- - * node scripts/classmap_cdp_verify.mjs --out-dir classmaps/1020092 - * node scripts/classmap_cdp_verify.mjs --port 9222 --mode both --out-dir classmaps/1020092 - * node scripts/classmap_cdp_verify.mjs --report --classmap --out - * CLASSMAP_OUT_DIR=classmaps/1020092 node scripts/classmap_cdp_verify.mjs --navigate --min-hit-rate 0.3 + * node scripts/classmap-cdp-verify.mjs --out-dir classmaps/1020092 + * node scripts/classmap-cdp-verify.mjs --port 9222 --mode both --out-dir classmaps/1020092 + * node scripts/classmap-cdp-verify.mjs --report --classmap --out + * CLASSMAP_OUT_DIR=classmaps/1020092 node scripts/classmap-cdp-verify.mjs --navigate --min-hit-rate 0.3 * * Paths * ----- * --classmap/--out may be given explicitly, or derived from --out-dir / * CLASSMAP_OUT_DIR. --report is optional; when omitted, every classmap leaf * is probed directly. + * --overlay applies a key's css-map.json on top of --css-map, as apply does, + * so semantic probes use the names the published key gives its classes. + * The report records the digest of both maps. * * Exit codes * ---------- @@ -53,6 +56,7 @@ function parseArgs(argv) { report: null, classmap: null, cssMap: path.join(CLI_ROOT, "css-map.json"), + overlay: null, out: null, navigate: false, deep: false, @@ -86,6 +90,9 @@ function parseArgs(argv) { case "--css-map": args.cssMap = path.resolve(take()); break; + case "--overlay": + args.overlay = path.resolve(take()); + break; case "--out": args.out = path.resolve(take()); break; @@ -201,8 +208,15 @@ function pickXpuiTarget(targets) { return pages.find((t) => (t.url || "").includes("xpui.app.spotify.com")) || pages.find((t) => (t.url || "").includes("index.html")) || pages[0]; } -function loadChecks({ reportPath, classmapPath, cssMapPath }) { - const cssMap = fs.existsSync(cssMapPath) ? JSON.parse(fs.readFileSync(cssMapPath, "utf8")) : {}; +/** The CLI css-map with a key's overlay applied on top, as `apply` merges them. */ +export function effectiveCssMap(cssMapPath, overlayPath) { + const base = fs.existsSync(cssMapPath) ? JSON.parse(fs.readFileSync(cssMapPath, "utf8")) : {}; + const overlay = overlayPath ? JSON.parse(fs.readFileSync(overlayPath, "utf8")) : {}; + return { ...base, ...overlay }; +} + +export function loadChecks({ reportPath, classmapPath, cssMapPath, overlayPath = null }) { + const cssMap = effectiveCssMap(cssMapPath, overlayPath); /** @type {{path:string, hash:string, semantic:string|null, confidence:string}[]} */ let checks = []; @@ -308,7 +322,31 @@ class CdpSession { } /** Shared helpers injected into page for navigation recipes. */ +/** + * The menu labels the navigation steps click, in the client's language. + * `translations` is the client's own i18n/.json; missing keys fall back + * to English. + */ +export function menuLabels(translations = {}) { + const t = (key, english) => (typeof translations[key] === "string" && translations[key]) || english; + return { + credits: t("contextmenu.show-credits", "View credits"), + share: t("contextmenu.share", "Share"), + embed: [t("ewg.title.track", "Embed track"), t("ewg.title.episode", "Embed episode")], + settings: t("user.settings", "Settings"), + }; +} + const NAV_HELPERS = ` + const menuLabels = ${menuLabels.toString()}; + const labels = async () => { + const lang = document.documentElement.lang || "en"; + try { + const response = await fetch("/i18n/" + lang + ".json"); + if (response.ok) return menuLabels(await response.json()); + } catch {} + return menuLabels(); + }; const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const visible = (el) => { if (!el) return false; @@ -321,7 +359,8 @@ const NAV_HELPERS = ` if (!el) return false; el.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, cancelable: true, view: window })); el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window })); - el.click(); + if (typeof el.click === "function") el.click(); + else el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window })); return true; }; @@ -354,6 +393,42 @@ const NAV_HELPERS = ` return "nav-failed:" + pathname; } }; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (probe, ms = 3000) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const value = probe(); + if (value) return value; + await wait(100); + } + return null; + }; + const openMenu = () => $$('[role="menu"]').find(visible); + const menuItem = (...texts) => + $$('[role="menu"] [role="menuitem"], [role="menu"] button, [role="menu"] a').find((el) => visible(el) && texts.includes((el.textContent || "").trim())); + const openDialogs = () => $$('dialog[open], [role="dialog"]').filter(visible); + // Closes open dialogs and menus so the next step starts from the page itself. + const closeOverlays = async () => { + for (const dialog of openDialogs()) { + const close = [...dialog.querySelectorAll("button[aria-label]")].find((b) => /^close$/i.test(b.getAttribute("aria-label"))); + if (close) click(close); + else dialog.close?.(); + } + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); + await waitFor(() => !openDialogs().length && !openMenu(), 5000); + }; + // Opens the context menu of the first visible track row. + const openTrackMenu = async () => { + // Track rows first: the Your Library sidebar also uses role="row". + const row = ['main [data-testid="tracklist-row"]', '[data-testid="tracklist-row"]', 'main [data-testid="internal-tracklist-row"]'] + .map((selector) => $$(selector).find(visible)) + .find(Boolean); + if (!row) return null; + const rect = row.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, view: window, clientX: rect.left + Math.min(40, rect.width / 2), clientY: rect.top + rect.height / 2, button: 2, buttons: 2 }; + row.dispatchEvent(new MouseEvent("contextmenu", opts)); + return waitFor(openMenu, 8000); + }; `; const NAV_STEPS = [ @@ -408,55 +483,67 @@ const NAV_STEPS = [ }, { name: "playlist", - waitMs: 1800, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Prefer an in-library playlist / liked songs / card link - const el = findClickable([ - (e) => (e.getAttribute("href") || "").includes("/playlist/"), - (e) => (e.getAttribute("href") || "").includes("/collection/tracks"), - (e) => /liked songs|playlist/i.test(textOf(e)) && (e.getAttribute("href") || "").startsWith("/"), - (e) => (e.getAttribute("data-testid") || "").includes("playlist") || (e.getAttribute("data-testid") || "") === "internal-tracklist-row", - ]); - if (click(el)) return "clicked-playlist-or-liked:" + (el.getAttribute("href") || el.getAttribute("data-testid") || el.tagName); - // Fallback: open a well-known public playlist route (Today's Top Hits-ish may 404; use search results cards) - const card = $$('[data-testid="card-click-handler"], [data-testid="top-result-card"], a[href*="playlist"], a[href*="album"]').find(visible); - if (click(card)) return "clicked-card:" + (card.getAttribute("href") || card.getAttribute("data-testid")); - return navigateSpa("/collection/tracks"); + // Only real links: a filter chip such as "Playlists" matches a looser test and navigates nowhere. + const links = () => $$("a[href]").filter((a) => visible(a) && /^\\/(playlist|album)\\/|^\\/collection\\/tracks/.test(a.getAttribute("href"))); + if (!links().length) { + click(findClickable([(e) => e.getAttribute("data-testid") === "home-button"])); + await waitFor(() => links().length, 15000); + } + const tracksRendered = () => waitFor(() => $$('main [data-testid="tracklist-row"]').some(visible), 45000); + for (const link of links().slice(0, 2)) { + const target = link.getAttribute("href"); + click(link); + if (await tracksRendered()) return "clicked-playlist-or-liked:" + target; + } + return links().length ? "tracks-not-rendered" : "no-playlist-link"; })()`, }, { name: "context_menu", - waitMs: 900, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Right-click a track row / more button to open context menu - const row = - $$('[data-testid="tracklist-row"], [data-testid="internal-tracklist-row"], [role="row"]').find(visible) || - $$('div[aria-selected], [data-testid*="track"]').find(visible); - if (row) { - const rect = row.getBoundingClientRect(); - const x = rect.left + Math.min(40, rect.width / 2); - const y = rect.top + rect.height / 2; - const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 2, buttons: 2 }; - row.dispatchEvent(new MouseEvent("contextmenu", opts)); - // also try the "more" button in the row - const more = row.querySelector('button[aria-label*="More" i], button[data-testid="more-button"]'); - if (more) click(more); - const menu = $$('[data-testid="context-menu"], [role="menu"], #context-menu, .main-contextMenu-menu').find(visible); - return menu ? "context-menu-open" : "context-menu-dispatched"; - } - // Fallback: more button anywhere in main view - const more = findClickable([(e) => /more options|more/i.test(textOf(e)) && e.tagName === "BUTTON"]); - if (click(more)) return "clicked-more"; - return "no-track-row"; + if (await openTrackMenu()) return "context-menu-open"; + return $$('[data-testid="tracklist-row"], [role="row"]').find(visible) ? "context-menu-dispatched" : "no-track-row"; + })()`, + }, + { + name: "track_credits", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + if (!openMenu() && !(await openTrackMenu())) return "no-track-row"; + const item = menuItem((await labels()).credits); + if (!click(item)) return "credits-not-found"; + return (await waitFor(() => openDialogs().length, 10000)) ? "credits-open" : "credits-not-found"; + })()`, + }, + { + name: "embed", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + await closeOverlays(); + if (!(await openTrackMenu())) return "no-track-row"; + const text = await labels(); + const share = menuItem(text.share); + if (!share) return "embed-not-found"; + share.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + click(share); + const embed = await waitFor(() => menuItem(...text.embed), 8000); + if (!click(embed)) return "embed-not-found"; + return (await waitFor(() => openDialogs().length, 10000)) ? "embed-open" : "embed-not-found"; })()`, }, { name: "sort_or_filter", waitMs: 800, - expr: `(() => { + expr: `(async () => { ${NAV_HELPERS} + await closeOverlays(); const el = findClickable([ (e) => /sort|filter|custom order|recently|title|artist/i.test(textOf(e)), (e) => (e.getAttribute("data-testid") || "").includes("sort"), @@ -467,43 +554,50 @@ const NAV_STEPS = [ }, { name: "settings", - waitMs: 1500, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Profile chip → Settings - const profile = findClickable([ - (e) => (e.getAttribute("data-testid") || "") === "user-widget-link", - (e) => /profile|account menu/i.test(textOf(e)), - ]); - if (profile) click(profile); - const pref = findClickable([ - (e) => (e.getAttribute("href") || "").includes("/preferences"), - (e) => /settings|preferences/i.test(textOf(e)), - ]); - if (click(pref)) return "clicked-settings"; + await closeOverlays(); + // Settings opens from the account menu; a pushed history entry doesn't render it. + const profile = findClickable([(e) => (e.getAttribute("data-testid") || "") === "user-widget-link"]); + if (click(profile)) { + const settingsLabel = (await labels()).settings; + const item = await waitFor(() => menuItem(settingsLabel), 8000); + if (click(item)) { + const rendered = await waitFor(() => $$("main input, main select").length > 5, 20000); + return rendered ? "settings-open" : "settings-not-rendered"; + } + } return navigateSpa("/preferences"); })()`, }, { name: "settings_scroll", - waitMs: 600, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Scroll settings main to mount more sections - const main = document.querySelector('main, [data-testid="preferences-page"], [class*="settings"]') || document.scrollingElement; - if (main) { - main.scrollTop = main.scrollHeight / 2; - window.scrollTo(0, document.body.scrollHeight / 2); - return "scrolled-settings"; + // Scroll the whole settings page so lazily mounted sections render. + const inputs = $$("main input, main select"); + let scroller = inputs[0]; + while (scroller && !(scroller.scrollHeight > scroller.clientHeight + 50 && /auto|scroll/.test(getComputedStyle(scroller).overflowY))) { + scroller = scroller.parentElement; } - return "no-settings-scroll-target"; + if (!scroller) return "no-settings-scroll-target"; + for (let y = 0; y <= scroller.scrollHeight; y += 500) { + scroller.scrollTop = y; + await wait(120); + } + return "scrolled-settings"; })()`, }, ]; -function navigationSucceeded(result) { +export function navigationSucceeded(result) { return ( - typeof result === "string" && !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target)(?::|$)/.test(result) + typeof result === "string" && + !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target|credits-not-found|embed-not-found|settings-not-rendered|tracks-not-rendered|no-playlist-link)(?::|$)/.test( + result, + ) ); } @@ -634,6 +728,7 @@ async function main() { reportPath: args.report, classmapPath: args.classmap, cssMapPath: args.cssMap, + overlayPath: args.overlay, }); console.log(`Loaded ${checks.length} classmap paths`); @@ -655,6 +750,12 @@ async function main() { return result; }; + // A slow client (an emulated VM) can take a minute to render its shell after the page loads. + const ready = await session.evaluate(`(async () => { + ${NAV_HELPERS} + return Boolean(await waitFor(() => $$("button").length > 50 && document.querySelector('[data-testid="home-button"], [data-testid="global-nav-bar"], nav'), ${args.timeoutMs})); + })()`); + if (!ready) console.warn(` app shell not rendered within ${args.timeoutMs}ms; probing anyway`); await runProbe("initial"); if (args.navigate) { @@ -690,6 +791,10 @@ async function main() { classmap: { sha256: crypto.createHash("sha256").update(fs.readFileSync(args.classmap)).digest("hex"), }, + cssMap: { + sha256: fs.existsSync(args.cssMap) ? crypto.createHash("sha256").update(fs.readFileSync(args.cssMap)).digest("hex") : null, + overlaySha256: args.overlay ? crypto.createHash("sha256").update(fs.readFileSync(args.overlay)).digest("hex") : null, + }, mode: args.mode, minHitRate: args.minHitRate, navigate: args.navigate, @@ -730,7 +835,9 @@ async function main() { process.exit(0); } -main().catch((err) => { - console.error(err.message || err); - process.exit(1); -}); +if (import.meta.main) { + main().catch((err) => { + console.error(err.message || err); + process.exit(1); + }); +} diff --git a/scripts/classmap-cdp-verify.test.mjs b/scripts/classmap-cdp-verify.test.mjs new file mode 100644 index 0000000000..8cac87c4af --- /dev/null +++ b/scripts/classmap-cdp-verify.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { loadChecks, menuLabels, navigationSucceeded } from "./classmap-cdp-verify.mjs"; + +const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); + +test("help works without report paths", () => { + const result = spawnSync(process.execPath, [verifier, "--help"], { + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); +}); + +test("an explicit classmap and output do not require a migrate report", () => { + const result = spawnSync(process.execPath, [verifier, "--classmap", "/tmp/classmap.json", "--out", "/tmp/cdp.json", "--timeout-ms", "1"], { + encoding: "utf8", + }); + assert.doesNotMatch(result.stderr, /Pass --report/); +}); + +test("out-dir does not silently consume a stale migrate report", () => { + const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); + try { + writeFileSync(join(dir, "classmap.json"), '{"leaf":"hashValueAA"}\n'); + writeFileSync(join(dir, "report.json"), "not json\n"); + const result = spawnSync(process.execPath, [verifier, "--out-dir", dir, "--timeout-ms", "1"], { + encoding: "utf8", + }); + assert.doesNotMatch(result.stderr, /Unexpected token|JSON/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a multi-class leaf is mapped to semantic names token by token", () => { + const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); + try { + writeFileSync(join(dir, "classmap.json"), '{"button":"hashBtnAA e-10860-legacy-button"}\n'); + writeFileSync(join(dir, "css-map.json"), '{"hashBtnAA":"main-topBar-button"}\n'); + const [check] = loadChecks({ reportPath: null, classmapPath: join(dir, "classmap.json"), cssMapPath: join(dir, "css-map.json") }); + assert.equal(check.semantic, "main-topBar-button e-10860-legacy-button"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +for (const args of [ + ["--mode", "invalid"], + ["--min-hit-rate", "NaN"], + ["--min-hit-rate", "2"], + ["--timeout-ms", "0"], + ["--port", "70000"], +]) { + test(`rejects invalid arguments: ${args.join(" ")}`, () => { + const result = spawnSync(process.execPath, [verifier, "--classmap", "/tmp/map.json", "--out", "/tmp/out.json", ...args], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /invalid/i); + }); +} + +test("an overlay renames classes on top of the CLI css-map", () => { + const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); + try { + writeFileSync(join(dir, "classmap.json"), '{"topbar":"hashTopAA","nav":"hashNavBB"}\n'); + writeFileSync(join(dir, "css-map.json"), '{"hashTopAA":"main-topBar-old","hashNavBB":"main-navBar-navBar"}\n'); + writeFileSync(join(dir, "overlay.json"), '{"hashTopAA":"Root__globalNav"}\n'); + const checks = loadChecks({ + reportPath: null, + classmapPath: join(dir, "classmap.json"), + cssMapPath: join(dir, "css-map.json"), + overlayPath: join(dir, "overlay.json"), + }); + assert.deepEqual( + checks.map(({ path, semantic }) => ({ path, semantic })), + [ + { path: "topbar", semantic: "Root__globalNav" }, + { path: "nav", semantic: "main-navBar-navBar" }, + ], + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a step that could not open its surface does not count as navigation", () => { + for (const result of ["context-menu-open", "credits-open", "embed-open", "settings-open", "scrolled-settings", "clicked-home"]) { + assert.equal(navigationSucceeded(result), true, result); + } + for (const result of [ + "tracks-not-rendered", + "no-playlist-link", + "context-menu-dispatched", + "credits-not-found", + "embed-not-found", + "settings-not-rendered", + "no-track-row", + "nav-failed:/", + ]) { + assert.equal(navigationSucceeded(result), false, result); + } +}); + +test("menu labels come from the client's translations, with English for missing keys", () => { + // Strings from the stock Spotify 1.3.1.234 i18n/es.json. + const es = { "contextmenu.show-credits": "Ver créditos", "contextmenu.share": "Compartir", "ewg.title.track": "Insertar canción" }; + assert.deepEqual(menuLabels(es), { + credits: "Ver créditos", + share: "Compartir", + embed: ["Insertar canción", "Embed episode"], + settings: "Settings", + }); +}); diff --git a/scripts/classmap_e2e.sh b/scripts/classmap-e2e.sh similarity index 70% rename from scripts/classmap_e2e.sh rename to scripts/classmap-e2e.sh index 9926b7d2b5..1a4e0e6617 100755 --- a/scripts/classmap_e2e.sh +++ b/scripts/classmap-e2e.sh @@ -1,16 +1,18 @@ #!/usr/bin/env bash -# Full classmap pipeline: migrate → static verify → CDP e2e DOM verify. +# Full classmap pipeline: migrate → static verify → overlay → CDP e2e DOM verify. # # Usage: -# ./scripts/classmap_e2e.sh -# ./scripts/classmap_e2e.sh --skip-migrate --deep -# SPOTIFY_SPA=/path/to/xpui.spa ./scripts/classmap_e2e.sh +# ./scripts/classmap-e2e.sh +# ./scripts/classmap-e2e.sh --skip-migrate --deep +# SPOTIFY_SPA=/path/to/xpui.spa ./scripts/classmap-e2e.sh # # Env: # SPOTIFY_SPA Path to xpui.spa (default: macOS Spotify.app) -# BASE_CLASSMAP Base classmap JSON (default: ../classmaps/1020040/...) -# BASE_CSS_DIR Base CSS dir from xpui-archive -# OUT_DIR Output dir (default: classmaps/1020092) +# SPOTIFY_VERSION Exact Spotify version recorded in the static report +# BASE_CLASSMAP Base classmap JSON (default: the newest key in ../classmaps) +# BASE_SPA Stock xpui.spa of the base build (or BASE_CSS_DIR); required to migrate +# BASE_CSS_DIR Directory of the base build's stock CSS +# OUT_DIR Output dir (default: $TMPDIR/spicetify-classmap-e2e) # CDP_PORT Remote debugging port (default: 9222) # MIN_HIT_RATE CDP pass threshold (default: 0.25) # SPICETIFY Spicetify binary (default: ~/.spicetify/spicetify) @@ -74,13 +76,15 @@ STOCK_SPA_CANDIDATES=( "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa.bak" ) -BASE_CLASSMAP="${BASE_CLASSMAP:-$ROOT/../classmaps/1020040/classmap-190747c4b8f.json}" -BASE_CSS_DIR="${BASE_CSS_DIR:-$ROOT/../xpui-archive/1.2.40.599}" -OUT_DIR="${OUT_DIR:-$ROOT/classmaps/1020092}" +NEWEST_KEY="$(ls -d "$ROOT"/../classmaps/[0-9][0-9][0-9][0-9][0-9][0-9][0-9] 2>/dev/null | sort | tail -1)" +BASE_CLASSMAP="${BASE_CLASSMAP:-$NEWEST_KEY/classmap.json}" +BASE_SPA="${BASE_SPA:-}" +BASE_CSS_DIR="${BASE_CSS_DIR:-}" +OUT_DIR="${OUT_DIR:-${TMPDIR:-/tmp}/spicetify-classmap-e2e}" CSS_MAP="${CSS_MAP:-$ROOT/css-map.json}" CDP_PORT="${CDP_PORT:-9222}" SPICETIFY="${SPICETIFY:-$HOME/.spicetify/spicetify}" -PYTHON="${PYTHON:-python3}" +SPOTIFY_VERSION="${SPOTIFY_VERSION:-}" NODE="${NODE:-node}" CLASSMAP_OUT="$OUT_DIR/classmap.json" @@ -188,45 +192,41 @@ ensure_cdp() { CFG_PATH="$cfg" CFG_BACKUP="$(mktemp -t spicetify-config-xpui)" cp "$cfg" "$CFG_BACKUP" - "$PYTHON" - "$cfg" "$CDP_PORT" <<'PY' -import sys -from pathlib import Path + "$NODE" - "$cfg" "$CDP_PORT" <<'JS' +const fs = require("node:fs"); -cfg, port = Path(sys.argv[1]), sys.argv[2] -debug_flag = f"--remote-debugging-port={port}" +const [cfg, port] = process.argv.slice(2); +const debugFlag = `--remote-debugging-port=${port}`; -def merge(value: str) -> str: - # Preserve existing flags; only append the debug port. No - # --remote-allow-origins=*: the verifier connects without an Origin - # header, so a wildcard that exposes the logged-in client to any - # local web page is unnecessary. - parts = [p for p in value.split("|") if p.strip()] - if not any("remote-debugging-port" in p for p in parts): - parts.append(debug_flag) - return "|".join(parts) +// Preserve existing flags; only append the debug port. No +// --remote-allow-origins=*: the verifier connects without an Origin +// header, so a wildcard that exposes the logged-in client to any +// local web page is unnecessary. +function merge(value) { + const parts = value.split("|").filter((p) => p.trim()); + if (!parts.some((p) => p.includes("remote-debugging-port"))) parts.push(debugFlag); + return parts.join("|"); +} -lines = [] -found = False -for line in cfg.read_text().splitlines(): - stripped = line.strip() - if stripped.startswith("spotify_launch_flags"): - _, _, value = stripped.partition("=") - lines.append(f"spotify_launch_flags = {merge(value.strip())}") - found = True - else: - lines.append(line) -if not found: - out = [] - inserted = False - for line in lines: - out.append(line) - if line.strip() == "[Setting]" and not inserted: - out.append(f"spotify_launch_flags = {debug_flag}") - inserted = True - lines = out if inserted else lines + [f"spotify_launch_flags = {debug_flag}"] -cfg.write_text("\n".join(lines) + "\n") -print("updated", cfg) -PY +let found = false; +let lines = fs.readFileSync(cfg, "utf8").split(/\r?\n/); +if (lines.at(-1) === "") lines.pop(); +lines = lines.map((line) => { + const stripped = line.trim(); + if (!stripped.startsWith("spotify_launch_flags")) return line; + found = true; + const value = stripped.slice(stripped.indexOf("=") + 1).trim(); + return `spotify_launch_flags = ${merge(stripped.includes("=") ? value : "")}`; +}); +if (!found) { + const section = lines.findIndex((line) => line.trim() === "[Setting]"); + const entry = `spotify_launch_flags = ${debugFlag}`; + if (section >= 0) lines.splice(section + 1, 0, entry); + else lines.push(entry); +} +fs.writeFileSync(cfg, `${lines.join("\n")}\n`); +console.log("updated", cfg); +JS fi fi @@ -249,14 +249,18 @@ if [[ "$SKIP_MIGRATE" -eq 0 ]]; then echo "Missing base classmap: $BASE_CLASSMAP" >&2 exit 1 fi - if [[ ! -d "$BASE_CSS_DIR" ]]; then - echo "Missing base CSS dir: $BASE_CSS_DIR" >&2 + if [[ -n "$BASE_SPA" && -f "$BASE_SPA" ]]; then + BASE_ARGS=(--base-spa "$BASE_SPA") + elif [[ -n "$BASE_CSS_DIR" && -d "$BASE_CSS_DIR" ]]; then + BASE_ARGS=(--base-css-dir "$BASE_CSS_DIR") + else + echo "Set BASE_SPA to the stock xpui.spa of the base build, or BASE_CSS_DIR to its CSS" >&2 exit 1 fi resolve_target_css_args 1 - "$PYTHON" scripts/classmap_capture.py migrate \ + "$NODE" scripts/classmap-capture.ts migrate \ --base-classmap "$BASE_CLASSMAP" \ - --base-css-dir "$BASE_CSS_DIR" \ + "${BASE_ARGS[@]}" \ "${TARGET_ARGS[@]}" \ --css-map "$CSS_MAP" \ --out "$CLASSMAP_OUT" \ @@ -270,20 +274,45 @@ fi if [[ "$SKIP_STATIC" -eq 0 ]]; then log "2/4 static verify" resolve_target_css_args 1 || resolve_target_css_args 0 - "$PYTHON" scripts/classmap_capture.py verify \ + VERSION_ARGS=() + [[ -n "$SPOTIFY_VERSION" ]] && VERSION_ARGS=(--target-version "$SPOTIFY_VERSION") + "$NODE" scripts/classmap-capture.ts verify \ --classmap "$CLASSMAP_OUT" \ --report "$REPORT_OUT" \ --css-map "$CSS_MAP" \ "${TARGET_ARGS[@]}" \ + "${VERSION_ARGS[@]+${VERSION_ARGS[@]}}" \ --out "$VERIFY_OUT" - "$PYTHON" scripts/classmap_capture.py devtools \ + "$NODE" scripts/classmap-capture.ts devtools \ --report "$REPORT_OUT" > "$OUT_DIR/devtools-snippet.js" || true else log "2/4 static verify skipped" fi +if [[ "$SKIP_FLATTEN" -eq 0 ]]; then + log "3/4 flatten css-map overlay" + for f in "$CLASSMAP_OUT" "$REPORT_OUT"; do + if [[ ! -f "$f" ]]; then + echo "Missing $f (run migrate first, or point OUT_DIR at existing artifacts)" >&2 + exit 1 + fi + done + META_ARGS=() + [[ -f "$OUT_DIR/META.json" ]] && META_ARGS=(--meta "$OUT_DIR/META.json") + "$NODE" scripts/classmap-capture.ts flatten \ + --classmap "$CLASSMAP_OUT" \ + --base-classmap "$BASE_CLASSMAP" \ + --css-map "$CSS_MAP" \ + --report "$REPORT_OUT" \ + "${META_ARGS[@]+${META_ARGS[@]}}" \ + --out "$OVERLAY_OUT" \ + --allow-partial +else + log "3/4 flatten skipped" +fi + if [[ "$SKIP_CDP" -eq 0 ]]; then - log "3/4 CDP e2e verify" + log "4/4 CDP e2e verify" if [[ "$ENSURE_CDP" -eq 1 ]]; then ensure_cdp fi @@ -295,40 +324,20 @@ if [[ "$SKIP_CDP" -eq 0 ]]; then if [[ "$RESTART" -eq 1 ]]; then RESTART_FLAG=(--restart) fi - "$NODE" scripts/classmap_cdp_verify.mjs \ + OVERLAY_ARGS=() + [[ -f "$OVERLAY_OUT" ]] && OVERLAY_ARGS=(--overlay "$OVERLAY_OUT") + "$NODE" scripts/classmap-cdp-verify.mjs \ --port "$CDP_PORT" \ --mode both \ - --report "$REPORT_OUT" \ --classmap "$CLASSMAP_OUT" \ --css-map "$CSS_MAP" \ + "${OVERLAY_ARGS[@]+${OVERLAY_ARGS[@]}}" \ --out "$CDP_OUT" \ --min-hit-rate "$MIN_HIT_RATE" \ "${NAV_FLAGS[@]}" \ "${RESTART_FLAG[@]}" else - log "3/4 CDP e2e skipped" -fi - -if [[ "$SKIP_FLATTEN" -eq 0 ]]; then - log "4/4 flatten css-map overlay" - for f in "$CLASSMAP_OUT" "$REPORT_OUT"; do - if [[ ! -f "$f" ]]; then - echo "Missing $f (run migrate first, or point OUT_DIR at existing artifacts)" >&2 - exit 1 - fi - done - META_ARGS=() - [[ -f "$OUT_DIR/META.json" ]] && META_ARGS=(--meta "$OUT_DIR/META.json") - "$PYTHON" scripts/classmap_capture.py flatten \ - --classmap "$CLASSMAP_OUT" \ - --base-classmap "$BASE_CLASSMAP" \ - --css-map "$CSS_MAP" \ - --report "$REPORT_OUT" \ - "${META_ARGS[@]+${META_ARGS[@]}}" \ - --out "$OVERLAY_OUT" \ - --allow-partial -else - log "4/4 flatten skipped" + log "4/4 CDP e2e skipped" fi log "Done" diff --git a/scripts/classmap_capture.py b/scripts/classmap_capture.py deleted file mode 100644 index dd2fed812f..0000000000 --- a/scripts/classmap_capture.py +++ /dev/null @@ -1,1036 +0,0 @@ -#!/usr/bin/env python3 -"""Capture / migrate / verify Spicetify classmaps between Spotify CSS builds. - -Commands --------- - inventory List class tokens from CSS/spa - migrate Migrate a classmap (CSS signatures + optional css-map.json) - verify Score a classmap or migrate report against css-map + live CSS - key Print classmap folder key for a version - devtools Print DevTools snippets to manually verify matched paths - -css-map.json signal -------------------- -The CLI css-map maps *current* hashed classes -> stable semantic names -(e.g. main-topBar-container). During migrate we: - 1. Prefer target candidates that appear as keys in css-map - 2. Boost candidates whose semantic name token-overlaps the classmap path - 3. Penalize / reject high CSS similarity when semantic tokens conflict - -Examples --------- - python3 scripts/classmap_capture.py migrate \\ - --base-classmap ../classmaps/1020040/classmap-190747c4b8f.json \\ - --base-css-dir ../xpui-archive/1.2.40.599 \\ - --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \\ - --css-map css-map.json \\ - --out classmaps/1020092/classmap.json \\ - --report classmaps/1020092/report.json \\ - --allow-partial - - python3 scripts/classmap_capture.py verify \\ - --classmap classmaps/1020092/classmap.json \\ - --report classmaps/1020092/report.json \\ - --css-map css-map.json \\ - --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \\ - --out classmaps/1020092/verify.json - - python3 scripts/classmap_capture.py devtools \\ - --report classmaps/1020092/report.json -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -import zipfile -from collections import defaultdict -from pathlib import Path -from typing import Any - -PROP_RE = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+);") - - -def is_hash_like(token: str) -> bool: - # Min length 4: real Spotify hashes can be short (e.g. the 1.2.45 - # play button class "cLkUmr" is 6 chars). Semantic/Encore classes are - # filtered by the lowercase-dash and prefix rules below, and matchers - # still require CSS or semantic evidence, so a low bound is safe here. - if len(token) < 4 or len(token) > 25: - return False - if "-" in token and token.islower(): - return False - if token.startswith("spotify") or token.startswith("encore"): - return False - has_upper = any(c.isupper() for c in token) - has_lower = any(c.islower() for c in token) - has_digit = any(c.isdigit() for c in token) - if has_upper and has_lower: - return True - if "_" in token and (has_digit or has_upper): - return True - return False - - -def read_css_sources(spa: str | None, css_dir: str | None, css_files: list[str] | None) -> str: - chunks: list[str] = [] - if spa: - with zipfile.ZipFile(spa) as zf: - for name in zf.namelist(): - if name.endswith(".css"): - chunks.append(zf.read(name).decode("utf-8", errors="ignore")) - if css_dir: - for path in sorted(Path(css_dir).rglob("*.css")): - chunks.append(path.read_text(encoding="utf-8", errors="ignore")) - for f in css_files or []: - chunks.append(Path(f).read_text(encoding="utf-8", errors="ignore")) - if not chunks: - raise SystemExit("no CSS sources provided (use --spa, --css-dir, or --css)") - return "\n".join(chunks) - - -def split_rules(css: str) -> list[tuple[str, str]]: - css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) - rules: list[tuple[str, str]] = [] - i, n = 0, len(css) - while i < n: - start = css.find("{", i) - if start < 0: - break - selector = css[i:start].strip() - depth = 1 - j = start + 1 - while j < n and depth: - if css[j] == "{": - depth += 1 - elif css[j] == "}": - depth -= 1 - j += 1 - body = css[start + 1 : j - 1] - if selector and not selector.lstrip().startswith("@"): - rules.append((selector, body)) - i = j - return rules - - -def normalize_props(body: str) -> frozenset[tuple[str, str]]: - props = [] - for m in PROP_RE.finditer(body): - prop = m.group(1).strip().lower() - val = re.sub(r"\s+", " ", m.group(2).strip().lower()) - if prop.startswith("--") or prop in {"content", "src", "animation-name"}: - continue - props.append((prop, val)) - return frozenset(props) - - -def class_signatures(css: str) -> dict[str, set[frozenset[tuple[str, str]]]]: - out: dict[str, set[frozenset[tuple[str, str]]]] = defaultdict(set) - for selector, body in split_rules(css): - props = normalize_props(body) - if not props: - continue - for cls in re.findall(r"\.([A-Za-z0-9_-]+)", selector): - out[cls].add(props) - return out - - -def inventory_classes(css: str) -> dict[str, int]: - counts: dict[str, int] = defaultdict(int) - for selector, _ in split_rules(css): - for cls in re.findall(r"\.([A-Za-z0-9_-]+)", selector): - counts[cls] += 1 - return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))) - - -def jaccard(a: frozenset, b: frozenset) -> float: - if not a and not b: - return 1.0 - if not a or not b: - return 0.0 - inter = len(a & b) - union = len(a | b) - return inter / union if union else 0.0 - - -def load_css_map(path: str | None) -> dict[str, str]: - if not path: - return {} - data = json.loads(Path(path).read_text()) - if not isinstance(data, dict): - raise SystemExit("css-map must be a JSON object") - return {str(k): str(v) for k, v in data.items()} - - -def tokenize_semantic(name: str) -> set[str]: - parts = re.split(r"[-_.\s]+", name) - tokens: set[str] = set() - for p in parts: - if not p: - continue - sub = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+", p) - if not sub: - tokens.add(p.lower()) - else: - for s in sub: - tokens.add(s.lower()) - return tokens - - -# Path segment -> semantic tokens expected in css-map values. -PATH_HINTS: dict[str, set[str]] = { - "topbar": {"topbar", "topbarcontent", "globalnav"}, - "playbar": {"nowplayingbar", "playercontrols", "playbackbar"}, - "navbar": {"navbar", "navlink", "navitem", "yourlibraryx"}, - "search_chips": {"searchcategory", "chip", "filterchips"}, - "search_box": {"search", "filterbox", "searchinput", "globalnav"}, - "widget_generator": {"embedwidgetgenerator", "embedwidget"}, - "track_credits": {"trackcredits"}, - "settings": {"settings", "desktopsettings"}, - "sort_box": {"sortbox", "sortdropdown", "sort"}, - "scrollable_text": {"marquee"}, - "context_menu": {"contextmenu"}, - "tracklist": {"tracklist"}, - "chip": {"chip"}, - "close": {"closebtn", "close"}, - "menu_item": {"menuitem", "contextmenu"}, - "expand_button": {"expandbutton", "searchinput"}, - "upgrade_button": {"upgradebutton", "upgrade"}, - "indicator": {"indicator"}, -} - -# Region segment -> required substrings in the lowercased full semantic name. -# Matching is against the full css-map value, not tokenized pieces, so -# "nowPlayingBar" and "actionBar" stay distinct. -REGION_REQUIRED: dict[str, tuple[str, ...]] = { - "topbar": ("topbar", "globalnav"), - "playbar": ("nowplayingbar", "player-controls", "playercontrols", "playbackbar"), - "navbar": ("navbar", "navlink", "navitem", "yourlibraryx", "mainnav"), - "track_credits": ("trackcredits",), - "widget_generator": ("embedwidget",), - "settings": ("settings",), - "tracklist": ("tracklist",), - "search_chips": ("search", "chip", "filterchip"), - "search_box": ("search", "filterbox", "globalnav"), - "context_menu": ("contextmenu",), - "sort_box": ("sort",), -} - -# If path contains the region, reject semantics that match these substrings. -REGION_FORBIDDEN: dict[str, tuple[str, ...]] = { - "playbar": ( - "actionbar", - "tracklist", - "watchfeed", - "entityheader", - "rowplay", - "rowimage", - ), - "topbar": ("actionbar", "entityheader", "watchfeed"), - "settings": ("equalizer", "nowplayingview", "actionbar"), - "widget_generator": ("watchfeed", "filterbox", "sortbox"), - "search_chips": ("nowplaying", "actionbar", "tracklist"), - "search_box": ("yourlibrary", "actionbar", "tracklist"), - "navbar": ("topbar", "nowplaying", "actionbar"), - "tracklist": ("actionbar", "nowplayingbar", "globalnav"), - "context_menu": ("settings", "equalizer", "sortbox"), -} - -# Leaf segment requirements against full semantic string. -LEAF_REQUIRED: dict[str, tuple[str, ...]] = { - "close": ("close",), - "content": ("content", "body", "iframe", "code"), - "header": ("header", "title"), - "chip": ("chip",), - "indicator": ("indicator", "active", "playing"), - "expand_button": ("expand", "search", "filter", "clear"), -} - -# Leaf forbidden when this leaf is the path tail. -LEAF_FORBIDDEN: dict[str, tuple[str, ...]] = { - "content": ("close",), # content container must not resolve to closeBtn only -} - - -def normalize_semantic(name: str) -> str: - """Lowercase and strip separators so nowPlayingBar -> nowplayingbar.""" - return re.sub(r"[-_.\s]+", "", name).lower() - - -def path_segments(path: tuple[str, ...]) -> list[str]: - return [seg.split("__")[0] for seg in path] - - -def semantic_fit(path: tuple[str, ...], semantic: str) -> float: - """Return 0..1 agreement between a nested classmap path and a css-map name. - - Uses full-string substring checks (not bag-of-tokens) so ActionBar cannot - satisfy a playbar requirement meant for nowPlayingBar. - """ - if not semantic: - return 0.0 - - segs = path_segments(path) - full = normalize_semantic(semantic) - raw_lower = semantic.lower() - - # Region required / forbidden - for seg in segs: - forb = REGION_FORBIDDEN.get(seg) - if forb and any(f in full for f in forb): - return 0.0 - req = REGION_REQUIRED.get(seg) - if req and not any(r.replace("-", "") in full or r in raw_lower for r in req): - return 0.0 - - # Intermediate + leaf structural roles - for i, seg in enumerate(segs): - is_tail = i == len(segs) - 1 - # For non-tail "content", still require content-ish names - if seg == "content": - if not any(x in full for x in ("content", "body", "iframe", "code")): - return 0.0 - # pure close button is never a content node - if "close" in full and "content" not in full: - return 0.0 - if is_tail: - req = LEAF_REQUIRED.get(seg) - if req and not any(r in full for r in req): - return 0.0 - forb = LEAF_FORBIDDEN.get(seg) - if forb and any(f in full for f in forb) and not any( - r in full for r in (LEAF_REQUIRED.get(seg) or ()) - ): - return 0.0 - - # Score: count how many specific path segments are reflected in the name. - specific = [ - s - for s in segs - if s - not in { - "main", - "wrapper", - "container", - "button", - "left", - "right", - "list", - "button_t", - } - ] - if not specific: - specific = segs[-1:] - - hits = 0 - for s in specific: - hints = PATH_HINTS.get(s, {s.replace("_", "")}) - hints = set(hints) | {s.replace("_", ""), s} - if any(normalize_semantic(h) in full or h.lower() in raw_lower for h in hints if len(h) >= 3): - hits += 1 - - if hits == 0: - return 0.0 - return min(1.0, hits / max(1, len(specific))) - - -def path_hint_tokens(path: tuple[str, ...]) -> set[str]: - """Legacy helper used by scoring diagnostics.""" - hints: set[str] = set() - for seg in path_segments(path): - hints.add(seg.lower().replace("-", "").replace("_", "")) - if seg in PATH_HINTS: - hints |= PATH_HINTS[seg] - return hints - - -def semantic_overlap(path: tuple[str, ...], semantic: str) -> float: - """Back-compat name used throughout scoring.""" - return semantic_fit(path, semantic) - - -def css_similarity( - src_sigs: set[frozenset[tuple[str, str]]], - tgt_sigs: set[frozenset[tuple[str, str]]], -) -> float: - if not src_sigs or not tgt_sigs: - return 0.0 - return max(jaccard(s, t) for s in src_sigs for t in tgt_sigs) - - -def score_candidate( - path: tuple[str, ...], - cand: str, - src_sigs: set[frozenset[tuple[str, str]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - css_map: dict[str, str], -) -> dict[str, Any]: - css_score = css_similarity(src_sigs, target_sigs.get(cand, set())) - in_map = cand in css_map - semantic = css_map.get(cand, "") - sem_score = semantic_overlap(path, semantic) if semantic else 0.0 - - segs = set(path_segments(path)) - strict_region = bool(segs & set(REGION_REQUIRED)) - - # Combined score: css similarity is base; css-map presence and semantic agreement boost. - score = css_score - if in_map and sem_score > 0: - score += 0.15 - if sem_score > 0: - score += 0.45 * sem_score - else: - # css-map hit with zero semantic fit is usually a false friend. - if in_map and css_score >= 0.5: - score -= 0.35 - # For strict regions (playbar/topbar/...), refuse pure CSS matches - # without semantic agreement — that is how actionBar leaks in. - if strict_region: - score = min(score, css_score * 0.25) - - return { - "class": cand, - "score": round(max(0.0, min(score, 1.5)), 4), - "css_score": round(css_score, 4), - "semantic_score": round(sem_score, 4), - "in_css_map": in_map, - "semantic": semantic or None, - } - - -def best_match( - path: tuple[str, ...], - src_sigs: set[frozenset[tuple[str, str]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - target_index: dict[frozenset[tuple[str, str]], set[str]], - css_map: dict[str, str], - threshold: float, -) -> dict[str, Any] | None: - if not src_sigs: - return None - - candidates: set[str] = set() - for sig in src_sigs: - candidates |= target_index.get(sig, set()) - - # Always consider css-map keys that have decent semantic overlap as - # candidates, but only when they actually exist in the target CSS: - # a css-map entry with zero target presence has no evidence behind it. - hints = path_hint_tokens(path) - if hints and css_map: - for h, sem in css_map.items(): - if not is_hash_like(h): - continue - if h not in target_sigs: - continue - if semantic_overlap(path, sem) >= 0.5: - candidates.add(h) - - scored: list[dict[str, Any]] = [] - pool = candidates if candidates else set(target_sigs.keys()) - for cand in pool: - if not is_hash_like(cand): - continue - if cand not in target_sigs: - continue - item = score_candidate(path, cand, src_sigs, target_sigs, css_map) - scored.append(item) - - if not scored: - return None - scored.sort(key=lambda x: (x["score"], x["semantic_score"], x["css_score"]), reverse=True) - best = scored[0] - segs = set(path_segments(path)) - strict_region = bool(segs & set(REGION_REQUIRED)) - # Require either solid css match or solid semantic agreement. - # Strict regions need semantic_score > 0 to avoid actionBar/etc. leaks. - if best["score"] < threshold: - best["rejected"] = True - best["next"] = scored[1:4] - return best - if strict_region and best["semantic_score"] <= 0: - best["rejected"] = True - best["reason"] = "strict region requires semantic agreement" - best["next"] = scored[1:4] - return best - if best["semantic_score"] <= 0 and best["css_score"] < 0.75: - best["rejected"] = True - best["next"] = scored[1:4] - return best - best["alternatives"] = scored[1:4] - return best - - -def build_target_index( - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], -) -> dict[frozenset[tuple[str, str]], set[str]]: - index: dict[frozenset[tuple[str, str]], set[str]] = defaultdict(set) - for cls, sigs in target_sigs.items(): - for sig in sigs: - index[sig].add(cls) - return index - - -def iter_leaves(node: Any, path: tuple[str, ...] = ()) -> list[tuple[tuple[str, ...], str]]: - leaves: list[tuple[tuple[str, ...], str]] = [] - if isinstance(node, dict): - for k, v in node.items(): - leaves.extend(iter_leaves(v, path + (k,))) - elif isinstance(node, str): - leaves.append((path, node)) - return leaves - - -def set_leaf(root: dict, path: tuple[str, ...], value: str) -> None: - cur: Any = root - for key in path[:-1]: - nxt = cur.get(key) - if not isinstance(nxt, dict): - nxt = {} - cur[key] = nxt - cur = nxt - cur[path[-1]] = value - - -def migrate_classmap( - base_map: dict, - base_sigs: dict[str, set[frozenset[tuple[str, str]]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - css_map: dict[str, str], - threshold: float, -) -> tuple[dict, dict]: - target_index = build_target_index(target_sigs) - out: dict = json.loads(json.dumps(base_map)) - report: dict[str, Any] = { - "matched": [], - "unmatched": [], - "identity": [], - "stats": {}, - } - - leaves = iter_leaves(base_map) - matched = 0 - identity = 0 - for path, old_hash in leaves: - dotted = ".".join(path) - - if old_hash in target_sigs and is_hash_like(old_hash): - set_leaf(out, path, old_hash) - entry = { - "path": dotted, - "class": old_hash, - "in_css_map": old_hash in css_map, - "semantic": css_map.get(old_hash), - "method": "identity", - } - report["identity"].append(entry) - identity += 1 - matched += 1 - continue - - src = base_sigs.get(old_hash) - if not src: - # No base CSS signature for the old class. Fall back to a - # semantic-only match via css-map, with a higher bar (0.75) - # because there is zero CSS similarity evidence behind it. - best_sem = None - best_s = 0.0 - for h, sem in css_map.items(): - if h not in target_sigs or not is_hash_like(h): - continue - s = semantic_overlap(path, sem) - if s > best_s: - best_s = s - best_sem = (h, sem, s) - if best_sem and best_s >= 0.75: - new_cls, sem, s = best_sem - set_leaf(out, path, new_cls) - report["matched"].append( - { - "path": dotted, - "old": old_hash, - "new": new_cls, - "score": round(0.2 + 0.5 * s, 4), - "css_score": 0.0, - "semantic_score": round(s, 4), - "in_css_map": True, - "semantic": sem, - "method": "semantic-only", - "css_evidence": False, - } - ) - matched += 1 - continue - - report["unmatched"].append( - { - "path": dotted, - "old": old_hash, - "reason": "old class not found in base CSS", - "stale": True, - "kept": old_hash, - } - ) - set_leaf(out, path, old_hash) - continue - - best = best_match(path, src, target_sigs, target_index, css_map, threshold) - if not best or best.get("rejected"): - report["unmatched"].append( - { - "path": dotted, - "old": old_hash, - "reason": "no confident target", - "best": best, - "stale": True, - "kept": old_hash, - } - ) - set_leaf(out, path, old_hash) - continue - - set_leaf(out, path, best["class"]) - report["matched"].append( - { - "path": dotted, - "old": old_hash, - "new": best["class"], - "score": best["score"], - "css_score": best["css_score"], - "semantic_score": best["semantic_score"], - "in_css_map": best["in_css_map"], - "semantic": best["semantic"], - "method": "css+semantic", - "alternatives": best.get("alternatives", []), - } - ) - matched += 1 - - report["stats"] = { - "leaves": len(leaves), - "matched": matched, - "identity": identity, - "migrated": matched - identity, - "unmatched": len(leaves) - matched, - "stale_kept": len(leaves) - matched, - "match_rate": round(matched / len(leaves), 4) if leaves else 0.0, - "threshold": threshold, - "css_map_entries": len(css_map), - "matched_in_css_map": sum(1 for m in report["matched"] if m.get("in_css_map")), - } - return out, report - - -def version_to_key(version: str) -> str: - parts = version.strip().split(".") - if len(parts) < 3: - raise SystemExit(f"need major.minor.patch, got {version!r}") - major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) - return f"{major}{minor:02d}{patch:04d}" - - -def confidence_label(m: dict[str, Any]) -> str: - if m.get("method") == "identity": - # Class survived unchanged in the target CSS: strongest evidence. - return "high" - sem = m.get("semantic_score") or 0 - css = m.get("css_score") or 0 - in_map = m.get("in_css_map") - if css == 0: - # No CSS similarity evidence at all (semantic-only guess). - return "low" - if in_map and sem >= 0.5 and css >= 0.35: - return "high" - if in_map and sem >= 0.5: - return "medium-semantic" - if css >= 0.75 and is_hash_like(m.get("new") or m.get("class") or ""): - return "medium-css" - if css >= 0.55 and sem >= 0.34: - return "low" - if css >= 0.55: - return "low" - return "reject" - - -def cmd_inventory(args: argparse.Namespace) -> int: - css = read_css_sources(args.spa, args.css_dir, args.css) - counts = inventory_classes(css) - items = [(c, n) for c, n in counts.items() if is_hash_like(c)] - print(f"hash-like classes: {len(items)} (of {len(counts)} selector tokens)") - for c, n in items[: args.top]: - print(f"{n:5d} {c}") - if args.out: - Path(args.out).write_text(json.dumps(dict(items), indent=2) + "\n") - print(f"wrote {args.out}") - return 0 - - -def cmd_migrate(args: argparse.Namespace) -> int: - base_map = json.loads(Path(args.base_classmap).read_text()) - base_css = read_css_sources(args.base_spa, args.base_css_dir, args.base_css) - target_css = read_css_sources(args.target_spa, args.target_css_dir, args.target_css) - css_map = load_css_map(args.css_map) - - print("Parsing base CSS signatures…") - base_sigs = class_signatures(base_css) - print(f" base classes with signatures: {len(base_sigs)}") - print("Parsing target CSS signatures…") - target_sigs = class_signatures(target_css) - print(f" target classes with signatures: {len(target_sigs)}") - print(f" css-map entries: {len(css_map)}") - - migrated, report = migrate_classmap( - base_map, base_sigs, target_sigs, css_map, args.threshold - ) - for m in report["matched"]: - m["confidence"] = confidence_label(m) - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(json.dumps(migrated, indent=2) + "\n") - print(f"wrote classmap {out}") - - if args.report: - report_path = Path(args.report) - report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(json.dumps(report, indent=2) + "\n") - print(f"wrote report {report_path}") - - stats = report["stats"] - print( - "stats: leaves={leaves} matched={matched} identity={identity} " - "migrated={migrated} unmatched={unmatched} rate={match_rate} " - "in_css_map={matched_in_css_map}".format(**stats) - ) - if stats["stale_kept"]: - print( - f"warning: {stats['stale_kept']} leaves kept their old (stale) hash; " - "see report.unmatched entries marked stale=true" - ) - # confidence breakdown - conf: dict[str, int] = defaultdict(int) - for m in report["matched"]: - conf[m.get("confidence", "?")] += 1 - print("confidence:", dict(conf)) - return 0 if stats["unmatched"] == 0 or args.allow_partial else 1 - - -def verify_classmap( - classmap: dict, - target_css: str, - css_map: dict[str, str], - report: dict | None = None, - target_version: str | None = None, - classmap_sha256: str | None = None, -) -> dict: - target_sigs = class_signatures(target_css) - inv = inventory_classes(target_css) - leaves = iter_leaves(classmap) - - rows = [] - for path, cls in leaves: - dotted = ".".join(path) - classes = cls.split() - semantics = [css_map[token] for token in classes if token in css_map] - semantic = css_map.get(cls) or (" ".join(semantics) if semantics else None) - missing_classes = [token for token in classes if token not in target_sigs and token not in inv] - row = { - "path": dotted, - "class": cls, - "classes": classes, - "missing_classes": missing_classes, - "in_target_css": not missing_classes, - "css_rule_count": sum(len(target_sigs.get(token, [])) for token in classes), - "selector_hits": sum(inv.get(token, 0) for token in classes), - "in_css_map": bool(semantics), - "semantics": semantics, - "semantic": semantic, - "semantic_score": round(max((semantic_overlap(path, name) for name in semantics), default=0.0), 4), - } - # attach migrate confidence if available - if report: - for m in report.get("matched", []) + report.get("identity", []): - if m.get("path") == dotted: - row["migrate_score"] = m.get("score") - row["migrate_confidence"] = m.get("confidence") or confidence_label(m) - row["method"] = m.get("method") - break - else: - # unmatched leaves keep old hash often - row["migrate_confidence"] = "unmatched-or-stale" - # final verdict - if ( - row["in_css_map"] - and row["semantic_score"] >= 0.5 - and row["in_target_css"] - and row.get("migrate_confidence") in {"high", "medium-semantic"} - ): - row["verdict"] = "likely_good" - elif row["in_target_css"] and row.get("migrate_confidence") in {"high", "medium-semantic", "medium-css"}: - row["verdict"] = "plausible" - elif not row["in_target_css"]: - row["verdict"] = "missing_in_css" - else: - row["verdict"] = "needs_manual_check" - rows.append(row) - - summary = defaultdict(int) - for r in rows: - summary[r["verdict"]] += 1 - - out = { - "target": { - "spotify_version": target_version, - "css_sha256": hashlib.sha256(target_css.encode()).hexdigest(), - "classmap_sha256": classmap_sha256, - }, - "summary": dict(summary), - "rows": rows, - } - return out - - -def cmd_verify(args: argparse.Namespace) -> int: - css_map = load_css_map(args.css_map) - target_css = read_css_sources(args.target_spa, args.target_css_dir, args.target_css) - report = json.loads(Path(args.report).read_text()) if args.report else None - classmap = json.loads(Path(args.classmap).read_text()) - classmap_sha256 = hashlib.sha256(Path(args.classmap).read_bytes()).hexdigest() - out = verify_classmap( - classmap, - target_css, - css_map, - report, - target_version=args.target_version, - classmap_sha256=classmap_sha256, - ) - - if args.out: - Path(args.out).parent.mkdir(parents=True, exist_ok=True) - Path(args.out).write_text(json.dumps(out, indent=2) + "\n") - print(f"wrote {args.out}") - - print("verify summary:", out["summary"]) - print() - print(f"{'VERDICT':<20} {'CONF':<16} {'PATH':<45} CLASS -> semantic") - for r in sorted(out["rows"], key=lambda x: (x["verdict"], x["path"])): - print( - f"{r['verdict']:<20} {str(r.get('migrate_confidence', '-')):<16} " - f"{r['path']:<45} {r['class']} -> {r.get('semantic') or '-'}" - f" (sem={r['semantic_score']}, css_hits={r['selector_hits']})" - ) - return 0 - - -def flatten_classmap( - classmap: dict, - base_map: dict | None, - css_map: dict[str, str], - report: dict | None, - meta: dict | None, -) -> tuple[dict[str, str], list[str]]: - """Bridge a nested classmap into a flat css-map overlay (hash -> semantic). - - The semantic name for a leaf is the name themes already target, resolved - in priority order: - 1. css-map name of the BASE classmap's hash at the same path (the - established, theme-facing name for that component), - 2. the migrate report's semantic for the path. - Leaves whose new hash is already in the global css-map are skipped: the - global map already rewrites them, so the overlay only carries verified - per-version corrections the global map lacks. Leaves with no resolvable - semantic are skipped with a warning. Stale leaves and leaves not yet - verified (per META.json) are skipped; an unverified required path is a - loud warning. - """ - stale: set[str] = set() - unverified: set[str] = set() - if meta: - stale |= set(meta.get("stale_leaves", [])) - unverified |= set(meta.get("unverified_leaves", [])) - if report: - for u in report.get("unmatched", []): - if u.get("stale"): - stale.add(u.get("path", "")) - - base_at: dict[str, str] = {} - if base_map: - for path, h in iter_leaves(base_map): - base_at[".".join(path)] = h - - report_sem: dict[str, str] = {} - if report: - for m in report.get("matched", []) + report.get("identity", []): - if m.get("semantic"): - report_sem[m.get("path", "")] = m["semantic"] - - overlay: dict[str, str] = {} - skipped: list[str] = [] - for path, h in iter_leaves(classmap): - dotted = ".".join(path) - if dotted in stale or dotted in unverified: - continue - if not is_hash_like(h): - skipped.append(f"{dotted}: {h!r} not hash-like") - continue - if h in css_map: - continue - semantic = css_map.get(base_at.get(dotted, "")) or report_sem.get(dotted) - if not semantic: - skipped.append(f"{dotted}: no semantic name for {h}") - continue - if h in overlay and overlay[h] != semantic: - skipped.append( - f"{dotted}: {h} already named {overlay[h]!r}, ignoring conflicting {semantic!r}" - ) - continue - overlay[h] = semantic - - if meta: - for path, status in (meta.get("required_paths") or {}).items(): - if not str(status).startswith("verified"): - skipped.append(f"WARNING: required path {path} is {status}") - return overlay, skipped - - -def cmd_flatten(args: argparse.Namespace) -> int: - classmap = json.loads(Path(args.classmap).read_text()) - base_map = json.loads(Path(args.base_classmap).read_text()) if args.base_classmap else None - css_map = load_css_map(args.css_map) - report = json.loads(Path(args.report).read_text()) if args.report else None - meta = json.loads(Path(args.meta).read_text()) if args.meta else None - - overlay, skipped = flatten_classmap(classmap, base_map, css_map, report, meta) - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(json.dumps(overlay, indent=2, sort_keys=True) + "\n") - print(f"wrote {out} ({len(overlay)} overlay entries)") - for s in skipped: - print(f" skipped: {s}") - if skipped and not args.allow_partial: - return 1 - return 0 - - -def cmd_devtools(args: argparse.Namespace) -> int: - report = json.loads(Path(args.report).read_text()) - matched = report.get("matched", []) + report.get("identity", []) - if not matched: - print("no matched entries in report") - return 1 - - print("// Paste into Spotify DevTools console (Ctrl+Shift+I / Cmd+Opt+I)") - print("// Requires spicetify enable-devtools or employee/devtools flags.") - print("(function verifyClassmapMatches() {") - print(" const checks = [") - for m in matched: - cls = m.get("new") or m.get("class") - path = m.get("path") - conf = m.get("confidence") or confidence_label(m) - print(f" {{ path: {path!r}, cls: {cls!r}, confidence: {conf!r} }},") - print(" ];") - print(""" const rows = checks.map(({path, cls, confidence}) => { - const nodes = document.getElementsByClassName(cls); - let sample = null; - if (nodes[0]) { - const el = nodes[0]; - sample = { - tag: el.tagName, - id: el.id || null, - aria: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || null, - text: (el.textContent || '').trim().slice(0, 60), - parent: el.parentElement && el.parentElement.className, - }; - } - return { path, cls, confidence, count: nodes.length, sample }; - }); - console.table(rows.map(r => ({ - path: r.path, - cls: r.cls, - confidence: r.confidence, - count: r.count, - tag: r.sample && r.sample.tag, - text: r.sample && r.sample.text, - }))); - console.log('Full detail', rows); - return rows; -})();""") - print() - print("// Tip: count===0 means the class is not in the current DOM (may be route-specific).") - print("// Tip: open Home / Now Playing / Settings / a modal before running.") - return 0 - - -def cmd_key(args: argparse.Namespace) -> int: - print(version_to_key(args.version)) - return 0 - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - sub = parser.add_subparsers(dest="cmd", required=True) - - p_inv = sub.add_parser("inventory", help="List class tokens from CSS/spa") - p_inv.add_argument("--spa") - p_inv.add_argument("--css-dir") - p_inv.add_argument("--css", action="append") - p_inv.add_argument("--top", type=int, default=40) - p_inv.add_argument("--out") - p_inv.set_defaults(func=cmd_inventory) - - p_mig = sub.add_parser("migrate", help="Migrate a classmap using CSS + css-map signals") - p_mig.add_argument("--base-classmap", required=True) - p_mig.add_argument("--base-spa") - p_mig.add_argument("--base-css-dir") - p_mig.add_argument("--base-css", action="append") - p_mig.add_argument("--target-spa") - p_mig.add_argument("--target-css-dir") - p_mig.add_argument("--target-css", action="append") - p_mig.add_argument("--css-map", default="css-map.json", help="Path to cli css-map.json (optional empty to disable)") - p_mig.add_argument("--out", required=True) - p_mig.add_argument("--report") - p_mig.add_argument("--threshold", type=float, default=0.50) - p_mig.add_argument("--allow-partial", action="store_true") - p_mig.set_defaults(func=cmd_migrate) - - p_ver = sub.add_parser("verify", help="Verify classmap leaves against css-map + target CSS") - p_ver.add_argument("--classmap", required=True) - p_ver.add_argument("--report", help="Optional migrate report for confidence labels") - p_ver.add_argument("--css-map", default="css-map.json") - p_ver.add_argument("--target-spa") - p_ver.add_argument("--target-css-dir") - p_ver.add_argument("--target-css", action="append") - p_ver.add_argument("--out") - p_ver.add_argument("--target-version", help="Exact Spotify version used for target provenance") - p_ver.set_defaults(func=cmd_verify) - - p_dt = sub.add_parser("devtools", help="Emit DevTools console snippet for matched paths") - p_dt.add_argument("--report", required=True) - p_dt.set_defaults(func=cmd_devtools) - - p_flat = sub.add_parser("flatten", help="Bridge a classmap into a flat css-map overlay") - p_flat.add_argument("--classmap", required=True) - p_flat.add_argument("--base-classmap", help="Base classmap used to derive theme-facing names") - p_flat.add_argument("--css-map", default="css-map.json") - p_flat.add_argument("--report", help="Migrate report (semantic fallbacks + stale flags)") - p_flat.add_argument("--meta", help="META.json with stale_leaves list") - p_flat.add_argument("--out", required=True) - p_flat.add_argument("--allow-partial", action="store_true") - p_flat.set_defaults(func=cmd_flatten) - - p_key = sub.add_parser("key", help="Print classmap key for a Spotify version") - p_key.add_argument("version") - p_key.set_defaults(func=cmd_key) - - args = parser.parse_args(argv) - if getattr(args, "css_map", None) == "": - args.css_map = None - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/setup-windows-arm64.ps1 b/scripts/setup-windows-arm64.ps1 new file mode 100644 index 0000000000..922b159d65 --- /dev/null +++ b/scripts/setup-windows-arm64.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath +if (-not $installation) { throw 'Visual Studio ARM64 C++ tools are required.' } +$compiler = Join-Path $installation 'VC\Tools\Llvm\ARM64\bin\clang-cl.exe' +if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install the Visual Studio C++ Clang compiler for ARM64.' } +$developerShell = Join-Path $installation 'Common7\Tools\VsDevCmd.bat' +$buildEnvironment = & cmd.exe /d /s /c "call `"$developerShell`" -arch=arm64 -host_arch=arm64 >nul && set" +if ($LASTEXITCODE -ne 0) { throw 'Failed to configure the ARM64 C++ environment.' } +$buildEnvironment | Where-Object { $_ -match '^(INCLUDE|LIB|LIBPATH|PATH)=' } | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +# AWS-LC needs clang-cl for ARM assembly; the window-control helper uses MSVC C++. +"CC=$compiler" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +& rustup set default-host aarch64-pc-windows-msvc +if ($LASTEXITCODE -ne 0) { throw 'Failed to select the native Rust host.' } diff --git a/scripts/test-install-architecture.ps1 b/scripts/test-install-architecture.ps1 new file mode 100644 index 0000000000..1d62d97742 --- /dev/null +++ b/scripts/test-install-architecture.ps1 @@ -0,0 +1,91 @@ +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Installer = Get-Content -LiteralPath "$RepoRoot\install.ps1" -Raw +. ([scriptblock]::Create($Installer.Substring(0, $Installer.IndexOf('#region Main')))) + +# Check the actual Windows API before replacing it with deterministic fixtures. +$native = Get-SpicetifyNativeMachine +if ($native -notin @(0x8664, 0xAA64)) { throw "Unexpected native machine: $native" } +if ($env:RUNNER_ARCH -eq 'ARM64' -and $native -ne 0xAA64) { throw 'Emulation hid the ARM64 host' } +Write-Host "Native release architecture: $(Get-SpicetifyArchitecture)" + +function Get-SpicetifyNativeMachine { 0xAA64 } +if ((Get-SpicetifyArchitecture) -ne 'aarch64') { throw 'ARM64 must select the native release' } +function Get-SpicetifyNativeMachine { 0x8664 } +if ((Get-SpicetifyArchitecture) -ne 'x86_64') { throw 'x64 must keep the x64 release' } +function Get-SpicetifyNativeMachine { 0x014c } +$rejected = $false +try { Get-SpicetifyArchitecture } catch { $rejected = $true } +if (-not $rejected) { throw 'Unsupported native architectures must fail explicitly' } +Write-Host 'Native Windows installer architecture selection passed.' + +# Exercise the real download selection and checksum check without network access. +function Get-SpicetifyNativeMachine { 0xAA64 } +function Invoke-WebRequest { + param($Uri, $OutFile, [switch]$UseBasicParsin) + if ($Uri -notlike '*-windows-aarch64.zip') { throw "Wrong architecture URL: $Uri" } + [IO.File]::WriteAllText($OutFile, 'archive fixture') +} +function Invoke-RestMethod { + param($Uri) + if ($Uri -notlike '*-windows-aarch64.zip.sha256') { throw "Wrong checksum URL: $Uri" } + (Get-FileHash -LiteralPath ([IO.Path]::Combine([IO.Path]::GetTempPath(), 'spicetify.zip')) -Algorithm SHA256).Hash +} +$v3 = $true +$v = '3.0.0-beta.20' +$download = $null +try { + $download = Get-Spicetify + if (-not (Test-Path -LiteralPath $download)) { throw 'Verified archive was not returned' } + function Invoke-RestMethod { param($Uri) '0' * 64 } + $rejected = $false + try { Get-Spicetify | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'A mismatched archive checksum must fail before installation' } +} +finally { + if ($download) { Remove-Item -LiteralPath $download -Force -ErrorAction SilentlyContinue } +} +Write-Host 'ARM64 release download and checksum rejection passed.' + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "spicetify-replace-$([guid]::NewGuid())" +$source = Join-Path $fixture 'source' +$destination = Join-Path $fixture 'installed' +try { + New-Item -ItemType Directory -Path $source -Force | Out-Null + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + [IO.File]::WriteAllText((Join-Path $source $name), 'first') + } + Install-SpicetifyBinaries -Source $source -Destination $destination + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'first') { + throw 'Fresh installation did not install both binaries.' + } + [IO.File]::WriteAllText((Join-Path $source $name), 'second') + } + Install-SpicetifyBinaries -Source $source -Destination $destination + Remove-Item -LiteralPath (Join-Path $source 'spicetify-daemon.exe') + [IO.File]::WriteAllText((Join-Path $source 'spicetify.exe'), 'third') + $rejected = $false + try { Install-SpicetifyBinaries -Source $source -Destination $destination } catch { $rejected = $true } + if (-not $rejected) { throw 'Incomplete replacement must fail.' } + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'second') { + throw 'Failed replacement did not restore the original binary pair.' + } + } + [IO.File]::WriteAllText((Join-Path $source 'spicetify-daemon.exe'), 'third') + $locked = [IO.File]::Open((Join-Path $destination 'spicetify.exe'), 'Open', 'Read', 'Read') + try { + $rejected = $false + try { Install-SpicetifyBinaries -Source $source -Destination $destination } catch { $rejected = $true } + if (-not $rejected) { throw 'A locked installation must fail without replacing the daemon.' } + } + finally { $locked.Dispose() } + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'second') { + throw 'Locked-file failure changed an installed binary.' + } + } +} +finally { Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue } +Write-Host 'Fresh installation, pair replacement, rollback, and locked-file recovery passed.' diff --git a/scripts/test_classmap_capture.py b/scripts/test_classmap_capture.py deleted file mode 100644 index c66bee2204..0000000000 --- a/scripts/test_classmap_capture.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Unit tests for classmap_capture.py. - -Run with: python3 -m unittest scripts.test_classmap_capture -v -(or: python3 scripts/test_classmap_capture.py) -""" - -from __future__ import annotations - -import hashlib -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import classmap_capture as cc # noqa: E402 - - -class TestIsHashLike(unittest.TestCase): - def test_real_hashes(self): - # Short real hash from the 1.2.45 fixture (play button). - self.assertTrue(cc.is_hash_like("cLkUmr")) - self.assertTrue(cc.is_hash_like("AbCdEfGh")) - self.assertTrue(cc.is_hash_like("Xy1_abC")) - self.assertTrue(cc.is_hash_like("aB3d")) - - def test_rejects_semantic_and_encore(self): - self.assertFalse(cc.is_hash_like("main-topbar")) - self.assertFalse(cc.is_hash_like("spotify-play-button")) - self.assertFalse(cc.is_hash_like("encore-text")) - self.assertFalse(cc.is_hash_like("abc")) # too short - self.assertFalse(cc.is_hash_like("alllower")) - self.assertFalse(cc.is_hash_like("A" * 26)) # too long - - -class TestSplitRulesAndSignatures(unittest.TestCase): - CSS = """ - .cLkUmr, .other_Class { color: red; padding: 4px; } - /* comment { ignored } */ - .main-topbar { --custom: 1; background: blue; } - @media (min-width: 100px) { .ignoredAtRule { color: green; } } - """ - - def test_split_rules(self): - rules = cc.split_rules(self.CSS) - selectors = [s for s, _ in rules] - self.assertTrue(any("cLkUmr" in s for s in selectors)) - self.assertFalse(any(s.startswith("@") for s in selectors)) - - def test_signatures_skip_css_vars(self): - sigs = cc.class_signatures(".foo { --x: 1; color: red; }") - self.assertIn("foo", sigs) - flat = {p for sig in sigs["foo"] for p, _ in sig} - self.assertEqual(flat, {"color"}) - - -class TestSemanticFitFalseFriends(unittest.TestCase): - def test_actionbar_rejected_for_playbar(self): - path = ("main", "playbar", "controls") - self.assertEqual(cc.semantic_fit(path, "actionBar"), 0.0) - - def test_nowplayingbar_accepted_for_playbar(self): - path = ("main", "playbar", "controls") - self.assertGreater(cc.semantic_fit(path, "nowPlayingBar"), 0.0) - - def test_close_leaf_requires_close(self): - path = ("main", "topbar", "close") - self.assertEqual(cc.semantic_fit(path, "topBarButton"), 0.0) - self.assertGreater(cc.semantic_fit(path, "topBarCloseBtn"), 0.0) - - def test_empty_semantic(self): - self.assertEqual(cc.semantic_fit(("a", "b"), ""), 0.0) - - -class TestVersionToKey(unittest.TestCase): - def test_keys(self): - self.assertEqual(cc.version_to_key("1.2.45"), "1020045") - self.assertEqual(cc.version_to_key("1.2.93"), "1020093") - self.assertEqual(cc.version_to_key("1.2.8"), "1020008") - - -class TestVerifyClassmap(unittest.TestCase): - def test_multi_class_leaf_is_present_when_every_token_exists(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA formControlBB encore-text-body-medium"}}, - ".formInputAA { color: red; } .formControlBB { color: blue; } " - ".encore-text-body-medium { font-size: 1rem; }", - {}, - ) - - row = result["rows"][0] - self.assertTrue(row["in_target_css"]) - self.assertEqual(row["missing_classes"], []) - self.assertEqual(row["selector_hits"], 3) - - def test_multi_class_leaf_reports_only_the_missing_tokens(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA missingControlBB"}}, - ".formInputAA { color: red; }", - {}, - ) - - row = result["rows"][0] - self.assertFalse(row["in_target_css"]) - self.assertEqual(row["missing_classes"], ["missingControlBB"]) - self.assertEqual(row["verdict"], "missing_in_css") - - def test_multi_class_leaf_reports_token_semantics(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA formControlBB"}}, - ".formInputAA { color: red; } .formControlBB { color: blue; }", - {"formInputAA": "x-settings-input", "formControlBB": "x-settings-control"}, - ) - - row = result["rows"][0] - self.assertTrue(row["in_css_map"]) - self.assertEqual( - row["semantics"], - ["x-settings-input", "x-settings-control"], - ) - - def test_static_report_includes_target_digest_provenance(self): - css = ".topbarHashAA { color: red; }" - result = cc.verify_classmap( - {"main": {"topbar": {"wrapper": "topbarHashAA"}}}, - css, - {}, - target_version="1.2.96.518", - ) - - self.assertEqual(result["target"]["spotify_version"], "1.2.96.518") - self.assertEqual(result["target"]["css_sha256"], hashlib.sha256(css.encode()).hexdigest()) - - -class TestMigrateClassmap(unittest.TestCase): - BASE_CSS = ".oldHashAA { color: red; padding: 4px; } .keepMe99 { margin: 0; }" - TARGET_CSS = ".newHashBB { color: red; padding: 4px; } .keepMe99 { margin: 0; }" - - def _migrate(self, base_map, css_map, threshold=0.5): - base_sigs = cc.class_signatures(self.BASE_CSS) - target_sigs = cc.class_signatures(self.TARGET_CSS) - return cc.migrate_classmap(base_map, base_sigs, target_sigs, css_map, threshold) - - def test_identity_kept(self): - base_map = {"main": {"widget": "keepMe99"}} - out, report = cc.migrate_classmap( - base_map, - cc.class_signatures(self.BASE_CSS), - cc.class_signatures(self.TARGET_CSS), - {}, - 0.5, - ) - self.assertEqual(out["main"]["widget"], "keepMe99") - self.assertEqual(report["stats"]["identity"], 1) - self.assertEqual(report["identity"][0]["method"], "identity") - self.assertEqual(cc.confidence_label(report["identity"][0]), "high") - - def test_css_evidence_match(self): - base_map = {"main": {"widget": "oldHashAA"}} - out, report = self._migrate(base_map, {}) - self.assertEqual(out["main"]["widget"], "newHashBB") - self.assertEqual(report["stats"]["matched"], 1) - - def test_unmatched_marks_stale_and_keeps_old_hash(self): - base_map = {"main": {"widget": "notInBase1"}} - out, report = self._migrate(base_map, {}) - # Old hash kept for incremental re-runs, but explicitly marked stale. - self.assertEqual(out["main"]["widget"], "notInBase1") - self.assertEqual(report["stats"]["unmatched"], 1) - self.assertEqual(report["stats"]["stale_kept"], 1) - entry = report["unmatched"][0] - self.assertTrue(entry["stale"]) - self.assertEqual(entry["kept"], "notInBase1") - - def test_semantic_only_requires_target_presence(self): - # css-map claims a great semantic fit, but the class does not exist - # in the target CSS at all: must not be matched. - base_map = {"main": {"playbar": {"controls": "notInBase1"}}} - css_map = {"ghostHash1": "nowPlayingBarControls"} - out, report = self._migrate(base_map, css_map) - self.assertEqual(out["main"]["playbar"]["controls"], "notInBase1") - self.assertEqual(report["stats"]["matched"], 0) - - def test_semantic_only_low_confidence(self): - # Old hash absent from base CSS; semantic-only fallback via css-map - # with the candidate present in target CSS: matched but low confidence. - target_sigs = cc.class_signatures(".newHashBB { color: red; }") - base_map = {"main": {"playbar": {"controls": "notInBase1"}}} - css_map = {"newHashBB": "nowPlayingBarControls"} - out, report = cc.migrate_classmap(base_map, {}, target_sigs, css_map, 0.5) - self.assertEqual(out["main"]["playbar"]["controls"], "newHashBB") - m = report["matched"][0] - self.assertEqual(m["method"], "semantic-only") - self.assertEqual(cc.confidence_label(m), "low") - - -if __name__ == "__main__": - unittest.main() - - -class TestFlattenClassmap(unittest.TestCase): - BASE = {"main": {"topbar": {"wrapper": "oldHashAA11"}}, "settings": {"button": {"wrapper": "oldHashBB22"}}} - CSS_MAP = {"oldHashAA11": "main-topBar-topbarContent", "newHashCC33": "x-settings-button"} - REPORT = { - "matched": [ - {"path": "settings.button.wrapper", "new": "newHashCC33", "semantic": "x-settings-button"} - ], - "unmatched": [{"path": "main.topbar.icon", "old": "staleHash99", "stale": True}], - "identity": [], - } - - def test_base_semantic_wins(self): - cm = {"main": {"topbar": {"wrapper": "newHashAA44"}}} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, None) - # Name comes from the base hash's css-map entry, not the new hash. - self.assertEqual(overlay, {"newHashAA44": "main-topBar-topbarContent"}) - self.assertEqual(skipped, []) - - def test_hash_already_in_css_map_skipped(self): - # Global css-map already rewrites this hash; overlay must not rename it. - cm = {"settings": {"button": {"wrapper": "newHashCC33"}}} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, self.REPORT, None) - self.assertEqual(overlay, {}) - - def test_report_semantic_fallback_for_hand_edits(self): - # New hash unknown to css-map and base hash unnamed: report's - # path-level semantic is the last resort (covers hand-edited leaves). - cm = {"settings": {"header": {"container": "newHashHH77"}}} - report = {"matched": [{"path": "settings.header.container", "semantic": "x-settings-header"}], "unmatched": [], "identity": []} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, report, None) - self.assertEqual(overlay, {"newHashHH77": "x-settings-header"}) - - def test_stale_excluded(self): - cm = {"main": {"topbar": {"icon": "staleHash99"}}} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, self.REPORT, None) - self.assertEqual(overlay, {}) - - def test_meta_stale_excluded(self): - cm = {"main": {"topbar": {"wrapper": "newHashAA44"}}} - meta = {"stale_leaves": ["main.topbar.wrapper"]} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, meta) - self.assertEqual(overlay, {}) - - def test_unresolvable_skipped_with_warning(self): - cm = {"main": {"mystery": {"leaf": "newHashZZ99"}}} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, None) - self.assertEqual(overlay, {}) - self.assertEqual(len(skipped), 1) - - def test_unverified_leaves_excluded(self): - cm = {"settings": {"button": {"wrapper": "newHashUU55"}}} - meta = { - "unverified_leaves": ["settings.button.wrapper"], - "required_paths": {"settings.button.wrapper": "unverified (in CSS)"}, - } - report = {"matched": [{"path": "settings.button.wrapper", "semantic": "x-settings-button"}], "unmatched": [], "identity": []} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, report, meta) - self.assertEqual(overlay, {}) - self.assertTrue(any("required path" in s for s in skipped)) - - def test_conflicting_semantic_first_wins_with_warning(self): - cm = { - "a": {"leaf": "sharedHash1"}, - "b": {"leaf": "sharedHash1"}, - } - base = {"a": {"leaf": "oldA1"}, "b": {"leaf": "oldB1"}} - css_map = {"oldA1": "name-a", "oldB1": "name-b"} - overlay, skipped = cc.flatten_classmap(cm, base, css_map, None, None) - self.assertEqual(len(overlay), 1) - self.assertEqual(overlay["sharedHash1"], "name-a") - self.assertTrue(any("conflicting" in s for s in skipped)) diff --git a/scripts/test_classmap_cdp_verify.mjs b/scripts/test_classmap_cdp_verify.mjs deleted file mode 100644 index 9f515bfe8b..0000000000 --- a/scripts/test_classmap_cdp_verify.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; - -const source = readFileSync(new URL("./classmap_cdp_verify.mjs", import.meta.url), "utf8"); -const verifier = fileURLToPath(new URL("./classmap_cdp_verify.mjs", import.meta.url)); - -test("CDP reports bind deep mode and the exact classmap digest", () => { - assert.match(source, /deep:\s*args\.deep/); - assert.match(source, /classmap:\s*\{\s*sha256:/); -}); - -test("help works without report paths", () => { - const result = spawnSync(process.execPath, [verifier, "--help"], { - encoding: "utf8", - }); - assert.equal(result.status, 0, result.stderr); -}); - -test("an explicit classmap and output do not require a migrate report", () => { - const result = spawnSync(process.execPath, [verifier, "--classmap", "/tmp/classmap.json", "--out", "/tmp/cdp.json", "--timeout-ms", "1"], { - encoding: "utf8", - }); - assert.doesNotMatch(result.stderr, /Pass --report/); -}); - -test("out-dir does not silently consume a stale migrate report", () => { - const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); - try { - writeFileSync(join(dir, "classmap.json"), '{"leaf":"hashValueAA"}\n'); - writeFileSync(join(dir, "report.json"), "not json\n"); - const result = spawnSync(process.execPath, [verifier, "--out-dir", dir, "--timeout-ms", "1"], { - encoding: "utf8", - }); - assert.doesNotMatch(result.stderr, /Unexpected token|JSON/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("semantic class conjunction is mapped token by token", () => { - assert.match(source, /semanticClassName\(hash, cssMap\)/); - assert.match(source, /split\(\/\\s\+\/\).*cssMap\[token\] \|\| token/s); -}); - -test("navigation success excludes explicit failure sentinels", () => { - assert.match(source, /function navigationSucceeded\(result\)/); - assert.match(source, /nav-failed\|context-menu-dispatched\|no-track-row\|sort-not-found\|no-settings-scroll-target/); - assert.match(source, /if \(!navigationSucceeded\(navResult\)\)/); -}); - -for (const args of [ - ["--mode", "invalid"], - ["--min-hit-rate", "NaN"], - ["--min-hit-rate", "2"], - ["--timeout-ms", "0"], - ["--port", "70000"], -]) { - test(`rejects invalid arguments: ${args.join(" ")}`, () => { - const result = spawnSync(process.execPath, [verifier, "--classmap", "/tmp/map.json", "--out", "/tmp/out.json", ...args], { - encoding: "utf8", - }); - assert.equal(result.status, 1); - assert.match(result.stderr, /invalid/i); - }); -} diff --git a/src/jsHelper/modularLoader/boot-order.test.mts b/src/jsHelper/modularLoader/boot-order.test.mts index 046c9f07f0..5418464424 100644 --- a/src/jsHelper/modularLoader/boot-order.test.mts +++ b/src/jsHelper/modularLoader/boot-order.test.mts @@ -1,22 +1,10 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; -const loaderSource = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); const captureModule = await import("./webpackCapture.ts").catch(() => undefined); const queueModule = await import("../shared/webpackChunkQueue.js"); describe("modular loader boot order", () => { - it("waits for webpack capture before loading modules", () => { - assert.match(loaderSource, /await captureWebpackRequire\(\);\s*await registry\.runLoads\(report\);/); - }); - - it("boots Spotify 1.3 from its direct xpui bundle", () => { - assert.match(loaderSource, /__SPICETIFY_CLIENT_BUNDLE_MODE__/); - assert.match(loaderSource, /kind:\s*["']direct["']/); - assert.match(loaderSource, /bundle:\s*["']\/xpui\.js["']/); - }); - it("selects only a queue whose runtime is ready", () => { const unready = []; const scoped = []; @@ -31,7 +19,6 @@ describe("modular loader boot order", () => { }); it("reports success only after the rspack callback supplies webpack require", async () => { - assert.equal(typeof captureModule?.captureWebpackRequire, "function"); let now = 0; let runtime: ((require: unknown) => unknown) | undefined; let captured: unknown; @@ -56,7 +43,6 @@ describe("modular loader boot order", () => { }); it("times out when queue push never invokes the runtime callback", async () => { - assert.equal(typeof captureModule?.captureWebpackRequire, "function"); let now = 0; const ok = await captureModule!.captureWebpackRequire({ maxWaitMs: 20, diff --git a/src/jsHelper/modularLoader/types.ts b/src/jsHelper/modularLoader/types.ts index e40a0e02d8..819152a8d8 100644 --- a/src/jsHelper/modularLoader/types.ts +++ b/src/jsHelper/modularLoader/types.ts @@ -39,6 +39,7 @@ export interface ModulesManifest { // Apply-time environment facts (absent in manifests from older CLIs). cliVersion?: string; updatesBlocked?: boolean; + managedSpotify?: "stable" | "testing"; classmapSpotify?: string; classmapVerified?: boolean; supportedSpotify?: string; diff --git a/src/jsHelper/spicetifyWrapper/platform.js b/src/jsHelper/spicetifyWrapper/platform.js index b540f781ee..951c7b5244 100644 --- a/src/jsHelper/spicetifyWrapper/platform.js +++ b/src/jsHelper/spicetifyWrapper/platform.js @@ -10,6 +10,7 @@ import { uninstallStaged, updateAndApplySupported as daemonUpdateAndApplySupported, } from "./shared/daemonRpc.js"; +import { managedSpotify } from "./shared/managedSpotify.js"; import { installUpdateJobBridge, updateAndApply, updateApiSupported } from "./shared/updateAndApply.js"; const updateAndApplySupported = async () => { @@ -36,6 +37,7 @@ Object.assign(Spicetify.Daemon, { uninstallStaged, updateAndApplySupported, updateAndApply, + managedSpotify, }); installUpdateJobBridge(() => Spicetify.Platform); diff --git a/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js new file mode 100644 index 0000000000..5974da20cd --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js @@ -0,0 +1,20 @@ +const ROOT = "http://127.0.0.1:7967/spotify"; + +const request = async (suffix = "", method = "GET") => { + const token = globalThis.__SPICETIFY_DAEMON_TOKEN__; + if (!token) throw new Error("no daemon token: apply Spicetify first"); + const response = await fetch(`${ROOT}${suffix}`, { + method, + headers: { "x-spicetify-token": token }, + signal: AbortSignal.timeout(suffix === "/check" ? 90000 : 15000), + }); + if (response.status === 404 && method === "GET") return null; + if (!response.ok) throw new Error((await response.text()).trim() || "Spotify package request failed"); + return response.json(); +}; + +export const managedSpotify = { + status: () => request(), + check: () => request("/check", "POST"), + update: () => request("/update", "POST"), +}; diff --git a/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts new file mode 100644 index 0000000000..9f7c900dcd --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { managedSpotify } from "./managedSpotify.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + delete globalThis.__SPICETIFY_DAEMON_TOKEN__; +}); + +test("managed package calls authenticate and never use Spotify's native updater", async () => { + globalThis.__SPICETIFY_DAEMON_TOKEN__ = "test-token"; + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push([String(url), options.method, options.headers["x-spicetify-token"]]); + return Response.json({ kind: "current", version: "1.2.96" }); + }; + await managedSpotify.status(); + await managedSpotify.check(); + await managedSpotify.update(); + assert.deepEqual(calls, [ + ["http://127.0.0.1:7967/spotify", "GET", "test-token"], + ["http://127.0.0.1:7967/spotify/check", "POST", "test-token"], + ["http://127.0.0.1:7967/spotify/update", "POST", "test-token"], + ]); +}); + +test("missing daemon capability degrades only the status probe", async () => { + globalThis.__SPICETIFY_DAEMON_TOKEN__ = "test-token"; + globalThis.fetch = async () => new Response("unsupported", { status: 404 }); + assert.equal(await managedSpotify.status(), null); + await assert.rejects(managedSpotify.update(), /unsupported/); +}); + +test("a missing token never sends an update request", async () => { + globalThis.fetch = async () => { + throw new Error("must not fetch"); + }; + await assert.rejects(managedSpotify.update(), /no daemon token/); +}); diff --git a/src/utils/classmap.go b/src/utils/classmap.go index 7e5d596527..c2d42bb8a1 100644 --- a/src/utils/classmap.go +++ b/src/utils/classmap.go @@ -309,7 +309,7 @@ func LoadClassmapForKey(classmapKey string) (Classmap, string, error) { } // CssMapOverlayFileName is the flat hash -> semantic overlay generated from a -// classmap (scripts/classmap_capture.py flatten), stored next to it. +// classmap (scripts/classmap-capture.ts flatten), stored next to it. const CssMapOverlayFileName = "css-map.json" // FindCssMapOverlayIn searches roots in order for classmaps//css-map.json.