From 10f9f835b77a230d38bf0c3ef269ceff5d186da7 Mon Sep 17 00:00:00 2001 From: Nikita Gladkikh <57749142+BrenOne@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:03:12 +1000 Subject: [PATCH 1/2] Allow Windows-only items to be unused on other platforms Away (fast user switching / RDP) is only reported by the Windows session watcher, and UNELEVATED_FPS only applies there; clippy -D warnings would fail on Linux and macOS. Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/device.rs | 1 + src-tauri/src/lifecycle.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src-tauri/src/device.rs b/src-tauri/src/device.rs index 192ea7e..8802666 100644 --- a/src-tauri/src/device.rs +++ b/src-tauri/src/device.rs @@ -64,6 +64,7 @@ fn held_for() -> Option { /// Without administrator rights the lock-key guard cannot see key presses in /// elevated windows, so on Windows the output rate stays at 30 FPS, the highest /// rate at which indicator updates never collide with frames. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const UNELEVATED_FPS: u8 = 30; pub fn fps_limit() -> Option { #[cfg(target_os = "windows")] diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs index 614ada7..60d85ca 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -9,6 +9,8 @@ pub enum SystemEvent { DisplayOff(bool), Locked(bool), /// Whether this session has the physical screen (and the strip) or not. + /// Only Windows reports it. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] Away(Away), SourcesChanged, #[cfg(target_os = "linux")] @@ -22,8 +24,10 @@ pub enum Away { #[default] Here, /// Another session (fast user switching) has the console. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OtherUser, /// This session is a remote desktop (RDP) session. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] Remote, } From a14e2e367b3655e969beb072854fa3d359e391b5 Mon Sep 17 00:00:00 2001 From: Nikita Gladkikh <57749142+BrenOne@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:09:06 +1000 Subject: [PATCH 2/2] CI: build and release for Windows, macOS and Linux - Matrix build (Windows x64, macOS arm64/x64, Linux x64) with the CLI, staged and published through scripts/release.cjs and scripts/publish-release.cjs. - Partial releases: Windows is required, macOS/Linux are optional; platforms that failed are left out of latest.json and named in the release notes. Re-running the workflow on a tag with republish adds them later. - macOS/Linux are linted and tested in their own legs (cfg differs). - The terminal installers and their test now point at this fork's releases. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build.yml | 151 ++++++++++++++++++++++-------------- scripts/install.ps1 | 2 +- scripts/install.sh | 6 +- scripts/publish-release.cjs | 15 ++-- scripts/release-test.cjs | 42 +++++++++- scripts/release.cjs | 25 +++++- 6 files changed, 170 insertions(+), 71 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5d4401..cb0096f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,14 +1,22 @@ -# Fork build: Windows only. Branches and PRs produce the NSIS installer as an -# artifact. A pushed tag vX.Y.Z also signs the updater package (secrets -# TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD) and -# publishes a GitHub Release with the installer and latest.json, which the -# app's updater reads. -# Checks and the installer build run in parallel. Every build uses one fast -# release profile and one cache key, so PRs reuse main's compiled dependencies +# Fork build for Windows, macOS and Linux. Branches and PRs build every platform +# and upload the packages as artifacts (with a disposable updater key). A pushed +# tag vX.Y.Z signs the updater packages (secrets TAURI_SIGNING_PRIVATE_KEY and +# TAURI_SIGNING_PRIVATE_KEY_PASSWORD) and publishes a GitHub Release with the +# packages and latest.json, which the app's updater reads. +# The Windows build is required for a release; macOS and Linux are optional: if +# one of them fails the release goes out without it (and says so). Re-run the +# workflow on the tag with `republish` to add it later. +# Checks and the builds run in parallel. Every build uses one fast release +# profile and one cache key per target, so PRs reuse main's compiled dependencies # (GitHub lets a PR restore caches saved by its base branch). name: Build desktop apps on: workflow_dispatch: + inputs: + republish: + description: 'Run on a version tag: upload over the already published release (adds platforms that failed before)' + type: boolean + default: false push: branches: [main] tags: ['v*'] @@ -20,6 +28,16 @@ concurrency: group: build-${{ github.ref }} cancel-in-progress: true jobs: + scripts: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: sudo apt-get update && sudo apt-get install -y minisign shellcheck + - run: node --test scripts/release-test.cjs scripts/publish-release-test.cjs scripts/install-test.cjs + - run: bash -n scripts/install.sh && shellcheck scripts/install.sh checks: runs-on: windows-latest steps: @@ -42,33 +60,60 @@ jobs: - run: pnpm test - run: pnpm check build: - runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + bundle: nsis + - os: macos-latest + target: aarch64-apple-darwin + bundle: app,dmg + - os: macos-latest + target: x86_64-apple-darwin + bundle: app,dmg + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + bundle: deb,appimage + runs-on: ${{ matrix.os }} env: - TARGET: x86_64-pc-windows-msvc + TARGET: ${{ matrix.target }} + MACOSX_DEPLOYMENT_TARGET: '13.0' + APPIMAGE_EXTRACT_AND_RUN: '1' steps: - uses: actions/checkout@v4 + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev libasound2-dev libxcb1-dev libxcb-randr0-dev libpipewire-0.3-dev libclang-dev libgbm-dev libegl1-mesa-dev libwayland-dev libxkbcommon-dev libdbus-1-dev pulseaudio pulseaudio-utils xvfb - uses: actions/setup-node@v4 with: node-version: 24 - uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-pc-windows-msvc + targets: ${{ matrix.target }} + components: clippy - uses: Swatinem/rust-cache@v2 with: - workspaces: src-tauri - key: build + workspaces: | + src-tauri + control + key: build-${{ matrix.target }} cache-on-failure: true - run: npm install --global pnpm@11.25.0 - run: pnpm install --frozen-lockfile - name: Check the tag matches the app version if: startsWith(github.ref, 'refs/tags/v') run: node scripts/release.cjs version ${{ github.ref_name }} - - name: Sign updater packages (release tags only) - if: startsWith(github.ref, 'refs/tags/v') + # Release tags use the repository key; everything else a disposable one, so + # the packaging pipeline (including updater signatures) is exercised too. + - name: Configure signed updater packages run: node scripts/configure-updater.cjs env: - RELEASE_BUILD: 'true' - RELEASE_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }} + RELEASE_SIGNING_KEY: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }} - name: Signing key password if: startsWith(github.ref, 'refs/tags/v') shell: bash @@ -81,59 +126,49 @@ jobs: echo "CARGO_PROFILE_RELEASE_LTO=false" >> "$GITHUB_ENV" echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> "$GITHUB_ENV" echo "CARGO_PROFILE_RELEASE_INCREMENTAL=true" >> "$GITHUB_ENV" - - name: Build installer - shell: bash + # Windows is linted and tested by the checks job; the other platforms + # compile different code (cfg), so they need their own pass. + - name: Lint and test (macOS, Linux) + if: runner.os != 'Windows' run: | - if [ -f .local/updater-build.json ]; then config="--config .local/updater-build.json"; fi - pnpm tauri build $config --target "$TARGET" --bundles nsis + pnpm check + pnpm test + - name: Build the command-line tool + run: cargo build --locked --manifest-path control/Cargo.toml --release --target ${{ matrix.target }} + - name: Build the app + run: pnpm tauri build --config .local/updater-build.json --target ${{ matrix.target }} --bundles ${{ matrix.bundle }} + - name: Verify Linux package contents and app startup + if: runner.os == 'Linux' + run: bash scripts/check-linux.sh + - name: Stage release assets + run: node scripts/release.cjs stage ${{ matrix.target }} dist/release - uses: actions/upload-artifact@v4 with: - name: dynamiclight-${{ env.TARGET }} - path: | - src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe - src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe.sig + name: dynamiclight-${{ matrix.target }} + path: dist/release/* if-no-files-found: error release: - needs: [checks, build] - if: startsWith(github.ref, 'refs/tags/v') + needs: [scripts, checks, build] + # A failed macOS or Linux build must not block the release: assemble drops + # the platforms without packages and refuses to go on without Windows. + if: always() && startsWith(github.ref, 'refs/tags/v') && needs.scripts.result == 'success' && needs.checks.result == 'success' runs-on: ubuntu-24.04 permissions: contents: write steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 - uses: actions/download-artifact@v4 with: - name: dynamiclight-x86_64-pc-windows-msvc - path: bundle - - name: Assemble release assets and latest.json - shell: bash - env: - TAG: ${{ github.ref_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - mkdir dist - exe=$(ls bundle/*.exe | grep -v '\.sig$' | head -n1) - name=dynamiclight-desktop-windows-x64-setup.exe - cp "$exe" "dist/$name" - cp "$exe.sig" "dist/$name.sig" - node -e ' - const fs = require("fs"); - const [tag, repo, name] = process.argv.slice(1); - const signature = fs.readFileSync(`dist/${name}.sig`, "utf8").trim(); - const url = `https://github.com/${repo}/releases/download/${tag}/${name}`; - fs.writeFileSync("dist/latest.json", JSON.stringify({ - version: tag.replace(/^v/, ""), - notes: `DynamicLight ${tag}`, - pub_date: new Date().toISOString(), - platforms: { - "windows-x86_64": { signature, url }, - "windows-x86_64-nsis": { signature, url }, - }, - }, null, 2)); - ' "$TAG" "$REPO" "$name" - cat dist/latest.json + pattern: dynamiclight-* + path: dist/release + merge-multiple: true + - run: sudo apt-get update && sudo apt-get install -y minisign - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: gh release create "$TAG" dist/* --repo "${{ github.repository }}" --title "DynamicLight $TAG" --generate-notes + ALLOW_PARTIAL_RELEASE: 'true' + REPUBLISH: ${{ inputs.republish && 'true' || 'false' }} + run: node scripts/publish-release.cjs dist/release diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 834d807..2582111 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -13,7 +13,7 @@ if ($architecture -ne 'AMD64') { throw "No desktop package for Windows/$architec [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $scratch = Join-Path ([IO.Path]::GetTempPath()) ('dynamiclight-install-' + [guid]::NewGuid()) $null = New-Item -ItemType Directory -Path $scratch -$releases = 'https://github.com/mskutin/DynamicLight/releases' +$releases = 'https://github.com/BrenOne/DynamicLight/releases' $asset = 'dynamiclight-desktop-windows-x64-setup.exe' if ($Cli) { $asset = 'dynamiclight-cli-windows-x64.exe' } try { diff --git a/scripts/install.sh b/scripts/install.sh index eca92c0..b51b4c6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,7 +41,7 @@ case "$os:$arch" in Linux:x86_64|Linux:amd64) if "$appimage"; then asset=dynamiclight-desktop-linux-x64.AppImage else asset=dynamiclight-desktop-linux-x64.deb; fi ;; - *) fail "No desktop package for $os/$arch. See https://github.com/mskutin/DynamicLight/releases" ;; + *) fail "No desktop package for $os/$arch. See https://github.com/BrenOne/DynamicLight/releases" ;; esac if "$appimage" && [ "$os" != Linux ]; then fail '--appimage is only supported on Linux'; fi if "$cli"; then @@ -65,7 +65,7 @@ cleanup() { } trap cleanup EXIT fetch() { curl --fail --silent --show-error --location --retry 3 --proto '=https' --proto-redir '=https' "$1" -o "$2"; } -releases=https://github.com/mskutin/DynamicLight/releases +releases=https://github.com/BrenOne/DynamicLight/releases if [ "$version" = latest ]; then fetch "$releases/latest/download/release-version.txt" "$scratch/version" version=$(tr -d '\r\n' < "$scratch/version") @@ -137,7 +137,7 @@ case "$os" in mv -f "$destination/DynamicLight.AppImage.new" "$destination/DynamicLight.AppImage" install -m 644 "$scratch/69-dynamiclight.rules" "$destination/69-dynamiclight.rules" printf 'Installed DynamicLight %s at %s/DynamicLight.AppImage\n' "$version" "$destination" - echo 'USB access and audio/FUSE dependencies require the one-time setup in https://github.com/mskutin/DynamicLight/blob/main/docs/linux.md' + echo 'USB access and audio/FUSE dependencies require the one-time setup in https://github.com/BrenOne/DynamicLight/blob/main/docs/linux.md' printf 'USB rule downloaded to %s/69-dynamiclight.rules\n' "$destination" else command -v apt-get >/dev/null || fail 'This system has no apt-get. Use --appimage and follow docs/linux.md for runtime dependencies' diff --git a/scripts/publish-release.cjs b/scripts/publish-release.cjs index 3c06849..32987c5 100644 --- a/scripts/publish-release.cjs +++ b/scripts/publish-release.cjs @@ -1,9 +1,9 @@ const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const { version, assemble } = require('./release.cjs'); +const { version, assemble, cli } = require('./release.cjs'); -function publishRelease({ root, directory, repo, sha, ref, event }, execute = (args, input) => +function publishRelease({ root, directory, repo, sha, ref, event, partial = false, republish = false }, execute = (args, input) => spawnSync('gh', args, { input, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })) { if (!['push', 'workflow_dispatch'].includes(event) || !(ref === 'refs/heads/main' || ref?.startsWith('refs/tags/v'))) { throw new Error('Publishing is allowed only for main or version-tag push/manual runs'); @@ -29,7 +29,8 @@ function publishRelease({ root, directory, repo, sha, ref, event }, execute = (a // List releases with authentication so interrupted drafts are included too. const releases = JSON.parse(gh(['api', `${base}/releases?per_page=100`, '--paginate', '--slurp'])).flat(); let release = releases.find(item => item.tag_name === tag); - if (release && !release.draft) { + // `republish` re-uploads over a published release (same tag, same commit), e.g. to add a platform that failed before. + if (release && !release.draft && !republish) { console.log(`${tag} is already published; leaving its tag and assets unchanged.`); return 'unchanged'; } @@ -45,8 +46,11 @@ function publishRelease({ root, directory, repo, sha, ref, event }, execute = (a } // Validate the complete current-run asset set before creating any remote state. - assemble(root, directory, tag); - const notes = fs.readFileSync(path.join(root, 'docs/release-notes.md'), 'utf8'); + const { missing } = assemble(root, directory, tag, { partial }); + let notes = fs.readFileSync(path.join(root, 'docs/release-notes.md'), 'utf8'); + const unreleased = missing.map(target => cli[target]?.replace(/^dynamiclight-cli-/, '').replace(/\.exe$/, '')).join(', '); + if (missing.length) notes += `\n\n**Not in this release:** ${unreleased} (the build failed; run the workflow again on this tag with republish to add it).`; + if (partial) notes += '\n\nmacOS and Linux builds are experimental: they are built automatically but not yet tested on a real controller.'; if (!tagExists) api(`${base}/git/refs`, 'POST', { ref: `refs/tags/${tag}`, sha }); const prerelease = tag.includes('-'); if (!release) { @@ -72,5 +76,6 @@ if (require.main === module) { root: path.resolve(__dirname, '..'), directory: process.argv[2] || 'dist/release', repo: process.env.GITHUB_REPOSITORY, sha: process.env.GITHUB_SHA, ref: process.env.GITHUB_REF, event: process.env.GITHUB_EVENT_NAME, + partial: process.env.ALLOW_PARTIAL_RELEASE === 'true', republish: process.env.REPUBLISH === 'true', }); } diff --git a/scripts/release-test.cjs b/scripts/release-test.cjs index 4a6eeb0..789221c 100644 --- a/scripts/release-test.cjs +++ b/scripts/release-test.cjs @@ -59,7 +59,7 @@ test('stages all platform bundles and hashes exactly the complete release', t => assert.equal(manifest.version, version(dir)); assert.equal(Object.keys(manifest.platforms).length, 4); for (const platform of Object.values(manifest.platforms)) { - assert.ok(platform.url.startsWith(`https://github.com/mskutin/DynamicLight/releases/download/v${version(dir)}/`)); + assert.ok(platform.url.startsWith(`https://github.com/BrenOne/DynamicLight/releases/download/v${version(dir)}/`)); assert.ok(platform.signature.length > 100); } for (const line of checksums) { @@ -100,3 +100,43 @@ test('signatures reject a different key, changed bytes and a relabeled version', fs.appendFileSync(file, 'changed'); assert.throws(() => verifySignature(file, signature, key, version(dir)), /Invalid updater signature/); }); + +test('a partial release needs Windows and leaves failed platforms out of latest.json', t => { + const dir = fixture(t); + const sign = signingFixture(dir); + const stageTarget = (target, output) => { + for (const [folder, ext] of bundles[target]) { + const source = path.join(dir, 'src-tauri/target', target, 'release/bundle', folder); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, `package${ext}`), `fixture ${target} ${ext}`); + } + const [, folder, ext] = updates[target]; + const source = path.join(dir, 'src-tauri/target', target, 'release/bundle', folder); + fs.mkdirSync(source, { recursive: true }); + const file = path.join(source, `package${ext}`); + fs.writeFileSync(file, `fixture ${target} ${ext}`); + sign(file); + const binary = path.join(dir, 'control/target', target, 'release', target.includes('windows') ? 'dynamiclight.exe' : 'dynamiclight'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.writeFileSync(binary, 'CLI fixture'); + stage(dir, target, output); + }; + const windows = 'x86_64-pc-windows-msvc'; + const linux = 'x86_64-unknown-linux-gnu'; + const output = path.join(dir, 'dist'); + stageTarget(linux, output); + assert.throws(() => assemble(dir, output, `v${version(dir)}`, { partial: true }), /Windows packages are required/); + fs.rmSync(output, { recursive: true }); + stageTarget(windows, output); + assert.throws(() => assemble(dir, output, `v${version(dir)}`), /every supported platform/); + const { missing } = assemble(dir, output, `v${version(dir)}`, { partial: true }); + assert.deepEqual(missing.sort(), ['aarch64-apple-darwin', linux, 'x86_64-apple-darwin'].sort()); + const manifest = JSON.parse(fs.readFileSync(path.join(output, 'latest.json'))); + assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); + // A platform that only has some of its packages is dropped as a whole. + fs.rmSync(output, { recursive: true }); + stageTarget(windows, output); + fs.writeFileSync(path.join(output, bundles[linux][0][2]), 'orphan'); + assert.deepEqual(assemble(dir, output, `v${version(dir)}`, { partial: true }).missing.includes(linux), true); + assert.ok(!fs.existsSync(path.join(output, bundles[linux][0][2]))); +}); diff --git a/scripts/release.cjs b/scripts/release.cjs index 9a79396..dff5225 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -77,15 +77,33 @@ function stage(root, target, output) { fs.copyFileSync(binary, path.join(output, cli[target])); } -function assemble(root, output, tag) { +// Every file one platform contributes to a release. +const packages = target => [...bundles[target].map(bundle => bundle[2]), updates[target][3], `${updates[target][3]}.sig`, cli[target]]; +// The platform a release cannot go without; the others are optional when `partial`. +const REQUIRED = 'x86_64-pc-windows-msvc'; + +// With `partial`, platforms whose packages are not all there are left out of the +// release (and of latest.json) instead of failing it; returns them as `missing`. +function assemble(root, output, tag, { partial = false } = {}) { const value = version(root, tag); - const expected = [...new Set([...Object.values(bundles).flat().map(bundle => bundle[2]), ...Object.values(updates).flatMap(u => [u[3], `${u[3]}.sig`]), ...Object.values(cli)])].sort(); + const missing = []; + if (partial) { + const present = new Set(fs.readdirSync(output)); + for (const target of Object.keys(updates)) { + if (packages(target).every(name => present.has(name))) continue; + missing.push(target); + for (const name of packages(target)) fs.rmSync(path.join(output, name), { force: true }); + } + if (missing.includes(REQUIRED)) throw new Error('The Windows packages are required for a release'); + } + const included = Object.keys(updates).filter(target => !missing.includes(target)); + const expected = [...new Set(included.flatMap(packages))].sort(); const actual = fs.readdirSync(output).sort(); if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error('Release must contain exactly one package for every supported platform'); for (const name of expected) if (!fs.statSync(path.join(output, name)).size) throw new Error(`Empty package: ${name}`); const publicKey = JSON.parse(fs.readFileSync(path.join(root, 'src-tauri/tauri.conf.json'))).plugins.updater.pubkey; const platforms = {}; - for (const [platform, , , name] of Object.values(updates)) { + for (const [platform, , , name] of included.map(target => updates[target])) { const signature = fs.readFileSync(path.join(output, `${name}.sig`), 'utf8').trim(); verifySignature(path.join(output, name), signature, publicKey, value); platforms[platform] = { signature, url: `https://github.com/BrenOne/DynamicLight/releases/download/v${value}/${name}` }; @@ -102,6 +120,7 @@ function assemble(root, output, tag) { return `${hash} ${name}\n`; }).join(''); fs.writeFileSync(path.join(output, 'SHA256SUMS'), checksums); + return { missing }; } module.exports = { bundles, updates, cli, version, stage, assemble, minisign, verifySignature };