diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce0e08b --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_NAME=Openscreen +BUNDLE_ID=com.siddharthvaddem.openscreen + +APPLE_ID= +TEAM_ID= +SIGN_IDENTITY="Developer ID Application: Samir Patil ()" +CSC_NAME="Samir Patil ()" + +NOTARY_PROFILE=OpenScreen-notary +APPLE_APP_SPECIFIC_PASSWORD= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5638ffc..f42a92d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,16 @@ name: Build Electron App on: workflow_dispatch: + inputs: + arch: + description: 'Architecture to build' + required: true + default: 'both' + type: choice + options: + - arm64 + - x64 + - both jobs: build-windows: @@ -36,38 +46,180 @@ jobs: build-macos: runs-on: macos-latest + strategy: + matrix: + arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }} + steps: + # ─── Checkout ───────────────────────────────────────────── - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 + # ─── Setup Node.js ──────────────────────────────────────── - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '22' + node-version: 22 + cache: npm + # ─── Setup Python (needed by some native deps) ──────────── - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' + # ─── Install Dependencies ───────────────────────────────── - name: Install dependencies run: npm ci - - name: Install app dependencies - run: npx electron-builder install-app-deps - - - name: Build macOS app - run: npm run build:mac + # ─── Import Code Signing Certificate ────────────────────── + # This is the KEY step that makes CI signing work. + # We create a temporary keychain, import the .p12 cert into it, + # and set it as the default so codesign can find it. + - name: Import code signing certificate env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create and configure keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -k "$KEYCHAIN_PATH" \ + -P "$MAC_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + + # Allow codesign to access the keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add to keychain search path (makes it the default) + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + # Verify the identity is available + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + # Clean up the .p12 file + rm -f $RUNNER_TEMP/certificate.p12 + + # ─── Build Vite + Electron ──────────────────────────────── + - name: Build Vite + Electron + run: npx tsc && npx vite build + + # ─── Package with electron-builder ──────────────────────── + # electron-builder handles deep codesigning the .app bundle + # "notarize: false" in electron-builder.json5 prevents it from + # trying its own notarization flow + - name: Package .app bundle + run: npx electron-builder --mac --${{ matrix.arch }} --dir + env: + CSC_NAME: "Samir Patil (N26FZ4GW28)" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload macOS build + # ─── Read version from package.json ─────────────────────── + - name: Get version + id: version + run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT + + # ─── Locate the .app bundle ─────────────────────────────── + - name: Find .app bundle + id: find_app + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "=== Release directory contents ===" + ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found" + echo "=== Searching for .app bundle ===" + APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1) + if [ -z "$APP_BUNDLE" ]; then + echo "::error::No .app bundle found in release/${VERSION}/" + exit 1 + fi + echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT + echo "Found: $APP_BUNDLE" + + # ─── Verify .app signature ──────────────────────────────── + - name: Verify .app code signature + run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" + + # ─── Create DMG ─────────────────────────────────────────── + - name: Create DMG + id: dmg + run: | + VERSION="${{ steps.version.outputs.version }}" + ARCH="${{ matrix.arch }}" + DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" + RELEASE_DIR="release/${VERSION}" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + STAGING="${RELEASE_DIR}/dmg-staging" + + mkdir -p "$STAGING" + cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -srcfolder "$STAGING" \ + -volname "Openscreen" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + rm -rf "$STAGING" + + echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT + echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT + + # ─── Sign DMG ───────────────────────────────────────────── + - name: Sign DMG + run: | + codesign --force \ + --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \ + --timestamp \ + "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Notarize DMG ──────────────────────────────────────── + # On CI we can't use keychain profiles for notarytool, so we + # pass credentials directly via env vars / flags + - name: Notarize DMG + run: | + xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait + timeout-minutes: 15 + + # ─── Staple ─────────────────────────────────────────────── + - name: Staple notarization ticket + run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Validate ───────────────────────────────────────────── + - name: Validate stapled DMG + run: | + xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" + spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Upload Artifact ────────────────────────────────────── + - name: Upload notarized DMG uses: actions/upload-artifact@v4 with: - name: macos-installer - path: release/**/*.dmg + name: openscreen-mac-${{ matrix.arch }} + path: ${{ steps.dmg.outputs.dmg_path }} retention-days: 30 + # ─── Cleanup Keychain ───────────────────────────────────── + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true + build-linux: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 9393ef6..1f895bd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist dist-electron dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/electron-builder.json5 b/electron-builder.json5 index 40fce0a..18498df 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -20,16 +20,18 @@ "!CONTRIBUTING.md", "!LICENSE" ], - "extraResources": [ - { - "from": "public/wallpapers", - "to": "assets/wallpapers" - } - ], - "publish": [{"provider": "github"}], - - "mac": { - "hardenedRuntime": false, + "extraResources": [ + { + "from": "public/wallpapers", + "to": "assets/wallpapers" + } + ], + + "mac": { + "notarize": false, + "hardenedRuntime": true, + "entitlements": "macos.entitlements", + "entitlementsInherit": "macos.entitlements", "target": [ { "target": "dmg", @@ -38,13 +40,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true - } + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSCameraUseContinuityCameraDeviceType": true, + "com.apple.security.device.audio-input": true + } }, "linux": { "target": [ @@ -54,14 +56,14 @@ "artifactName": "${productName}-Linux-${version}.${ext}", "category": "AudioVideo" }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } -} + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } +} diff --git a/macos.entitlements b/macos.entitlements new file mode 100644 index 0000000..5c6ddcf --- /dev/null +++ b/macos.entitlements @@ -0,0 +1,25 @@ + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.device.audio-input + + + + com.apple.security.device.camera + + + diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh new file mode 100755 index 0000000..bd35710 --- /dev/null +++ b/scripts/build_macos.sh @@ -0,0 +1,216 @@ +#!/bin/bash +# +# OpenScreen macOS Build Script +# Produces: release//OpenScreen-Mac--.dmg +# +# Usage: chmod +x scripts/build_macos.sh && ./scripts/build_macos.sh +# + +set -euo pipefail + +# ── Load .env ───────────────────────────────────────────────────────── +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${PROJECT_ROOT}/.env" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +else + echo "ERROR: .env file not found at ${ENV_FILE}" + echo "Create one with APP_NAME, SIGN_IDENTITY, NOTARY_PROFILE, etc." + exit 1 +fi + +# ── Config ──────────────────────────────────────────────────────────── +VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version") +RELEASE_DIR="${PROJECT_ROOT}/release/${VERSION}" +ENTITLEMENTS="${PROJECT_ROOT}/macos.entitlements" +ARCHS=("arm64" "x64") + +# ── Colors ──────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +print_step() { echo -e "\n${CYAN}${BOLD}▸ $1${NC}"; } +print_ok() { echo -e "${GREEN}✓ $1${NC}"; } +print_warn() { echo -e "${YELLOW}⚠ $1${NC}"; } +print_err() { echo -e "${RED}✗ $1${NC}"; } + +# ── Preflight ───────────────────────────────────────────────────────── +echo -e "\n${BOLD}╔══════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ ${APP_NAME} macOS Build Script v${VERSION} ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}" + +print_step "Checking prerequisites..." + +if [[ "$(uname)" != "Darwin" ]]; then + print_err "This script must be run on macOS." + exit 1 +fi +print_ok "Running on macOS ($(uname -m))" + +if ! command -v node &> /dev/null; then + print_err "Node.js not found. Please install Node.js first." + exit 1 +fi +print_ok "Node.js found: $(node -v)" + +if ! command -v npm &> /dev/null; then + print_err "npm not found." + exit 1 +fi +print_ok "npm found: $(npm -v)" + +# Check signing identity +if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then + print_err "Signing identity not found: ${SIGN_IDENTITY}" + print_err "Run 'security find-identity -v -p codesigning' to see available identities." + exit 1 +fi +print_ok "Signing identity found: ${SIGN_IDENTITY}" + +# Check notary profile +if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &> /dev/null; then + print_err "Notary profile '${NOTARY_PROFILE}' not found in keychain." + print_err "Run: xcrun notarytool store-credentials \"${NOTARY_PROFILE}\" --apple-id \"${APPLE_ID}\" --team-id \"${TEAM_ID}\"" + exit 1 +fi +print_ok "Notary profile found: ${NOTARY_PROFILE}" + +# Check entitlements +if [ ! -f "$ENTITLEMENTS" ]; then + print_err "Entitlements file not found: ${ENTITLEMENTS}" + exit 1 +fi +print_ok "Entitlements file found" + +# ── Clean ───────────────────────────────────────────────────────────── +cd "$PROJECT_ROOT" + +print_step "Cleaning previous build artifacts..." +rm -rf dist dist-electron "${RELEASE_DIR}" +print_ok "Clean complete" + +# ── Install Dependencies ───────────────────────────────────────────── +print_step "Installing dependencies..." +npm ci +print_ok "Dependencies installed" + +# ── Build Vite + Electron ──────────────────────────────────────────── +print_step "Building Vite + Electron... (this may take a minute)" +npx tsc && npx vite build +print_ok "Vite + Electron build complete" + +# ── Package, Sign, Notarize per Architecture ───────────────────────── +for ARCH in "${ARCHS[@]}"; do + echo "" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD} Building for: ${ARCH}${NC}" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + # ── Package with electron-builder ───────────────────────────── + print_step "[${ARCH}] Packaging with electron-builder..." + + # Build .app only (--dir), electron-builder handles codesigning + # with hardenedRuntime + entitlements from electron-builder.json5 + CSC_NAME="$CSC_NAME" npx electron-builder --mac --${ARCH} --dir + + # Find the .app bundle + APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | grep -i "${ARCH}\|mac" | head -n1) + if [ -z "$APP_BUNDLE" ]; then + # Fallback: find any .app in the output + APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1) + fi + + if [ -z "$APP_BUNDLE" ]; then + print_err "[${ARCH}] Could not find .app bundle in ${RELEASE_DIR}" + exit 1 + fi + print_ok "[${ARCH}] App bundle: $(basename "$APP_BUNDLE")" + + # ── Verify codesign on .app ─────────────────────────────────── + print_step "[${ARCH}] Verifying .app code signature..." + codesign --verify --deep --strict "$APP_BUNDLE" 2>&1 || print_warn "[${ARCH}] Deep verify had warnings (may be expected pre-notarization)" + print_ok "[${ARCH}] .app signature verified" + + # ── Create DMG ──────────────────────────────────────────────── + DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + DMG_STAGING="${RELEASE_DIR}/dmg-staging-${ARCH}" + + print_step "[${ARCH}] Creating DMG..." + + rm -f "$DMG_OUTPUT" + rm -rf "$DMG_STAGING" + + # Stage: app + Applications shortcut for drag-to-install + mkdir -p "$DMG_STAGING" + cp -R "$APP_BUNDLE" "$DMG_STAGING/" + ln -s /Applications "$DMG_STAGING/Applications" + + hdiutil create \ + -srcfolder "$DMG_STAGING" \ + -volname "${APP_NAME}" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + print_ok "[${ARCH}] DMG created: ${DMG_NAME}" + rm -rf "$DMG_STAGING" + + # ── Sign DMG ────────────────────────────────────────────────── + print_step "[${ARCH}] Signing DMG..." + codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_OUTPUT" + print_ok "[${ARCH}] DMG signed" + + # ── Notarize DMG ────────────────────────────────────────────── + print_step "[${ARCH}] Notarizing DMG with Apple... (this may take several minutes)" + xcrun notarytool submit "$DMG_OUTPUT" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + print_ok "[${ARCH}] DMG notarized" + + # ── Staple ──────────────────────────────────────────────────── + print_step "[${ARCH}] Stapling notarization ticket..." + xcrun stapler staple "$DMG_OUTPUT" + print_ok "[${ARCH}] Ticket stapled" + + # ── Validate ────────────────────────────────────────────────── + print_step "[${ARCH}] Validating stapled DMG..." + xcrun stapler validate "$DMG_OUTPUT" + print_ok "[${ARCH}] Validation passed" + +done + +# ── Clean up unpacked dirs (keep only DMGs) ─────────────────────────── +print_step "Cleaning up intermediate directories..." +find "${RELEASE_DIR}" -maxdepth 1 -type d ! -name "$(basename "$RELEASE_DIR")" -exec rm -rf {} + 2>/dev/null || true +print_ok "Cleanup complete" + +# ── Done ────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}" +echo -e "${GREEN}${BOLD} Build & Notarization Complete!${NC}" +echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}" +echo "" + +for ARCH in "${ARCHS[@]}"; do + DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg" + DMG_PATH="${RELEASE_DIR}/${DMG_NAME}" + if [ -f "$DMG_PATH" ]; then + DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1) + echo -e " 📦 ${BOLD}${ARCH}:${NC} ${DMG_PATH}" + echo -e " 📏 ${BOLD}Size:${NC} ${DMG_SIZE}" + echo "" + fi +done + +echo -e " ${GREEN}All DMGs are fully signed, notarized, and stapled!${NC}" +echo -e " ${GREEN}Ready for distribution outside the Mac App Store.${NC}" +echo ""