From 59f04f3f992080cbca501784910210920aa5f2dc Mon Sep 17 00:00:00 2001 From: Shix Sun Date: Thu, 6 Aug 2026 11:10:05 +0000 Subject: [PATCH 1/2] Add release pipeline: working installer, CI, and distribution docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amber had no way for anyone to actually install it — no Releases, no installer, and the packaging work that existed was uncommitted and unbuildable. This makes the project distributable end to end. Installer (installer/Amber.nsi) — was a scaffold whose detection logic was placeholder code. DetectFoobar2000Page hardcoded C:\Program Files\ foobar2000 (with a "REMOVE THIS before production" note) and ComponentCheckPage unconditionally set JSPLITTER_OK=1, so a compiled build would have installed to a fixed path regardless of where foobar2000 actually lives. Implemented for real: - locates foobar2000 via registry, App Paths, then conventional dirs, with a Browse fallback and live validation feedback - rejects pre-2.0 via the exe's version resource - resolves portable vs roaming profiles, including v1-era profile dirs - probes user-components-x64/foo_uie_jsplitter, which settles both architecture and the required-component check in one test; an x86 profile now gets a specific message instead of "component missing" - reports optional components without blocking - backs up theme.fth and any previous Amber scripts before writing - checks profile writability up front rather than failing mid-install - uninstaller parses its recorded metadata instead of guessing the profile from $INSTDIR\.. theme.fth is still never overwritten — importing the layout is one click in foobar2000, while a bad automatic overwrite costs the user a layout they may have spent hours on. Build fixes: - OutFile pointed at ..\..\dist, one level above the File commands' ..\dist, so artefacts landed outside the repo entirely - branding assets were never generated, so MUI_ICON broke the compile - the version was hardcoded in the NSI, contradicting manifest.json's own single-source-of-truth rule; it now comes from a generated dist/version.nsh, and validate-release.py fails the build if a literal reappears - SHA256SUMS was written before makensis ran, so it never covered the installer; --checksums-only recomputes it afterwards CI: ci.yml validates and stages on Linux and compiles the installer on Windows, so packaging breakage surfaces on push rather than at tag time. release.yml publishes on tag, refusing to build if the tag and manifest.json disagree. Docs: README leads with a download section, INSTALL.md documents the installer path, and THIRD_PARTY_LICENSES.md records why no component is bundled — freely downloadable is not freely redistributable. Verified locally: validation passes 6/6, staging produces a clean 13-entry theme zip with no build artefacts, and makensis compiles a 544 KB installer. The detection logic has not yet been exercised against a real foobar2000 install on Windows. Co-Authored-By: Claude Opus 5 (1M context) --- .editorconfig | 32 ++ .gitattributes | 34 ++ .github/workflows/ci.yml | 77 ++++ .github/workflows/release.yml | 115 ++++++ .gitignore | 30 ++ INSTALL.md | 24 +- README.md | 28 +- THIRD_PARTY_LICENSES.md | 57 +++ amber-layouts/amber-standard.fth | Bin 0 -> 321511 bytes installer/Amber.nsi | 634 +++++++++++++++++++++++++++++++ installer/assets/make-assets.py | 190 +++++++++ manifest.json | 86 +++++ scripts/sanitize-layout.py | 150 ++++++++ scripts/stage-release.py | 231 +++++++++++ scripts/validate-release.py | 194 ++++++++++ 15 files changed, 1876 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 amber-layouts/amber-standard.fth create mode 100644 installer/Amber.nsi create mode 100644 installer/assets/make-assets.py create mode 100644 manifest.json create mode 100755 scripts/sanitize-layout.py create mode 100755 scripts/stage-release.py create mode 100755 scripts/validate-release.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b937353 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[*.{md,markdown}] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.py] +indent_style = space +indent_size = 4 + +[*.{nsi,nsh}] +indent_style = tab +# NSIS compiles on Windows toolchains; CRLF avoids diff noise there. +end_of_line = crlf + +# JSplitter loads panel scripts as UTF-8 *with BOM*. +# Dropping the BOM produces mojibake for all Chinese strings in foobar2000. +# Do not "normalise" this away. +[amber/**.js] +charset = utf-8-bom +end_of_line = lf +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2afc5a5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,34 @@ +# Default: let git normalise text files in the repo, check out per-platform. +* text=auto + +# Source files always stored with LF. +*.js text eol=lf +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.txt text eol=lf +.editorconfig text eol=lf + +# NSIS sources are consumed by makensis on Windows runners. +*.nsi text eol=crlf +*.nsh text eol=crlf + +# Binary payloads - never touch these. +*.fth binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.bmp binary +*.dll binary +*.exe binary +*.zip binary +*.7z binary +*.ttf binary +*.otf binary + +# Keep screenshots out of language statistics. +docs/** linguist-documentation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2b2e2ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +# Validation runs on Linux — every check in validate-release.py is +# platform-independent (BOM, line endings, JS syntax, path scanning). +# Only the NSIS compile needs Windows, and that lives in release.yml. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # validate-release.py shells out to `node --check` for JS syntax. + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate release rules + run: python3 scripts/validate-release.py + + - name: Stage release artefacts + run: python3 scripts/stage-release.py --clean + + # Catches drift between manifest.json and the installer: if the NSI + # stops matching the generated defines, the release build would fail + # at tag time instead of here. + - name: Check installer references generated version + run: | + grep -q 'dist\\version.nsh' installer/Amber.nsi \ + || (echo "Amber.nsi no longer includes the generated version.nsh" && exit 1) + grep -q 'PRODUCT_VERSION' dist/version.nsh \ + || (echo "version.nsh is missing PRODUCT_VERSION" && exit 1) + + - name: Upload theme-only zip + uses: actions/upload-artifact@v4 + with: + name: Amber-ThemeOnly + path: dist/*.zip + if-no-files-found: error + + # Compiles the installer on every push so packaging breakage surfaces + # immediately, rather than at tag time when it blocks a release. + build-installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Stage release artefacts + run: python scripts/stage-release.py --clean + + - name: Generate installer assets + run: python installer/assets/make-assets.py + + - name: Compile installer + run: makensis installer/Amber.nsi + + - name: Upload installer + uses: actions/upload-artifact@v4 + with: + name: Amber-Setup + path: dist/*.exe + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98003e6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release + +# Publishes a GitHub Release when a tag is pushed. The tag name (vX.Y.Z) must +# match the version in manifest.json, or the build fails. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to build (e.g., v1.3.0)' + required: true + +jobs: + build-and-release: + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Extract version from tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${{ github.ref_name }}" + fi + VERSION="${TAG#v}" + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Validate release rules + run: python scripts/validate-release.py + + - name: Check version matches tag + shell: bash + run: | + MANIFEST_VERSION=$(python -c "import json; print(json.load(open('manifest.json'))['version'])") + if [ "$MANIFEST_VERSION" != "$VERSION" ]; then + echo "Version mismatch: tag is $VERSION, manifest.json is $MANIFEST_VERSION" + exit 1 + fi + echo "Version $VERSION matches tag $TAG" + + - name: Stage release artefacts + run: python scripts/stage-release.py --clean + + - name: Generate installer assets + run: python installer/assets/make-assets.py + + - name: Compile installer + run: makensis installer/Amber.nsi + + # The installer only exists now, so checksums must be recomputed to + # cover it — staging ran before makensis and saw only the zip. + - name: Recompute checksums + run: python scripts/stage-release.py --checksums-only + + - name: Verify artefacts and collect checksums + shell: bash + run: | + ls -lh dist/*.zip dist/*.exe dist/SHA256SUMS.txt + test -s dist/SHA256SUMS.txt || (echo "SHA256SUMS.txt is empty" && exit 1) + grep -q '\.exe$' dist/SHA256SUMS.txt \ + || (echo "SHA256SUMS.txt does not cover the installer" && exit 1) + # Expose the file for the release body via a heredoc-delimited env var. + { + echo 'SHA256SUMS<> "$GITHUB_ENV" + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG }} + name: Amber ${{ env.VERSION }} + body: | + ## Installation + + **Installer** (recommended): + Download `Amber-Setup-x64-${{ env.VERSION }}.exe` and run it. It detects your foobar2000 installation, checks for JSplitter, and installs Amber's scripts + layout into your profile. + + **Manual** (theme-only zip): + Download `Amber-ThemeOnly-v${{ env.VERSION }}.zip`, extract the `amber/` and `amber-layouts/` folders into your foobar2000 profile directory, then import the layout from Preferences → Display → Default User Interface. + + Requires foobar2000 v2.0+ (x64) and [JSplitter](https://foobar2000.ru/forum/viewtopic.php?t=6378). See [INSTALL.md](https://github.com/${{ github.repository }}/blob/${{ env.TAG }}/INSTALL.md) for detailed instructions. + + ## Checksums + + ``` + ${{ env.SHA256SUMS }} + ``` + + --- + + Full changelog: [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ env.TAG }}/CHANGELOG.md) + files: | + dist/*.exe + dist/*.zip + dist/SHA256SUMS.txt + draft: false + prerelease: ${{ contains(env.VERSION, '-') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492f28d --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output - published as GitHub Release assets, never committed. +/build/ +/dist/ +/tmp/ + +# Packaged artefacts +*.exe +*.zip +*.7z +*.sha256 +SHA256SUMS.txt + +# Installer branding — deterministically regenerated by +# installer/assets/make-assets.py during every build, so the binaries +# themselves never need to be committed. +/installer/assets/*.ico +/installer/assets/*.bmp + +# Logs and scratch +*.log +.venv/ +__pycache__/ +*.pyc + +# Editor / OS noise +.vscode/ +.idea/ +.DS_Store +Thumbs.db +desktop.ini diff --git a/INSTALL.md b/INSTALL.md index bd47f60..c500932 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,11 +16,31 @@ foobar2000 独立主题:琥珀暖色 · 封面墙 · 极简播放卡。 ## 安装步骤 +### 方式一:安装器(推荐) + +从 [Releases](https://github.com/ShixSun/foobar2000-amber/releases/latest) 下载 +`Amber-Setup-x64-*.exe` 并运行。安装器会: + +1. 自动探测 foobar2000 安装位置(注册表 → App Paths → 常见路径),找不到可手动浏览 +2. 校验版本 ≥ 2.0、架构为 x64 +3. 判断便携版 / 标准版,定位对应 profile 目录 +4. 检查 JSplitter 是否就位——缺失则给出下载链接并中止(没有它 Amber 无法运行) +5. 报告可选组件状态(缺失不影响安装) +6. **备份现有 `theme.fth`** 到 `amber-backups\<时间戳>\`,然后写入脚本与布局 + +> 安装器**不会**自动覆盖你的 `theme.fth`。布局导入是 foobar2000 里的一次点击, +> 而自动覆盖一旦出错会毁掉你可能调了几小时的布局——所以这一步留给你自己决定。 + +装完后按结束页提示导入布局即可(见下方第 3 步)。卸载可通过「程序和功能」或 +profile 下的 `amber\Uninstall-Amber.exe`;备份目录不会被删除。 + +### 方式二:手动安装 + 1. 安装上述组件,重启 foobar2000 2. 把 `amber` 整个文件夹放进 foobar2000 的 **profile 目录** (便携版 = 安装目录下的 `profile\`;标准版 = `%appdata%\foobar2000-v2\`) -3. 关闭 foobar2000,用本仓库的 `theme.fth` 覆盖 profile 下的 `theme.fth` - (或在 参数选择 → 显示 → 默认用户界面 里导入主题) +3. 参数选择 → 显示 → 默认用户界面 → **导入主题**,选择 + `amber-layouts\amber-standard.fth` 4. 配色:参数选择 → 显示 → **颜色和字体** → 深色方案 → 自定义: - 背景 `32, 27, 20`(暖炭) - 高亮 `235, 165, 70`(琥珀) diff --git a/README.md b/README.md index 8f0fe34..7b78d99 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,24 @@ **foobar2000 琥珀主题 / An amber-toned, coverflow-centric theme for foobar2000 v2 (x64)** -> 墙负责"看",卡负责"读" —— 封面墙浏览 + 极简播放卡 + 全局琥珀暖色体系。 +[![GitHub release](https://img.shields.io/github/v/release/ShixSun/foobar2000-amber?include_prereleases&label=latest)](https://github.com/ShixSun/foobar2000-amber/releases/latest) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![foobar2000](https://img.shields.io/badge/foobar2000-v2.0%2B%20(x64)-orange)](https://www.foobar2000.org/) + +> 墙负责"看",卡负责"读" —— 封面墙浏览 + 极简播放卡 + 全局琥珀暖色体系。 > 全部面板为原创 JSplitter 脚本,由 [ShixSun](https://github.com/ShixSun) 与 Claude 共同设计打造。 ![预览图](docs/screenshot-main.png) +## 下载安装 / Download + +**[→ 最新版下载 / Latest Release](https://github.com/ShixSun/foobar2000-amber/releases/latest)** + +- **推荐:安装器** `Amber-Setup-x64-*.exe` — 自动探测 foobar2000、检查组件、一键安装 +- **手动:主题包** `Amber-ThemeOnly-v*.zip` — 解压到 profile 目录,手动导入布局 + +详细步骤见 **[INSTALL.md](INSTALL.md)** + ## 特性 - **封面墙**:伪 3D 轮播(倒影/缓动动画/高质量插值),滚轮翻页、双击播放, @@ -24,7 +37,8 @@ ## 快速开始 -见 [INSTALL.md](INSTALL.md)。核心三步:装 JSplitter → `amber\` 放进 profile → 导入 `theme.fth`。 +见 [INSTALL.md](INSTALL.md)。安装器会自动完成全部步骤;手动安装的核心三步: +装 JSplitter → `amber\` 放进 profile → 导入 `amber-layouts\amber-standard.fth`。 ## 定制 @@ -34,6 +48,7 @@ ## License [MIT](LICENSE) · 灵感致谢 [foobox](https://github.com/dream7180/foobox-cn),运行时依赖 JSplitter(请从官方渠道获取)。 +第三方组件说明见 [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md)。 --- @@ -47,5 +62,10 @@ codec/bitrate info and queue badges), and a **playlist sidebar** with a global s results panel. All panels are hand-written JSplitter scripts — no third-party theme dependencies. Colors follow foobar2000's own "Colors and Fonts" settings (suggested dark base `32,27,20` + highlight `235,165,70`), with amber accents layered -by the scripts. See [INSTALL.md](INSTALL.md) for setup. MIT licensed. -Designed by [ShixSun](https://github.com/ShixSun) & Claude, 2026. +by the scripts. + +**Install:** grab the installer from [Releases](https://github.com/ShixSun/foobar2000-amber/releases/latest), +or see [INSTALL.md](INSTALL.md) for the manual route. Requires foobar2000 v2.0+ (x64) +and [JSplitter](https://foobar2000.ru/forum/viewtopic.php?t=6378). + +MIT licensed. Designed by [ShixSun](https://github.com/ShixSun) & Claude, 2026. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..b158840 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,57 @@ +# Third-party components + +Amber itself is MIT licensed (see `LICENSE`). It is a set of JavaScript panels +and a layout — it contains no third-party code. + +## Nothing is bundled, and why + +The installer ships **only** Amber's own files. It does not redistribute +foobar2000 or any component, even though bundling would make installation a +single click. + +The reason is licensing, not laziness. Every component below is freeware +distributed from its author's own channel, and none of them publish terms that +clearly grant redistribution rights to third parties. "Freely downloadable" is +not the same as "freely redistributable". Rather than assume permission, Amber +detects what you already have, and links to the author's official download for +anything missing. + +This has a side benefit: you always get the component version the author +currently supports, instead of whatever was current when Amber was packaged. + +If you are the author of one of these components and would like Amber to bundle +it, please open an issue — explicit permission is all that is needed. + +## Required + +| Component | Role | Source | +|---|---|---| +| **JSplitter** (`foo_uie_jsplitter`) | The scripting host every Amber panel runs inside. Amber is completely non-functional without it. | | + +JSplitter is built on Spider Monkey Panel, which is itself derived from +WSH Panel Mod. Amber calls only the documented `fb` / `plman` / `window` / +`gdi` / `utils` interfaces those provide. + +## Optional + +Amber installs and runs without all of these. Each panel that depends on one +renders an install hint in its place, and playback is never affected. + +| Component | Role | Source | +|---|---|---| +| **ESLyric** (`foo_uie_eslyric`) | Lyrics pane in the default layout | | +| **Spectrum Analyzer** (`foo_vis_spectrum_analyzer`) | Spectrum visualisation in the sidebar | | +| **Waveform Minibar (mod)** (`foo_wave_minibar_mod`) | Waveform seekbar under the album card | | + +## foobar2000 + +foobar2000 is © Peter Pawłowski. Amber is an unaffiliated third-party theme. +It is not endorsed by, or associated with, the foobar2000 project. You must +install foobar2000 yourself from . + +## Acknowledgements + +Amber's panel code is original and carries no third-party theme dependencies. +The project was nonetheless inspired by [foobox](https://github.com/dream7180/foobox-cn) +(GPL-3.0), which demonstrated how far a foobar2000 DUI configuration can be +pushed. No foobox code is used, copied, or derived from. diff --git a/amber-layouts/amber-standard.fth b/amber-layouts/amber-standard.fth new file mode 100644 index 0000000000000000000000000000000000000000..9b66585284ba5c2fb6b29079e572bd4e2f691f4d GIT binary patch literal 321511 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 VU%qd{o&V`S$1dAz{7-O?{r_+eJJA3D literal 0 HcmV?d00001 diff --git a/installer/Amber.nsi b/installer/Amber.nsi new file mode 100644 index 0000000..c19f35c --- /dev/null +++ b/installer/Amber.nsi @@ -0,0 +1,634 @@ +; Amber for foobar2000 — NSIS Installer +; ============================================================================ +; Installs the Amber panel scripts + layout into an existing foobar2000 v2 x64 +; profile. It refuses to run if: +; - foobar2000 cannot be located +; - foobar2000 is older than v2.0 +; - JSplitter is not installed for x64 (Amber cannot function without it) +; +; It does NOT install foobar2000 itself, and does NOT bundle third-party +; components whose redistribution terms are unverified (JSplitter, ESLyric, +; Spectrum Analyzer, Waveform Minibar). If optional components are missing, +; the installer reports it and links to their official sources. +; +; SAFETY: backs up the existing layout before modifying anything. The installer +; never replaces theme.fth — the user imports the layout manually. See the +; "Layout policy" note in the install section. +; +; BUILD: run `python3 scripts/stage-release.py` first. It populates +; dist/staging/ (the payload) and generates dist/staging/version.nsh (the +; version + URL defines below). Then: +; makensis installer/Amber.nsi + +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "LogicLib.nsh" +!include "x64.nsh" +!include "nsDialogs.nsh" +!include "WinVer.nsh" + +; ============================================================================ +; Configuration +; ============================================================================ +; Generated from manifest.json by scripts/stage-release.py — the single source +; of truth for version and component URLs. Never hardcode the version here. +; It lives outside staging/ so it stays out of the user-facing theme zip. +!include "..\dist\version.nsh" + +; Installer metadata +Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" +OutFile "..\dist\Amber-Setup-x64-${PRODUCT_VERSION}.exe" +SetCompressor /SOLID lzma +Unicode true + +; Amber only ever writes inside the foobar2000 profile directory (%APPDATA% or +; a portable folder), so elevation is never required. If the profile turns out +; to be unwritable we tell the user rather than silently failing. +RequestExecutionLevel user + +; $INSTDIR is the directory the uninstaller lives in: \amber +InstallDir "$APPDATA\foobar2000-v2\amber" + +VIProductVersion "${PRODUCT_VERSION_QUAD}" +VIAddVersionKey "ProductName" "${PRODUCT_NAME}" +VIAddVersionKey "ProductVersion" "${PRODUCT_VERSION}" +VIAddVersionKey "FileVersion" "${PRODUCT_VERSION}" +VIAddVersionKey "CompanyName" "${PRODUCT_PUBLISHER}" +VIAddVersionKey "LegalCopyright" "${PRODUCT_PUBLISHER}" +VIAddVersionKey "FileDescription" "${PRODUCT_NAME} installer" + +; ============================================================================ +; UI +; ============================================================================ +!define MUI_ICON "assets\amber.ico" +!define MUI_UNICON "assets\amber.ico" +!define MUI_HEADERIMAGE +!define MUI_HEADERIMAGE_BITMAP "assets\header.bmp" +!define MUI_WELCOMEFINISHPAGE_BITMAP "assets\welcome.bmp" +!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets\welcome.bmp" +!define MUI_ABORTWARNING + +; ============================================================================ +; Variables +; ============================================================================ +Var FB2K_PATH ; foobar2000 root (e.g., C:\Program Files\foobar2000) +Var PROFILE_DIR ; profile root (portable or roaming) +Var IS_PORTABLE ; 1 if portable_mode_enabled is present +Var JSPLITTER_OK ; 1 if JSplitter x64 is present +Var FB2K_ARCH ; "x64", "x86" or "" if undetermined +Var FB2K_VERSION ; major.minor read from foobar2000.exe +Var BACKUP_TIMESTAMP ; e.g., 20260806-153000 +Var BACKUP_DIR ; full path to this run's backup folder ("" if none) +Var EXISTING_LAYOUT ; 1 if theme.fth existed before install + +; Detect page controls +Var Dlg +Var PathText +Var StatusLabel + +; Component page controls +Var OptMissing ; space-separated ids of missing optional components + +; ============================================================================ +; Pages +; ============================================================================ +!insertmacro MUI_PAGE_WELCOME +Page custom DetectPageCreate DetectPageLeave +Page custom ComponentPageCreate ComponentPageLeave +!insertmacro MUI_PAGE_INSTFILES + +!define MUI_FINISHPAGE_TITLE "Amber is installed" +!define MUI_FINISHPAGE_TEXT "Amber's scripts and layout are in place.$\r$\n$\r$\nTo apply the theme:$\r$\n 1. Open foobar2000$\r$\n 2. Preferences -> Display -> Default User Interface$\r$\n 3. Import theme, and choose:$\r$\n $PROFILE_DIR\amber-layouts\amber-standard.fth$\r$\n$\r$\nYour previous layout was backed up and is safe to restore at any time." +!define MUI_FINISHPAGE_SHOWREADME "" +!define MUI_FINISHPAGE_SHOWREADME_TEXT "Open the folder containing the Amber layout" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION OpenLayoutsFolder +!define MUI_FINISHPAGE_LINK "Amber on GitHub" +!define MUI_FINISHPAGE_LINK_LOCATION "${PRODUCT_WEB_SITE}" +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +; ============================================================================ +; Detection helpers +; ============================================================================ + +; Accepts a candidate directory in $R0. If it holds foobar2000.exe and we have +; not settled on a path yet, adopt it. Used to walk the registry/path probes in +; priority order without repeating the existence check at every call site. +Function TryCandidate + ${If} $FB2K_PATH != "" + Return + ${EndIf} + ${If} $R0 == "" + Return + ${EndIf} + IfFileExists "$R0\foobar2000.exe" 0 +2 + StrCpy $FB2K_PATH "$R0" +FunctionEnd + +; Populates $FB2K_PATH by probing, in order: the registry keys foobar2000 +; writes on install, the App Paths entry, then the conventional locations. +Function DetectFb2k + StrCpy $FB2K_PATH "" + + ReadRegStr $R0 HKLM "SOFTWARE\foobar2000" "InstallDir" + Call TryCandidate + ReadRegStr $R0 HKCU "SOFTWARE\foobar2000" "InstallDir" + Call TryCandidate + + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\foobar2000" "InstallLocation" + Call TryCandidate + ReadRegStr $R0 HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\foobar2000" "InstallLocation" + Call TryCandidate + + ; App Paths stores the full path to the executable, so step up one level. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\foobar2000.exe" "" + ${If} $R1 != "" + ${GetParent} "$R1" $R0 + Call TryCandidate + ${EndIf} + + StrCpy $R0 "$PROGRAMFILES64\foobar2000" + Call TryCandidate + StrCpy $R0 "$PROGRAMFILES\foobar2000" + Call TryCandidate + StrCpy $R0 "C:\foobar2000" + Call TryCandidate +FunctionEnd + +; Reads the version resource of foobar2000.exe into $FB2K_VERSION ("2.24"), +; and leaves the major version in $R2 for the caller's comparison. +Function ReadFb2kVersion + StrCpy $FB2K_VERSION "" + StrCpy $R2 0 + IfFileExists "$FB2K_PATH\foobar2000.exe" 0 done + ClearErrors + GetDLLVersion "$FB2K_PATH\foobar2000.exe" $R0 $R1 + ${If} ${Errors} + Goto done + ${EndIf} + IntOp $R2 $R0 >> 16 + IntOp $R2 $R2 & 0xFFFF ; major + IntOp $R3 $R0 & 0xFFFF ; minor + StrCpy $FB2K_VERSION "$R2.$R3" + done: +FunctionEnd + +; Resolves $PROFILE_DIR and $IS_PORTABLE. +; +; foobar2000 keeps its profile next to the executable when portable_mode_enabled +; is present, otherwise under %APPDATA%. v2 uses the "foobar2000-v2" folder, but +; a profile carried over from v1 can still be named "foobar2000" — prefer the v2 +; folder and fall back only when the v1 one looks like a real x64 v2 profile. +Function ResolveProfile + StrCpy $IS_PORTABLE "0" + StrCpy $PROFILE_DIR "" + + IfFileExists "$FB2K_PATH\portable_mode_enabled" 0 roaming + StrCpy $IS_PORTABLE "1" + StrCpy $PROFILE_DIR "$FB2K_PATH\profile" + Return + + roaming: + IfFileExists "$APPDATA\foobar2000-v2\*.*" 0 +3 + StrCpy $PROFILE_DIR "$APPDATA\foobar2000-v2" + Return + + IfFileExists "$APPDATA\foobar2000\user-components-x64\*.*" 0 +3 + StrCpy $PROFILE_DIR "$APPDATA\foobar2000" + Return + + ; Nothing exists yet — foobar2000 v2 has never been run. Point at the + ; canonical v2 location; the install section creates it. + StrCpy $PROFILE_DIR "$APPDATA\foobar2000-v2" +FunctionEnd + +; Determines architecture and JSplitter availability in one pass. +; +; JSplitter living in user-components-x64 is proof of an x64 v2 profile, which +; is exactly what Amber needs — so a single probe answers both questions. A hit +; in the 32-bit user-components folder means the user is running x86 and needs +; to be told so explicitly rather than seeing a vague "component missing". +Function DetectComponents + StrCpy $JSPLITTER_OK "0" + StrCpy $FB2K_ARCH "" + + IfFileExists "$PROFILE_DIR\user-components-x64\foo_uie_jsplitter\*.*" 0 +4 + StrCpy $JSPLITTER_OK "1" + StrCpy $FB2K_ARCH "x64" + Return + + IfFileExists "$PROFILE_DIR\user-components-x64\*.*" 0 +2 + StrCpy $FB2K_ARCH "x64" + + IfFileExists "$PROFILE_DIR\user-components\foo_uie_jsplitter\*.*" 0 +2 + StrCpy $FB2K_ARCH "x86" +FunctionEnd + +; Appends $R9 to $OptMissing when the named component folder is absent. +!macro CHECK_OPTIONAL id + IfFileExists "$PROFILE_DIR\user-components-x64\${id}\*.*" +2 0 + StrCpy $OptMissing "$OptMissing ${id}" +!macroend + +; ============================================================================ +; Custom Page: locate foobar2000 +; ============================================================================ +Function DetectPageCreate + !insertmacro MUI_HEADER_TEXT "Locate foobar2000" \ + "Amber installs into your foobar2000 v2 (x64) profile." + + Call DetectFb2k + ${If} $FB2K_PATH != "" + Call ResolveProfile + ${EndIf} + + nsDialogs::Create 1018 + Pop $Dlg + ${If} $Dlg == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0 0 100% 24u \ + "Select the folder where foobar2000 is installed. Amber writes only into \ +that installation's profile folder — the player itself is left untouched." + Pop $R0 + + ${NSD_CreateText} 0 30u 78% 13u "$FB2K_PATH" + Pop $PathText + + ${NSD_CreateButton} 80% 29u 20% 15u "Browse..." + Pop $R0 + ${NSD_OnClick} $R0 OnBrowse + + ${NSD_CreateLabel} 0 52u 100% 60u "" + Pop $StatusLabel + + Call RefreshStatus + nsDialogs::Show +FunctionEnd + +Function OnBrowse + nsDialogs::SelectFolderDialog "Select your foobar2000 folder" "$FB2K_PATH" + Pop $R0 + ${If} $R0 != error + StrCpy $FB2K_PATH "$R0" + ${NSD_SetText} $PathText "$FB2K_PATH" + Call ResolveProfile + Call RefreshStatus + ${EndIf} +FunctionEnd + +; Re-runs validation and rewrites the status block. Called on page creation and +; after every browse, so the message always describes the current selection. +Function RefreshStatus + ${If} $FB2K_PATH == "" + SetCtlColors $StatusLabel 0xB00020 transparent + ${NSD_SetText} $StatusLabel "foobar2000 was not found automatically.$\r$\nUse Browse to select the folder containing foobar2000.exe." + Return + ${EndIf} + + IfFileExists "$FB2K_PATH\foobar2000.exe" 0 not_fb2k + + Call ReadFb2kVersion + Call ResolveProfile + Call DetectComponents + + ${If} $R2 < 2 + ${AndIf} $FB2K_VERSION != "" + SetCtlColors $StatusLabel 0xB00020 transparent + ${NSD_SetText} $StatusLabel "Found foobar2000 $FB2K_VERSION.$\r$\nAmber requires version ${FB2K_MIN_VERSION} or newer." + Return + ${EndIf} + + ${If} $FB2K_ARCH == "x86" + SetCtlColors $StatusLabel 0xB00020 transparent + ${NSD_SetText} $StatusLabel "This looks like a 32-bit (x86) foobar2000 profile.$\r$\nAmber requires the 64-bit build of foobar2000 v2." + Return + ${EndIf} + + StrCpy $R1 "" + ${If} $IS_PORTABLE == "1" + StrCpy $R1 "portable" + ${Else} + StrCpy $R1 "standard" + ${EndIf} + + SetCtlColors $StatusLabel 0x1F6F3C transparent + ${NSD_SetText} $StatusLabel "foobar2000 $FB2K_VERSION detected ($R1 installation).$\r$\n$\r$\nProfile folder:$\r$\n$PROFILE_DIR" + Return + + not_fb2k: + SetCtlColors $StatusLabel 0xB00020 transparent + ${NSD_SetText} $StatusLabel "foobar2000.exe was not found in:$\r$\n$FB2K_PATH" +FunctionEnd + +Function DetectPageLeave + ${NSD_GetText} $PathText $FB2K_PATH + + ${If} $FB2K_PATH == "" + MessageBox MB_ICONEXCLAMATION "Please select your foobar2000 folder." + Abort + ${EndIf} + + IfFileExists "$FB2K_PATH\foobar2000.exe" 0 bad_path + + Call ReadFb2kVersion + ${If} $FB2K_VERSION != "" + ${AndIf} $R2 < 2 + MessageBox MB_ICONSTOP "Amber requires foobar2000 ${FB2K_MIN_VERSION} or newer.$\r$\nThe selected installation reports version $FB2K_VERSION." + Abort + ${EndIf} + + Call ResolveProfile + Call DetectComponents + + ${If} $FB2K_ARCH == "x86" + MessageBox MB_ICONSTOP "Amber requires the 64-bit build of foobar2000 v2.$\r$\nThe selected profile contains 32-bit components." + Abort + ${EndIf} + + StrCpy $INSTDIR "$PROFILE_DIR\amber" + Return + + bad_path: + MessageBox MB_ICONSTOP "foobar2000.exe was not found in:$\r$\n$FB2K_PATH" + Abort +FunctionEnd + +; ============================================================================ +; Custom Page: component status +; ============================================================================ +Function ComponentPageCreate + !insertmacro MUI_HEADER_TEXT "Components" \ + "Amber runs on JSplitter. Optional components add extra panels." + + Call DetectComponents + StrCpy $OptMissing "" + !insertmacro CHECK_OPTIONAL "foo_uie_eslyric" + !insertmacro CHECK_OPTIONAL "foo_vis_spectrum_analyzer" + !insertmacro CHECK_OPTIONAL "foo_wave_minibar_mod" + + nsDialogs::Create 1018 + Pop $Dlg + ${If} $Dlg == error + Abort + ${EndIf} + + ${If} $JSPLITTER_OK == "1" + ${NSD_CreateLabel} 0 0 100% 12u "Required: JSplitter — installed" + Pop $R0 + SetCtlColors $R0 0x1F6F3C transparent + ${Else} + ${NSD_CreateLabel} 0 0 100% 24u \ + "Required: JSplitter — NOT INSTALLED$\r$\nAmber cannot load a single panel without it. Install JSplitter, then run this installer again." + Pop $R0 + SetCtlColors $R0 0xB00020 transparent + + ${NSD_CreateLink} 0 26u 100% 12u "Download JSplitter" + Pop $R0 + ${NSD_OnClick} $R0 OnJSplitterLink + ${EndIf} + + ${If} $OptMissing == "" + ${NSD_CreateLabel} 0 48u 100% 24u \ + "Optional: ESLyric, Spectrum Analyzer and Waveform Minibar are all installed. The default layout will use every panel." + Pop $R0 + SetCtlColors $R0 0x1F6F3C transparent + ${Else} + ${NSD_CreateLabel} 0 48u 100% 46u \ + "Optional components missing:$\r$\n $OptMissing$\r$\n$\r$\nAmber installs fine without them — the panels that need them show an install hint instead, and playback is unaffected." + Pop $R0 + + ${NSD_CreateLink} 0 96u 100% 12u "Where to get the optional components" + Pop $R0 + ${NSD_OnClick} $R0 OnOptionalLink + ${EndIf} + + nsDialogs::Show +FunctionEnd + +Function OnJSplitterLink + ExecShell "open" "${JSPLITTER_URL}" +FunctionEnd + +Function OnOptionalLink + ExecShell "open" "${PRODUCT_WEB_SITE}/blob/main/INSTALL.md" +FunctionEnd + +Function ComponentPageLeave + ${If} $JSPLITTER_OK == "0" + MessageBox MB_ICONSTOP "JSplitter is required.$\r$\nInstall it in foobar2000 first, then run this installer again." + Abort + ${EndIf} +FunctionEnd + +; ============================================================================ +; Install +; ============================================================================ +Section "Install" SEC01 + ${If} $JSPLITTER_OK == "0" + MessageBox MB_ICONSTOP "Amber requires JSplitter to function.$\r$\nPlease install JSplitter first, then run this installer again." + Abort + ${EndIf} + + ; --- 0. Make sure we can actually write to the profile -------------------- + CreateDirectory "$PROFILE_DIR" + ClearErrors + FileOpen $0 "$PROFILE_DIR\.amber-write-test" w + ${If} ${Errors} + MessageBox MB_ICONSTOP "The profile folder is not writable:$\r$\n$PROFILE_DIR$\r$\n$\r$\nIf foobar2000 is installed in Program Files in portable mode, re-run this installer as Administrator." + Abort + ${EndIf} + FileClose $0 + Delete "$PROFILE_DIR\.amber-write-test" + + ; --- 1. Back up the existing layout -------------------------------------- + DetailPrint "Backing up existing layout..." + ${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6 + StrCpy $BACKUP_TIMESTAMP "$2$1$0-$4$5$6" + StrCpy $BACKUP_DIR "" + StrCpy $EXISTING_LAYOUT "0" + + IfFileExists "$PROFILE_DIR\theme.fth" 0 no_layout + StrCpy $EXISTING_LAYOUT "1" + StrCpy $BACKUP_DIR "$PROFILE_DIR\amber-backups\$BACKUP_TIMESTAMP" + CreateDirectory "$BACKUP_DIR" + CopyFiles /SILENT "$PROFILE_DIR\theme.fth" "$BACKUP_DIR\theme.fth" + DetailPrint "Previous layout backed up to $BACKUP_DIR" + no_layout: + + ; A previous Amber install leaves scripts behind; back those up too so a + ; user who edited the tunables at the top of a panel can get them back. + IfFileExists "$PROFILE_DIR\amber\*.js" 0 no_scripts + ${If} $BACKUP_DIR == "" + StrCpy $BACKUP_DIR "$PROFILE_DIR\amber-backups\$BACKUP_TIMESTAMP" + CreateDirectory "$BACKUP_DIR" + ${EndIf} + CreateDirectory "$BACKUP_DIR\amber" + CopyFiles /SILENT "$PROFILE_DIR\amber\*.js" "$BACKUP_DIR\amber" + DetailPrint "Previous Amber scripts backed up to $BACKUP_DIR\amber" + no_scripts: + + ; --- 2. Panel scripts ----------------------------------------------------- + DetailPrint "Installing Amber panels..." + SetOutPath "$PROFILE_DIR\amber" + File "..\dist\staging\amber\amber-lib.js" + File "..\dist\staging\amber\coverflow.js" + File "..\dist\staging\amber\albumcard.js" + File "..\dist\staging\amber\playlists.js" + File "..\dist\staging\amber\controls.js" + File "..\dist\staging\amber\README.md" + + ; --- 3. Layouts ----------------------------------------------------------- + SetOutPath "$PROFILE_DIR\amber-layouts" + File "..\dist\staging\amber-layouts\amber-standard.fth" + + ; --- 4. Docs -------------------------------------------------------------- + SetOutPath "$PROFILE_DIR\amber" + File "..\dist\staging\README.md" + File "..\dist\staging\INSTALL.md" + File "..\dist\staging\CHANGELOG.md" + File "..\dist\staging\LICENSE" + File "..\dist\staging\manifest.json" + IfFileExists "..\dist\staging\THIRD_PARTY_LICENSES.md" 0 +2 + File "..\dist\staging\THIRD_PARTY_LICENSES.md" + + ; --- 5. Layout policy ----------------------------------------------------- + ; The installer deliberately does NOT overwrite theme.fth. Importing the + ; layout is a one-click action inside foobar2000, whereas a bad automatic + ; overwrite costs the user a layout they may have spent hours on. The finish + ; page tells them exactly which file to import. + ; + ; An opt-in "apply Amber now" checkbox is the natural next step once the + ; backup path above has seen real-world use. + + ; --- 6. Uninstaller ------------------------------------------------------- + SetOutPath "$PROFILE_DIR\amber" + WriteUninstaller "$PROFILE_DIR\amber\Uninstall-Amber.exe" + + FileOpen $0 "$PROFILE_DIR\amber\.install-meta.txt" w + FileWrite $0 "PROFILE_DIR=$PROFILE_DIR$\r$\n" + FileWrite $0 "BACKUP_TIMESTAMP=$BACKUP_TIMESTAMP$\r$\n" + FileWrite $0 "EXISTING_LAYOUT=$EXISTING_LAYOUT$\r$\n" + FileWrite $0 "VERSION=${PRODUCT_VERSION}$\r$\n" + FileClose $0 + + ; Register in Programs and Features, scoped to the current user because the + ; installer never elevates. + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "InstallLocation" "$PROFILE_DIR\amber" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "UninstallString" "$\"$PROFILE_DIR\amber\Uninstall-Amber.exe$\"" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" \ + "NoRepair" 1 + + DetailPrint "Amber ${PRODUCT_VERSION} installed to $PROFILE_DIR" +SectionEnd + +Function OpenLayoutsFolder + ExecShell "open" "$PROFILE_DIR\amber-layouts" +FunctionEnd + +; ============================================================================ +; Uninstaller +; ============================================================================ + +; Reads "KEY=VALUE" from $R0 into $R1, trimming the trailing CRLF that FileRead +; keeps. Returns "" when the line does not carry the expected key. +!macro READ_META_VALUE key + Push $R2 + StrLen $R2 "${key}=" + StrCpy $R3 $R0 $R2 + ${If} $R3 == "${key}=" + StrCpy $R1 $R0 "" $R2 + ; strip trailing CR/LF + ${Do} + StrCpy $R2 $R1 "" -1 + ${If} $R2 == "$\r" + ${OrIf} $R2 == "$\n" + StrCpy $R1 $R1 -1 + ${Else} + ${ExitDo} + ${EndIf} + ${Loop} + ${Else} + StrCpy $R1 "" + ${EndIf} + Pop $R2 +!macroend + +Function un.onInit + StrCpy $PROFILE_DIR "" +FunctionEnd + +Section "Uninstall" + ; $INSTDIR is \amber. Read the recorded profile path rather than + ; inferring it, so a relocated profile still uninstalls cleanly. + IfFileExists "$INSTDIR\.install-meta.txt" 0 meta_missing + ClearErrors + FileOpen $9 "$INSTDIR\.install-meta.txt" r + ${If} ${Errors} + Goto meta_missing + ${EndIf} + ${Do} + ClearErrors + FileRead $9 $R0 + ${If} ${Errors} + ${ExitDo} + ${EndIf} + !insertmacro READ_META_VALUE "PROFILE_DIR" + ${If} $R1 != "" + StrCpy $PROFILE_DIR $R1 + ${EndIf} + ${Loop} + FileClose $9 + + meta_missing: + ${If} $PROFILE_DIR == "" + ${GetParent} "$INSTDIR" $PROFILE_DIR + ${EndIf} + + ; --- 1. Remove Amber's own files ----------------------------------------- + ; Scoped to the two directories Amber owns. amber-backups is deliberately + ; left in place — it holds the user's pre-Amber layout. + Delete "$PROFILE_DIR\amber\*.js" + Delete "$PROFILE_DIR\amber\README.md" + Delete "$PROFILE_DIR\amber\INSTALL.md" + Delete "$PROFILE_DIR\amber\CHANGELOG.md" + Delete "$PROFILE_DIR\amber\LICENSE" + Delete "$PROFILE_DIR\amber\manifest.json" + Delete "$PROFILE_DIR\amber\THIRD_PARTY_LICENSES.md" + Delete "$PROFILE_DIR\amber\.install-meta.txt" + RMDir /r "$PROFILE_DIR\amber-layouts" + + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Amber" + + ; --- 2. Layout policy ----------------------------------------------------- + ; theme.fth is left exactly as-is. The user may have kept editing it after + ; installing Amber, so silently reverting it would destroy their work. The + ; message below tells them where the backup is if they want it back. + + ; --- 3. Remove the uninstaller itself ------------------------------------ + Delete "$PROFILE_DIR\amber\Uninstall-Amber.exe" + RMDir "$PROFILE_DIR\amber" + + IfFileExists "$PROFILE_DIR\amber-backups\*.*" 0 +3 + MessageBox MB_OK "Amber has been uninstalled.$\r$\n$\r$\nYour previous layout is still backed up at:$\r$\n$PROFILE_DIR\amber-backups$\r$\n$\r$\nTo restore it, import that theme.fth from Preferences -> Display -> Default User Interface." + Goto done + MessageBox MB_OK "Amber has been uninstalled." + done: +SectionEnd diff --git a/installer/assets/make-assets.py b/installer/assets/make-assets.py new file mode 100644 index 0000000..8b67544 --- /dev/null +++ b/installer/assets/make-assets.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Generate installer branding assets with zero image-library dependencies. + +Writes: + amber.ico multi-size icon (16/32/48/64/128/256) in amber tones + header.bmp 150x57 24-bit BMP MUI header strip + welcome.bmp 164x314 24-bit BMP MUI welcome/finish sidebar + +Everything is emitted as uncompressed BGRA/BGR so no PIL, no ImageMagick. +The artwork is a rounded amber disc with a coverflow-style reflected card, +matching the Amber palette used by the panels. +""" + +import math +import struct +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +# Amber palette, kept in sync with amber/amber-lib.js +BG_DARK = (0x14, 0x10, 0x0C) +AMBER = (0xFF, 0xB0, 0x3A) +AMBER_DEEP = (0xC8, 0x7A, 0x14) +AMBER_LIGHT = (0xFF, 0xD9, 0x8C) +CARD_DARK = (0x2A, 0x20, 0x16) + + +def lerp(a, b, t): + return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) + + +def render_icon(size): + """Return a size*size list of (r,g,b,a) — amber disc + album card glyph.""" + px = [(0, 0, 0, 0)] * (size * size) + buf = list(px) + c = (size - 1) / 2.0 + r_outer = size * 0.48 + r_inner = size * 0.30 + + for y in range(size): + for x in range(size): + dx, dy = x - c, y - c + dist = math.hypot(dx, dy) + + if dist > r_outer: + continue + + # Radial amber gradient for the disc. + t = min(1.0, dist / r_outer) + col = lerp(AMBER_LIGHT, AMBER_DEEP, t ** 0.85) + + # Antialias the rim. + alpha = 255 + edge = r_outer - dist + if edge < 1.2: + alpha = int(255 * max(0.0, edge / 1.2)) + + # Inner "album card": a rotated square hole showing dark bg, + # evoking the coverflow card at centre stage. + card = size * 0.20 + rx = dx * 0.9239 + dy * 0.3827 # rotate ~22.5deg + ry = -dx * 0.3827 + dy * 0.9239 + if abs(rx) < card and abs(ry) < card: + inset = min(card - abs(rx), card - abs(ry)) + k = min(1.0, inset / max(1.0, size * 0.03)) + col = lerp(col, CARD_DARK, k) + # Small amber dot = "now playing". + if dist < size * 0.055: + col = AMBER + + buf[y * size + x] = (col[0], col[1], col[2], alpha) + + return buf + + +def ico_dir_entry(w, h, offset, length): + bw = 0 if w >= 256 else w + bh = 0 if h >= 256 else h + return struct.pack(" (r, g, b).""" + row_pad = (-(w * 3)) % 4 + pixel_bytes = (w * 3 + row_pad) * h + file_size = 14 + 40 + pixel_bytes + + out = bytearray() + out += b"BM" + struct.pack("8,} bytes (16/32/48/64/128/256, 32bpp)") + print(f"header.bmp {n_hdr:>8,} bytes (150x57, 24bpp)") + print(f"welcome.bmp {n_wel:>8,} bytes (164x314, 24bpp)") + + +if __name__ == "__main__": + main() diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cc8c3a5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,86 @@ +{ + "$comment": "Single source of truth for release metadata. Every build script, the NSIS installer and the CI workflows read version + component data from here. Do not hardcode the version anywhere else.", + "name": "Amber", + "displayName": "Amber for foobar2000", + "version": "1.3.0-dev", + "description": "An amber-toned, coverflow-centric interface suite for foobar2000 v2 (x64).", + "homepage": "https://github.com/ShixSun/foobar2000-amber", + "license": "MIT", + "publisher": "ShixSun", + "target": { + "os": "windows", + "minimumOsVersion": "10.0.14393", + "minimumOsName": "Windows 10 1607", + "foobar2000": { + "minimumVersion": "2.0", + "architecture": "x64" + } + }, + "components": { + "required": [ + { + "id": "foo_uie_jsplitter", + "name": "JSplitter", + "minimumVersion": "4.0.4", + "source": "https://foobar2000.ru/forum/viewtopic.php?t=6378", + "redistributable": "unverified", + "reason": "Runtime for every Amber panel. Without it no panel can load.", + "profileDir": "user-components-x64/foo_uie_jsplitter" + } + ], + "optional": [ + { + "id": "foo_uie_eslyric", + "name": "ESLyric", + "source": "https://github.com/ESLyric/release", + "redistributable": "unverified", + "reason": "Lyrics pane in the default layout.", + "degradesTo": "Lyrics pane renders an install hint instead." + }, + { + "id": "foo_vis_spectrum_analyzer", + "name": "Spectrum Analyzer", + "source": "https://www.foobar2000.org/components", + "redistributable": "unverified", + "reason": "Spectrum visualisation in the sidebar.", + "degradesTo": "Pane can be removed in layout editing mode." + }, + { + "id": "foo_wave_minibar_mod", + "name": "Waveform Minibar (mod)", + "source": "https://hydrogenaud.io/index.php?topic=79936", + "redistributable": "unverified", + "reason": "Waveform seekbar under the album card.", + "degradesTo": "Seekbar area stays empty; playback is unaffected." + } + ] + }, + "payload": { + "$comment": "Paths are relative to the foobar2000 profile directory. The installer owns exactly these paths and nothing else.", + "scriptDir": "amber", + "layoutDir": "amber-layouts", + "backupDir": "amber-backups", + "scripts": [ + "amber/amber-lib.js", + "amber/coverflow.js", + "amber/albumcard.js", + "amber/playlists.js", + "amber/controls.js" + ], + "layouts": [ + { + "id": "standard", + "file": "amber-layouts/amber-standard.fth", + "displayName": "Amber Standard", + "targetResolution": "1920x1080 and above", + "usesOptionalComponents": ["foo_uie_eslyric", "foo_vis_spectrum_analyzer", "foo_wave_minibar_mod"] + } + ] + }, + "validation": { + "$comment": "Enforced by scripts/validate-release.py. Release builds fail if any rule is violated.", + "requireUtf8Bom": ["amber/*.js"], + "forbidAbsolutePaths": true, + "requireLfEndings": ["amber/*.js"] + } +} diff --git a/scripts/sanitize-layout.py b/scripts/sanitize-layout.py new file mode 100755 index 0000000..4972fdf --- /dev/null +++ b/scripts/sanitize-layout.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Sanitise a foobar2000 .fth layout by stripping developer-machine absolute paths. + +WHY THIS IS BYTE-LENGTH PRESERVING +---------------------------------- +.fth is a binary container. Strings appear in two framings: + + 1. foobar2000 native config: uint32-LE length prefix + raw UTF-8 bytes + e.g. 15 00 00 00 "E:\\foobar2000\\profile" (0x15 = 21 = len) + + 2. protobuf-framed component config: varint tag + varint length + bytes + e.g. 2a 24 "E:\\BaiduNetdiskDownload\\12.jpg" (0x24 = 36 = len) + +In framing (2) the blob is *nested* — its length is also counted by one or more +parent length prefixes. Changing the byte length of a string would require +rewriting every ancestor length field. Getting that wrong silently corrupts the +layout, and the breakage only shows up inside foobar2000 on Windows. + +So this script only ever substitutes replacements of EXACTLY the same byte +length. File size is unchanged, every length prefix stays valid, and no offset +shifts. The script refuses to write output if the size changed. + +NOTE: this is damage control for an already-exported layout. The authoritative +fix is to re-export .fth from a clean foobar2000 profile that never had personal +paths in it. See docs/release-process.md. +""" + +import argparse +import re +import sys +from pathlib import Path + +# Replacement table. Keys are the leaked byte strings, values are neutral +# replacements of IDENTICAL byte length. Length equality is asserted at runtime. +# +# Rationale per entry: +# profile path -> a relative marker; foobar2000 recreates/ignores a stale +# profile hint, and the relative form leaks nothing. +# background jpg-> points inside the Amber asset folder. Amber ships no +# background image, so the panel simply draws no background +# instead of referencing someone's Baidu download folder. +REPLACEMENTS = { + rb"E:\foobar2000\profile": rb".\profile\amber-theme", + # NOTE: filename is 12黑场.jpg (U+9ED1 U+573A). Byte sequence is taken + # verbatim from the container, not transcribed by eye — an earlier attempt + # misread it as 12墨绿.jpg and the length assert below caught it. + b"E:\\BaiduNetdiskDownload\\12\xe9\xbb\x91\xe5\x9c\xba.jpg": rb"amber\assets\no-background-image.jpg", +} + +# Stop at the first byte that is not valid in a Windows path. \x80-\xff is allowed +# because paths may contain UTF-8 multi-byte characters, but a trailing lone +# continuation byte belonging to the *next* binary field must not be swallowed — +# hence we decode-validate matches before reporting them. +ABS_PATH_RE = re.compile(rb"[A-Z]:\\[^\x00-\x1f\"<>|*?]{2,}") + + +def _trim_to_valid_utf8(candidate: bytes) -> bytes: + """Drop trailing bytes that are not part of a decodable UTF-8 sequence. + + Binary containers place arbitrary bytes right after a string blob. A greedy + byte-regex will happily absorb them; decoding tells us where the real string + ends. + """ + while candidate: + try: + candidate.decode("utf-8") + return candidate + except UnicodeDecodeError: + candidate = candidate[:-1] + return candidate + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("source", type=Path, help="input .fth") + ap.add_argument("output", type=Path, help="output .fth") + ap.add_argument("--check", action="store_true", + help="report leaks and exit non-zero; write nothing") + args = ap.parse_args() + + if not args.source.exists(): + print(f"error: {args.source} not found", file=sys.stderr) + return 2 + + original = args.source.read_bytes() + + # Validate the replacement table before touching anything. + for old, new in REPLACEMENTS.items(): + if len(old) != len(new): + print(f"error: replacement length mismatch for {old!r}: " + f"{len(old)} != {len(new)}", file=sys.stderr) + return 2 + + leaks = sorted({_trim_to_valid_utf8(m) for m in ABS_PATH_RE.findall(original)}) + leaks = [leak for leak in leaks if leak] + if not leaks: + print("No absolute Windows paths found; nothing to do.") + if not args.check: + args.output.write_bytes(original) + return 0 + + print(f"Found {len(leaks)} distinct absolute path(s):") + for leak in leaks: + count = original.count(leak) + handled = "handled" if any(leak.startswith(k) for k in REPLACEMENTS) else "UNHANDLED" + print(f" [{handled}] {leak.decode('utf-8', 'replace')} ({count}x)") + + if args.check: + return 1 + + patched = original + total = 0 + for old, new in REPLACEMENTS.items(): + n = patched.count(old) + if n: + patched = patched.replace(old, new) + total += n + print(f" replaced {n}x {old.decode('utf-8', 'replace')!r}" + f" -> {new.decode('utf-8', 'replace')!r}") + + # Hard invariant: byte length must not change, or every downstream length + # prefix in the container is now wrong. + if len(patched) != len(original): + print(f"error: size changed {len(original)} -> {len(patched)}; refusing to write", + file=sys.stderr) + return 2 + + remaining = sorted({_trim_to_valid_utf8(m) for m in ABS_PATH_RE.findall(patched)}) + remaining = [r for r in remaining if r] + if remaining: + print("\nerror: absolute paths remain after sanitising:", file=sys.stderr) + for r in remaining: + print(f" {r.decode('utf-8', 'replace')}", file=sys.stderr) + print("Add them to REPLACEMENTS with an equal-byte-length replacement.", + file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(patched) + print(f"\nWrote {args.output}") + print(f" size unchanged: {len(patched)} bytes") + print(f" replacements: {total}") + print(f" absolute paths: 0") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/stage-release.py b/scripts/stage-release.py new file mode 100755 index 0000000..8046207 --- /dev/null +++ b/scripts/stage-release.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Stage Amber release artefacts into dist/. + +Produces: + dist/Amber-ThemeOnly-v.zip theme + layouts + docs, no third-party binaries + dist/staging/ flat tree the NSIS installer compiles from + dist/SHA256SUMS.txt checksums for every published artefact + +Version and file list come from manifest.json. Nothing is hardcoded here. + +Deliberately NOT bundled: JSplitter, ESLyric, Spectrum Analyzer, Waveform Minibar. +Their redistribution terms are unverified (see manifest.json -> components), so +the installer detects them and links to the official source instead. +""" + +import argparse +import hashlib +import json +import shutil +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" +STAGING = DIST / "staging" + +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +RESET = "\033[0m" + +# Docs shipped inside the theme-only zip. +DOC_FILES = ["README.md", "INSTALL.md", "CHANGELOG.md", "LICENSE", "manifest.json"] +OPTIONAL_DOC_FILES = ["THIRD_PARTY_LICENSES.md"] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--clean", action="store_true", help="wipe dist/ before staging") + ap.add_argument("--checksums-only", action="store_true", + help="only recompute SHA256SUMS.txt over existing dist/ artefacts " + "(run after makensis so the installer .exe is covered)") + args = ap.parse_args() + + if args.checksums_only: + return write_checksums() + + manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8")) + version = manifest["version"] + + print(f"{GREEN}Staging Amber v{version}{RESET}\n") + + if args.clean and DIST.exists(): + shutil.rmtree(DIST) + print(f" cleaned {DIST}") + + STAGING.mkdir(parents=True, exist_ok=True) + + # --- 1. Panel scripts ------------------------------------------------- + script_dir = manifest["payload"]["scriptDir"] + staged_scripts = STAGING / script_dir + staged_scripts.mkdir(parents=True, exist_ok=True) + + print("Staging panel scripts:") + for rel in manifest["payload"]["scripts"]: + src = ROOT / rel + if not src.exists(): + return fail(f"missing script declared in manifest: {rel}") + dst = STAGING / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + # Re-assert the BOM survived the copy; JSplitter needs it. + if not dst.read_bytes().startswith(b"\xef\xbb\xbf"): + return fail(f"{rel} lost its UTF-8 BOM during staging") + print(f" + {rel} ({src.stat().st_size:,} bytes, BOM ok)") + + # Ship the panel README alongside the scripts if present. + panel_readme = ROOT / script_dir / "README.md" + if panel_readme.exists(): + shutil.copy2(panel_readme, staged_scripts / "README.md") + print(f" + {script_dir}/README.md") + + # --- 2. Layouts ------------------------------------------------------- + print("\nStaging layouts:") + for layout in manifest["payload"]["layouts"]: + src = ROOT / layout["file"] + if not src.exists(): + return fail(f"missing layout: {layout['file']} " + f"(run scripts/sanitize-layout.py first)") + # Refuse to ship a layout that still leaks absolute paths. + blob = src.read_bytes() + import re + if re.search(rb"[A-Z]:\\[^\x00-\x1f\"<>|*?]{2,}", blob): + return fail(f"{layout['file']} still contains absolute Windows paths; " + f"run scripts/sanitize-layout.py") + dst = STAGING / layout["file"] + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + print(f" + {layout['file']} ({src.stat().st_size:,} bytes, no abs paths)") + + # --- 3. Docs ---------------------------------------------------------- + print("\nStaging docs:") + for name in DOC_FILES: + src = ROOT / name + if not src.exists(): + return fail(f"missing required doc: {name}") + shutil.copy2(src, STAGING / name) + print(f" + {name}") + for name in OPTIONAL_DOC_FILES: + src = ROOT / name + if src.exists(): + shutil.copy2(src, STAGING / name) + print(f" + {name}") + else: + warn(f"{name} not present yet") + + # --- 3b. Generate version.nsh for NSIS ------------------------------- + # Single-source the version and URLs into the installer, avoiding + # hardcoded duplicates. This lives outside staging/ so it doesn't end + # up in the theme-only zip. + print("\nGenerating version.nsh:") + version_nsh = DIST / "version.nsh" + + # NSIS requires a 4-part version for VIProductVersion. Pad with .0s. + parts = version.split(".") + # Strip any -dev / -alpha / -rc suffix from the last part + if parts and "-" in parts[-1]: + parts[-1] = parts[-1].split("-")[0] + while len(parts) < 4: + parts.append("0") + version_quad = ".".join(parts[:4]) + + jsplitter = next((c for c in manifest["components"]["required"] + if c["id"] == "foo_uie_jsplitter"), None) + jsplitter_url = jsplitter["source"] if jsplitter else "" + + nsh_lines = [ + f'; Auto-generated by scripts/stage-release.py from manifest.json', + f'; DO NOT EDIT — the installer includes this file to single-source version data.', + f'', + f'!define PRODUCT_NAME "{manifest["displayName"]}"', + f'!define PRODUCT_VERSION "{version}"', + f'!define PRODUCT_VERSION_QUAD "{version_quad}"', + f'!define PRODUCT_PUBLISHER "{manifest["publisher"]}"', + f'!define PRODUCT_WEB_SITE "{manifest["homepage"]}"', + f'!define FB2K_MIN_VERSION "{manifest["target"]["foobar2000"]["minimumVersion"]}"', + f'!define FB2K_REQUIRED_ARCH "{manifest["target"]["foobar2000"]["architecture"]}"', + f'!define JSPLITTER_URL "{jsplitter_url}"', + f'', + ] + version_nsh.write_text("\n".join(nsh_lines), encoding="utf-8") + print(f" + {version_nsh.relative_to(ROOT)}") + + # --- 4. Theme-only zip ------------------------------------------------ + zip_name = f"Amber-ThemeOnly-v{version}.zip" + zip_path = DIST / zip_name + print(f"\nBuilding {zip_name}:") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for path in sorted(STAGING.rglob("*")): + if path.is_file(): + arc = path.relative_to(STAGING) + zf.write(path, arc) + size = zip_path.stat().st_size + print(f" {zip_path.relative_to(ROOT)} ({size:,} bytes)") + + # Verify the archive round-trips and the BOM survived compression. + with zipfile.ZipFile(zip_path) as zf: + bad = zf.testzip() + if bad: + return fail(f"corrupt entry in zip: {bad}") + for rel in manifest["payload"]["scripts"]: + data = zf.read(rel) + if not data.startswith(b"\xef\xbb\xbf"): + return fail(f"{rel} lost its BOM inside the zip") + entries = len(zf.namelist()) + print(f" archive verified: {entries} entries, all BOMs intact") + + # --- 5. Checksums ----------------------------------------------------- + # Note: the installer .exe does not exist yet at this point — NSIS runs + # after staging. The release workflow re-runs this script with + # --checksums-only once makensis has produced it. + write_checksums() + + print(f"\n{GREEN}✓ Staging complete.{RESET}") + print(f" Installer payload root: {STAGING.relative_to(ROOT)}") + return 0 + + +def write_checksums() -> int: + """(Re)compute SHA256SUMS.txt over every publishable artefact in dist/.""" + if not DIST.exists(): + return fail("dist/ does not exist — run staging first") + + sums_path = DIST / "SHA256SUMS.txt" + lines = [] + for artefact in sorted(DIST.glob("*.zip")) + sorted(DIST.glob("*.exe")): + digest = sha256(artefact) + lines.append(f"{digest} {artefact.name}") + + if not lines: + return fail("no .zip or .exe artefacts found in dist/") + + sums_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"\nChecksums -> {sums_path.relative_to(ROOT)}") + for line in lines: + print(f" {line}") + return 0 + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def warn(msg: str) -> None: + print(f" {YELLOW}⚠{RESET} {msg}") + + +def fail(msg: str) -> int: + print(f"\n{RED}✗ Staging failed:{RESET} {msg}\n", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-release.py b/scripts/validate-release.py new file mode 100755 index 0000000..8afbf3c --- /dev/null +++ b/scripts/validate-release.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Amber release validation. + +Enforces the rules in manifest.json: UTF-8 BOM for panel scripts, no absolute +Windows paths in layouts, LF line endings, include() targets exist, and JS syntax. + +Exit 0 when all checks pass; exit 1 on first violation. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "manifest.json" + +# ANSI colors for terminal output. +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RESET = "\033[0m" + + +def main(): + if not MANIFEST.exists(): + fail(f"manifest.json not found at {MANIFEST}") + + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + scripts_dir = ROOT / manifest["payload"]["scriptDir"] + layouts_dir = ROOT / manifest["payload"].get("layoutDir", "amber-layouts") + + print(f"{GREEN}Amber release validation{RESET}\n") + + # 1. UTF-8 BOM check for panel scripts. + print(f"{YELLOW}[1/5]{RESET} Checking UTF-8 BOM on panel scripts...") + for script_rel in manifest["payload"]["scripts"]: + script = ROOT / script_rel + if not script.exists(): + fail(f"Script declared in manifest does not exist: {script_rel}") + check_utf8_bom(script) + ok("All panel scripts have UTF-8 BOM") + + # 2. JS syntax check with Node. + print(f"{YELLOW}[2/5]{RESET} Checking JavaScript syntax...") + for js in scripts_dir.glob("*.js"): + check_js_syntax(js) + ok("All JS files pass syntax check") + + # 3. include() path completeness. + print(f"{YELLOW}[3/5]{RESET} Checking include() references...") + for js in scripts_dir.glob("*.js"): + check_includes(js, scripts_dir) + ok("All include() targets exist") + + # 4. Absolute path scan in layouts. + print(f"{YELLOW}[4/5]{RESET} Scanning layouts for absolute Windows paths...") + if layouts_dir.exists(): + for fth in layouts_dir.glob("*.fth"): + check_no_absolute_paths(fth) + else: + warn(f"Layouts directory {layouts_dir} does not exist yet; skipping.") + ok("No absolute Windows paths in layouts") + + # 5. LF line-ending check for JS (CRLF breaks foobar2000 JSplitter on some setups). + print(f"{YELLOW}[5/6]{RESET} Checking LF line endings on panel scripts...") + for script_rel in manifest["payload"]["scripts"]: + script = ROOT / script_rel + check_lf_endings(script) + ok("All panel scripts use LF endings") + + # 6. The installer must take its version from the generated header, never + # from a literal. A stale hardcoded version ships an installer that + # reports the wrong version and writes the wrong uninstall registry key. + print(f"{YELLOW}[6/6]{RESET} Checking installer does not hardcode the version...") + check_installer_version_source(manifest["version"]) + ok("Installer sources its version from the generated header") + + print(f"\n{GREEN}✓ All validation checks passed.{RESET}") + + +def check_utf8_bom(path: Path): + """Panel scripts must start with UTF-8 BOM (0xEF 0xBB 0xBF) for JSplitter.""" + raw = path.read_bytes() + if not raw.startswith(b"\xef\xbb\xbf"): + fail(f"{path.name} lacks UTF-8 BOM (JSplitter will show mojibake).") + + +def check_js_syntax(path: Path): + """Run `node --check` to validate JS syntax.""" + result = subprocess.run( + ["node", "--check", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + fail(f"{path.name} has syntax errors:\n{result.stderr}") + + +def check_includes(path: Path, base_dir: Path): + """Ensure every include('...') references an existing file.""" + text = path.read_text(encoding="utf-8-sig") + # Match: include(fb.ProfilePath + 'amber\\whatever.js'); + pattern = r"include\s*\(\s*fb\.ProfilePath\s*\+\s*['\"]([^'\"]+)['\"]\s*\)" + for match in re.finditer(pattern, text): + rel = match.group(1).replace("\\\\", "/").replace("\\", "/") + # Construct absolute path from base_dir (which is already /amber). + # rel looks like "amber/albumcard.js", so strip the leading "amber/" if present. + if rel.startswith("amber/"): + rel = rel[6:] + target = base_dir / rel + if not target.exists(): + fail(f"{path.name} includes missing file: {rel}") + + +def check_no_absolute_paths(path: Path): + r"""Scan .fth for Windows absolute paths (C:\, E:\, etc.).""" + # .fth is binary; read as latin-1 to avoid decode errors, then regex match. + try: + text = path.read_bytes().decode("latin-1", errors="ignore") + except Exception as e: + warn(f"Could not read {path.name} for path scan: {e}") + return + + # Match C:\, D:\, E:\, etc. — common patterns in exported foobar2000 layouts. + matches = re.findall(r"[A-Z]:\\[^\x00-\x1f\"<>|]*", text) + if matches: + # Deduplicate and show up to 5 examples. + unique = sorted(set(matches))[:5] + fail( + f"{path.name} contains absolute Windows paths (dev machine leak):\n " + + "\n ".join(unique) + ) + + +def check_lf_endings(path: Path): + """Ensure the file uses LF (not CRLF) line endings.""" + raw = path.read_bytes() + # Strip BOM if present, then check for CRLF. + body = raw[3:] if raw.startswith(b"\xef\xbb\xbf") else raw + if b"\r\n" in body: + fail(f"{path.name} contains CRLF line endings (must be LF).") + + +def check_installer_version_source(version: str): + """The NSI must !include the generated header and define no version literal. + + manifest.json is the single source of truth. A `!define PRODUCT_VERSION` + left in the NSI silently wins over the generated header, so guard against + it being reintroduced. + """ + nsi = ROOT / "installer" / "Amber.nsi" + if not nsi.exists(): + warn(f"{nsi.relative_to(ROOT)} not found; skipping.") + return + + text = nsi.read_text(encoding="utf-8") + + if not re.search(r'!include\s+"[^"]*version\.nsh"', text): + fail("installer/Amber.nsi does not !include the generated version.nsh") + + for literal in re.finditer(r"^\s*!define\s+PRODUCT_VERSION\b", text, re.MULTILINE): + line = text[: literal.start()].count("\n") + 1 + fail( + f"installer/Amber.nsi:{line} hardcodes PRODUCT_VERSION. " + f"It must come from dist/version.nsh, generated from manifest.json." + ) + + # A bare version string anywhere in the NSI is almost always a leftover. + if version in text: + line = text[: text.index(version)].count("\n") + 1 + fail( + f"installer/Amber.nsi:{line} contains the literal version {version!r}. " + f"Reference ${{PRODUCT_VERSION}} instead." + ) + + +def ok(msg: str): + print(f" {GREEN}✓{RESET} {msg}") + + +def warn(msg: str): + print(f" {YELLOW}⚠{RESET} {msg}") + + +def fail(msg: str): + print(f"\n{RED}✗ Validation failed:{RESET} {msg}\n", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 2d94d111537a7ab1007c8011d96e363fc386b513 Mon Sep 17 00:00:00 2001 From: Shix Sun Date: Thu, 6 Aug 2026 11:37:27 +0000 Subject: [PATCH 2/2] ci: install NSIS before compiling installer GitHub Windows runner does not have makensis in PATH by default. Install it via Chocolatey before the compile step. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2e2ea..edc43cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,9 @@ jobs: - name: Generate installer assets run: python installer/assets/make-assets.py + - name: Install NSIS + run: choco install nsis -y + - name: Compile installer run: makensis installer/Amber.nsi