Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/build-electron.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions app/build/mac/notarize.js
Original file line number Diff line number Diff line change
@@ -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.');
};
113 changes: 98 additions & 15 deletions app/build/update-latest-yml.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -13,13 +19,29 @@ 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');
hashSum.update(fileBuffer);
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'));
Expand All @@ -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}
Expand All @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions app/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ asar: false

# Build hooks
afterPack: build/afterPack.js
afterSign: build/mac/notarize.js

# Publishing configuration for auto-updates
publish:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading