From be5823315a54e06415c7f8079e15be663d50c392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Leszko?= Date: Wed, 11 Feb 2026 10:58:57 +0100 Subject: [PATCH] Add Electron App for Mac build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Leszko --- .github/workflows/build-electron.yml | 120 +++++++++++++++++++++++++++ app/build/mac/notarize.js | 50 +++++++++++ app/build/update-latest-yml.js | 113 +++++++++++++++++++++---- app/electron-builder.yml | 6 +- app/package.json | 1 + app/src/main.ts | 20 ++++- app/src/services/electronApp.ts | 12 +++ 7 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 app/build/mac/notarize.js diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index 28754f41e..8e796bdd2 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -105,3 +105,123 @@ jobs: app/dist/**/* retention-days: 30 if-no-files-found: error + + build-macos: + name: Build macOS + runs-on: macos-latest + env: + # Map CI_MACOS_* secrets to env vars used by certificate import and electron-builder + MACOS_SIGNING_IDENTITY: ${{ secrets.CI_MACOS_CERTIFICATE_ID }} + MACOS_SIGNING_CERT: ${{ secrets.CI_MACOS_CERTIFICATE_BASE64 }} + MACOS_SIGNING_CERT_PASSWORD: ${{ secrets.CI_MACOS_CERTIFICATE_PASSWORD }} + MACOS_NOTARIZATION_USERNAME: ${{ secrets.CI_MACOS_NOTARIZATION_USER }} + MACOS_NOTARIZATION_PASSWORD: ${{ secrets.CI_MACOS_NOTARIZATION_PASSWORD }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + cache: 'npm' + cache-dependency-path: | + app/package-lock.json + frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci + + - name: Security audit - Frontend + working-directory: ./frontend + run: npm audit --audit-level=moderate + continue-on-error: true + + - name: Build frontend + working-directory: ./frontend + run: npm run build + + - name: Install app dependencies + working-directory: ./app + run: npm ci + + - name: Security audit - Electron App + working-directory: ./app + run: npm audit --audit-level=moderate + continue-on-error: true + + - name: Build application + working-directory: ./app + run: npm run compile + + - name: Import Apple Developer ID certificate + if: env.MACOS_SIGNING_CERT != '' + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$MACOS_SIGNING_CERT" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" \ + -P "$MACOS_SIGNING_CERT_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + rm "$CERT_PATH" + + # Set keychain to be searched first + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Build Electron app (macOS) + working-directory: ./app + run: npm run dist:mac + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + # Skip code signing when no certificate is available + CSC_IDENTITY_AUTO_DISCOVERY: ${{ env.MACOS_SIGNING_CERT != '' }} + + - name: Generate latest-mac.yml + working-directory: ./app + run: node build/update-latest-yml.js --platform mac + + - name: Upload to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') && env.MACOS_SIGNING_CERT != '' + uses: softprops/action-gh-release@v1 + with: + files: | + app/dist/DaydreamScope-x64.dmg + app/dist/DaydreamScope-arm64.dmg + app/dist/*.zip + app/dist/latest-mac.yml + prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Clean up keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + if [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: | + app/dist/**/* + retention-days: 30 + if-no-files-found: error diff --git a/app/build/mac/notarize.js b/app/build/mac/notarize.js new file mode 100644 index 000000000..887a589f5 --- /dev/null +++ b/app/build/mac/notarize.js @@ -0,0 +1,50 @@ +/** + * afterSign hook for electron-builder — Apple notarization + * + * Submits the .app bundle to Apple for notarization and staples the result. + * Skips gracefully when not on macOS or when credentials are missing + * (allows local dev builds and unsigned CI builds without an Apple Developer account). + * + * Expected env variables (matching daydream-obs codesigning action): + * MACOS_SIGNING_IDENTITY — e.g. "Developer ID Application: Company (TEAMID)" + * MACOS_NOTARIZATION_USERNAME — Apple ID email + * MACOS_NOTARIZATION_PASSWORD — App-specific password + */ +const { notarize } = require('@electron/notarize'); + +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context; + + if (electronPlatformName !== 'darwin') { + console.log('Skipping notarization — not macOS.'); + return; + } + + if (!process.env.MACOS_NOTARIZATION_USERNAME || !process.env.MACOS_NOTARIZATION_PASSWORD) { + console.log('Skipping notarization — MACOS_NOTARIZATION_USERNAME or MACOS_NOTARIZATION_PASSWORD not set.'); + return; + } + + // Extract team ID from signing identity, e.g. "Developer ID Application: Company (ABC123)" → "ABC123" + const identity = process.env.MACOS_SIGNING_IDENTITY || ''; + const teamIdMatch = identity.match(/\(([A-Z0-9]+)\)\s*$/); + if (!teamIdMatch) { + console.log('Skipping notarization — could not extract team ID from MACOS_SIGNING_IDENTITY.'); + return; + } + const teamId = teamIdMatch[1]; + + const appName = context.packager.appInfo.productFilename; + const appPath = `${appOutDir}/${appName}.app`; + + console.log(`Notarizing ${appPath} (team ${teamId}) ...`); + + await notarize({ + appPath, + appleId: process.env.MACOS_NOTARIZATION_USERNAME, + appleIdPassword: process.env.MACOS_NOTARIZATION_PASSWORD, + teamId, + }); + + console.log('Notarization complete.'); +}; diff --git a/app/build/update-latest-yml.js b/app/build/update-latest-yml.js index b9d3f424d..92fe429d3 100644 --- a/app/build/update-latest-yml.js +++ b/app/build/update-latest-yml.js @@ -1,10 +1,16 @@ #!/usr/bin/env node /** - * Update latest.yml after code signing + * Update latest.yml / latest-mac.yml after code signing * - * When code signing happens outside of electron-builder (e.g., Azure Trusted Signing), - * the binary is modified and the SHA512 checksum in latest.yml becomes invalid. - * This script regenerates latest.yml with the correct checksum for the signed exe. + * Usage: + * node update-latest-yml.js # Windows (default) + * node update-latest-yml.js --platform win # Windows (explicit) + * node update-latest-yml.js --platform mac # macOS + * + * When code signing happens outside of electron-builder (e.g., Azure Trusted Signing + * on Windows, or Apple notarization on macOS), the binary is modified and the SHA512 + * checksum in latest.yml becomes invalid. This script regenerates the yml with the + * correct checksums. */ const fs = require('fs'); @@ -13,6 +19,10 @@ const crypto = require('crypto'); const DIST_DIR = path.join(__dirname, '..', 'dist'); +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function calculateSha512(filePath) { const fileBuffer = fs.readFileSync(filePath); const hashSum = crypto.createHash('sha512'); @@ -20,6 +30,18 @@ function calculateSha512(filePath) { return hashSum.digest('base64'); } +function parsePlatformArg() { + const idx = process.argv.indexOf('--platform'); + if (idx === -1 || idx + 1 >= process.argv.length) { + return 'win'; // default for backward compatibility + } + return process.argv[idx + 1]; +} + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + function findExeFile() { const files = fs.readdirSync(DIST_DIR); const exeFile = files.find(f => f.endsWith('.exe') && !f.includes('Uninstall')); @@ -29,19 +51,16 @@ function findExeFile() { return path.join(DIST_DIR, exeFile); } -function updateLatestYml(exeFilePath) { +function updateLatestYmlWin(exeFilePath) { console.log('Updating latest.yml with signed exe checksum...'); const packageJson = require('../package.json'); const version = packageJson.version; const exeFileName = path.basename(exeFilePath); - // Calculate checksum of signed exe const exeSha512 = calculateSha512(exeFilePath); const exeSize = fs.statSync(exeFilePath).size; - // Generate latest.yml in the same format electron-builder produces - // Note: We don't include blockMapSize since we're not using differential updates const latestYml = `version: ${version} files: - url: ${exeFileName} @@ -59,16 +78,80 @@ releaseDate: ${new Date().toISOString()} console.log(latestYml); } -function main() { - try { - console.log('=== Updating latest.yml after code signing ===\n'); +// --------------------------------------------------------------------------- +// macOS +// --------------------------------------------------------------------------- + +function findZipFile(arch) { + const files = fs.readdirSync(DIST_DIR); + // Match zip files with the architecture in the name + const zipFile = files.find(f => f.endsWith('.zip') && f.includes(arch)); + if (!zipFile) { + throw new Error(`ZIP not found for arch ${arch} in ${DIST_DIR}`); + } + return path.join(DIST_DIR, zipFile); +} + +function updateLatestYmlMac() { + console.log('Generating latest-mac.yml ...'); + + const packageJson = require('../package.json'); + const version = packageJson.version; + const releaseDate = new Date().toISOString(); + + const arm64Path = findZipFile('arm64'); + const x64Path = findZipFile('x64'); + + const arm64Name = path.basename(arm64Path); + const x64Name = path.basename(x64Path); + + const arm64Sha512 = calculateSha512(arm64Path); + const x64Sha512 = calculateSha512(x64Path); + + const arm64Size = fs.statSync(arm64Path).size; + const x64Size = fs.statSync(x64Path).size; + + const latestMacYml = `version: ${version} +files: + - url: ${arm64Name} + sha512: ${arm64Sha512} + size: ${arm64Size} + arch: arm64 + - url: ${x64Name} + sha512: ${x64Sha512} + size: ${x64Size} + arch: x64 +path: ${arm64Name} +sha512: ${arm64Sha512} +releaseDate: ${releaseDate} +`; - const exeFilePath = findExeFile(); - console.log(`Found exe file: ${exeFilePath}\n`); + const ymlPath = path.join(DIST_DIR, 'latest-mac.yml'); + fs.writeFileSync(ymlPath, latestMacYml, 'utf8'); + console.log(`✓ latest-mac.yml updated: ${ymlPath}`); + console.log('\nContents:'); + console.log(latestMacYml); +} - updateLatestYml(exeFilePath); +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- - console.log('\n✓ latest.yml updated successfully!'); +function main() { + const platform = parsePlatformArg(); + + try { + if (platform === 'mac') { + console.log('=== Generating latest-mac.yml ===\n'); + updateLatestYmlMac(); + console.log('\n✓ latest-mac.yml generated successfully!'); + } else { + console.log('=== Updating latest.yml after code signing ===\n'); + const exeFilePath = findExeFile(); + console.log(`Found exe file: ${exeFilePath}\n`); + updateLatestYmlWin(exeFilePath); + console.log('\n✓ latest.yml updated successfully!'); + } } catch (error) { console.error('\n✗ Error:', error.message); process.exit(1); diff --git a/app/electron-builder.yml b/app/electron-builder.yml index 2de1643fe..b78e84aeb 100644 --- a/app/electron-builder.yml +++ b/app/electron-builder.yml @@ -37,6 +37,7 @@ asar: false # Build hooks afterPack: build/afterPack.js +afterSign: build/mac/notarize.js # Publishing configuration for auto-updates publish: @@ -49,21 +50,22 @@ publish: mac: category: public.app-category.utilities icon: assets/icon.png + artifactName: DaydreamScope-${version}-${os}-${arch}.${ext} hardenedRuntime: true gatekeeperAssess: false + notarize: false # Handled by custom afterSign hook (build/mac/notarize.js) entitlements: build/mac/entitlements.mac.plist entitlementsInherit: build/mac/entitlements.mac.plist target: - target: dmg arch: - - x64 - arm64 - target: zip arch: - - x64 - arm64 dmg: + artifactName: DaydreamScope-${arch}.${ext} contents: - x: 130 y: 220 diff --git a/app/package.json b/app/package.json index b15cbe714..b37fc3754 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "url": "https://github.com/daydreamlive/scope.git" }, "devDependencies": { + "@electron/notarize": "^2.5.0", "@testing-library/react": "^16.1.0", "@types/node": "^22.10.2", "@types/react": "^19.1.10", diff --git a/app/src/main.ts b/app/src/main.ts index 0b472304b..d1382cfbb 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -34,7 +34,7 @@ autoUpdater.autoInstallOnAppQuit = true; // Install updates when app quits autoUpdater.disableDifferentialDownload = true; // Disable differential downloads (no blockmaps) // Auto-updater event handlers -let updateDownloaded = false; +let userInitiatedDownload = false; autoUpdater.on('checking-for-update', () => { logger.info('Checking for updates...'); @@ -54,7 +54,14 @@ autoUpdater.on('update-available', (info) => { cancelId: 1 }).then(result => { if (result.response === 0) { - autoUpdater.downloadUpdate(); + userInitiatedDownload = true; + autoUpdater.downloadUpdate().catch(err => { + logger.error('Failed to download update:', err); + dialog.showErrorBox( + 'Update Download Failed', + `Failed to download the update: ${err.message}\n\nPlease try again later or download the update manually.` + ); + }); } }); }); @@ -65,6 +72,14 @@ autoUpdater.on('update-not-available', (info) => { autoUpdater.on('error', (err) => { logger.error('Auto-updater error:', err); + // Only show error dialog if the user actively initiated the download, + // to avoid noisy popups from background update checks on flaky networks. + if (userInitiatedDownload) { + dialog.showErrorBox( + 'Update Error', + `An error occurred during the update process: ${err.message}` + ); + } }); autoUpdater.on('download-progress', (progressObj) => { @@ -73,7 +88,6 @@ autoUpdater.on('download-progress', (progressObj) => { autoUpdater.on('update-downloaded', (info) => { logger.info('Update downloaded:', info); - updateDownloaded = true; // Notify user that update is ready dialog.showMessageBox({ diff --git a/app/src/services/electronApp.ts b/app/src/services/electronApp.ts index f4d03516f..fcaca4448 100644 --- a/app/src/services/electronApp.ts +++ b/app/src/services/electronApp.ts @@ -307,6 +307,18 @@ export class ScopeElectronAppService { }, ], }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, ]); // Set this as the application menu (accessible via Alt key)