diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c71b2da..d1bca2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release # Manual release: from the GitHub "Actions" tab pick "Release", click -# "Run workflow", enter the version tag, and it builds the Windows portable +# "Run workflow", enter the version tag, and it builds the Windows installer # .exe and the Linux tarball + .deb, then publishes a GitHub Release with all # artifacts attached and auto-generated notes. on: @@ -23,7 +23,7 @@ permissions: jobs: build-windows: - name: Build Windows portable + name: Build Windows installer runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -59,12 +59,12 @@ jobs: echo "Windows code signing secrets not configured; building unsigned." fi - - name: Package Windows portable .exe + - name: Package Windows installer .exe run: npm run package:windows - uses: actions/upload-artifact@v4 with: - name: windows-portable + name: windows-installer path: releases/*.exe if-no-files-found: error @@ -126,7 +126,7 @@ jobs: flags+=(--prerelease) fi gh release create "$TAG" \ - dist/windows-portable/* \ + dist/windows-installer/*.exe \ dist/linux-artifacts/* \ --title "StepForge $TAG" \ --target "${{ github.sha }}" \ diff --git a/README.md b/README.md index 0b17484..7842813 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ bash scripts/bootstrap-offline.sh # verify toolchain availability bash scripts/verify.sh # full test suite + smoke checks bash scripts/build-release.sh # assemble runnable app directory bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec) -npm run package:windows # portable Windows .exe in releases/ -pwsh scripts/package-windows.ps1 # same Windows portable build via PowerShell +npm run package:windows # Windows installer .exe in releases/ +pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell ``` See [build/build_report.md](build/build_report.md) for what was produced on diff --git a/app/main.js b/app/main.js index a3fab62..c89abc0 100644 --- a/app/main.js +++ b/app/main.js @@ -21,6 +21,12 @@ const { readLock } = require('../core/locks'); const CaptureService = require('./capture'); const { keepProcessesResponsive } = require('./win-power'); +const APP_ID = 'com.stepforge.app'; + +if (process.platform === 'win32') { + app.setAppUserModelId(APP_ID); +} + // Keep capture working on battery. In a power-saving plan on DC power, Windows // applies Power Throttling (EcoQoS) to background work — and StepForge records // with its window hidden, so the frame-capture worker renderer is exactly the diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index fbd5c50..15e69c3 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -87,6 +87,6 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions. ## Optional builds 1. `bash scripts/build-release.sh` assembles the offline release layout. -2. `npm run package:windows` creates the portable Windows `.exe` in +2. `npm run package:windows` creates the Windows installer `.exe` in `releases/`. 3. `bash scripts/package-linux.sh` creates Linux release artifacts. diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 366b49b..60546d0 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -109,7 +109,7 @@ ${toolRows} Fallback policy: when a packaging tool is missing the build still produces the runnable app (portable tarball with launcher) plus whatever package formats the available tools allow. Windows artifacts are produced by -\`npm run package:windows\` (electron-builder, portable .exe); .msi/.rpm/ +\`npm run package:windows\` (electron-builder, installer .exe); .msi/.rpm/ AppImage require the tools listed above and are skipped on this host. ## Offline guarantee diff --git a/scripts/package-windows.js b/scripts/package-windows.js index ba55b64..49f0eb6 100644 --- a/scripts/package-windows.js +++ b/scripts/package-windows.js @@ -9,12 +9,9 @@ const { build, Platform } = require('electron-builder'); const ROOT_DIR = path.resolve(__dirname, '..'); const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json')); -const RELEASE_DIR = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases')); -const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-')); -const OUTPUT_DIR = path.join(WORK_DIR, 'output'); -const ARTIFACT_NAME = 'stepforge-windows-x64-portable.exe'; +const APP_ID = 'com.stepforge.app'; -function findPortableExe(dir) { +function findInstallerExe(dir) { if (!fs.existsSync(dir)) return null; const stack = [dir]; while (stack.length) { @@ -31,15 +28,12 @@ function findPortableExe(dir) { return null; } -async function main() { - fs.mkdirSync(RELEASE_DIR, { recursive: true }); - fs.rmSync(OUTPUT_DIR, { recursive: true, force: true }); - - const config = { - appId: 'com.stepforge.app', +function createWindowsInstallerConfig(outputDir) { + return { + appId: APP_ID, productName: 'StepForge', directories: { - output: OUTPUT_DIR, + output: outputDir, }, files: [ 'app/**/*', @@ -49,33 +43,59 @@ async function main() { ], asar: true, compression: 'normal', - artifactName: ARTIFACT_NAME, win: { - target: ['portable'], + target: ['nsis'], + }, + nsis: { + oneClick: false, + allowToChangeInstallationDirectory: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: 'StepForge', }, }; +} + +async function buildWindowsInstaller() { + const releaseDir = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases')); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-')); + const outputDir = path.join(workDir, 'output'); + + fs.mkdirSync(releaseDir, { recursive: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + + const config = createWindowsInstallerConfig(outputDir); try { await build({ - targets: Platform.WINDOWS.createTarget('portable'), + targets: Platform.WINDOWS.createTarget('nsis'), config, }); } catch (err) { - throw new Error(`Windows portable build failed: ${err.message}`); + throw new Error(`Windows installer build failed: ${err.message}`); } - const builtExe = findPortableExe(OUTPUT_DIR); - if (!builtExe) { - throw new Error(`No .exe artifact was produced in ${OUTPUT_DIR}`); + const builtInstaller = findInstallerExe(outputDir); + if (!builtInstaller) { + throw new Error(`No installer .exe artifact was produced in ${outputDir}`); } - const releaseExe = path.join(RELEASE_DIR, path.basename(builtExe)); - fs.copyFileSync(builtExe, releaseExe); + const releaseInstaller = path.join(releaseDir, path.basename(builtInstaller)); + fs.copyFileSync(builtInstaller, releaseInstaller); - console.log(`StepForge ${PACKAGE_JSON.version} Windows portable build written to ${releaseExe}`); + console.log(`StepForge ${PACKAGE_JSON.version} Windows installer written to ${releaseInstaller}`); } -main().catch((err) => { - console.error(err.message || err); - process.exitCode = 1; -}); +if (require.main === module) { + buildWindowsInstaller().catch((err) => { + console.error(err.message || err); + process.exitCode = 1; + }); +} + +module.exports = { + APP_ID, + createWindowsInstallerConfig, + findInstallerExe, + buildWindowsInstaller, +}; diff --git a/tests/unit/package-windows.test.js b/tests/unit/package-windows.test.js new file mode 100644 index 0000000..22dca29 --- /dev/null +++ b/tests/unit/package-windows.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeTmpDir, rmrf } = require('./helpers'); +const { createWindowsInstallerConfig, findInstallerExe } = require('../../scripts/package-windows'); + +test('Windows packaging uses an assisted NSIS installer', (t) => { + const config = createWindowsInstallerConfig('/tmp/stepforge-output'); + + assert.deepEqual(config.win.target, ['nsis']); + assert.equal(config.nsis.oneClick, false); + assert.equal(config.nsis.allowToChangeInstallationDirectory, true); + assert.equal(config.nsis.createDesktopShortcut, true); + assert.equal(config.nsis.createStartMenuShortcut, true); + assert.equal(config.nsis.shortcutName, 'StepForge'); + assert.equal(config.asar, true); + assert.ok(config.files.includes('app/**/*')); + assert.ok(config.files.includes('core/**/*')); + assert.ok(config.files.includes('exporters/**/*')); + assert.ok(!config.files.includes('assets/**/*')); + + const tmp = makeTmpDir('windows-installer'); + t.after(() => rmrf(tmp)); + fs.mkdirSync(path.join(tmp, 'nested', 'deeper'), { recursive: true }); + const installer = path.join(tmp, 'nested', 'deeper', 'StepForge Setup 0.2.0.exe'); + fs.writeFileSync(installer, Buffer.from('fake installer')); + assert.equal(findInstallerExe(tmp), installer); +});