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..edc43cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +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: Install NSIS + run: choco install nsis -y + + - 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 0000000..9b66585 Binary files /dev/null and b/amber-layouts/amber-standard.fth differ 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()