diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9341438..49fe6f0 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -14,8 +14,22 @@ concurrency: jobs: release: - name: Draft Desktop Release - runs-on: windows-latest + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + runner: windows-latest + rust_target: x86_64-pc-windows-msvc + - target: aarch64-apple-darwin + runner: macos-latest + rust_target: aarch64-apple-darwin + - target: x86_64-apple-darwin + runner: macos-13 + rust_target: x86_64-apple-darwin steps: - name: Checkout repository @@ -38,12 +52,13 @@ jobs: - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-pc-windows-msvc + targets: ${{ matrix.rust_target }} - name: Cache Rust uses: Swatinem/rust-cache@v2 with: workspaces: desktop/src-tauri -> .codex-cargo-target/desktop-tauri + key: ${{ matrix.rust_target }} - name: Install dependencies run: pnpm install @@ -101,27 +116,102 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} RELEASE_NOTES_OUTPUT: desktop/.codex-temp/release-assets/RELEASE_NOTES.md - - name: Create or update draft GitHub release + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.target }} + path: | + desktop/.codex-temp/release-assets/ + .codex-cargo-target/desktop-tauri/release/bundle/ + + merge-and-publish: + name: Merge Manifest & Publish Release + needs: release + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + pattern: build-* + path: build-artifacts + merge-multiple: false + + - name: Collect and merge updater manifests shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - $tag = "${{ github.ref_name }}" - $repo = "${{ github.repository }}" - $notesPath = (Resolve-Path "desktop/.codex-temp/release-assets/RELEASE_NOTES.md").Path - $manifestPath = (Resolve-Path "desktop/.codex-temp/release-assets/latest.json").Path - $bundleRoot = (Resolve-Path ".codex-cargo-target/desktop-tauri/release/bundle").Path + $manifestDir = "merged-manifests" + New-Item -ItemType Directory -Path $manifestDir -Force + + Get-ChildItem -Path "build-artifacts" -Recurse -Filter "latest.json" | + ForEach-Object { + Copy-Item $_.FullName -Destination (Join-Path $manifestDir "latest-$($_.Directory.Parent.Name).json") -Force + } + + node scripts/merge-updater-manifests.mjs $manifestDir desktop/.codex-temp/release-assets/latest.json + + - name: Collect release notes + shell: pwsh + run: | + $notesFile = Get-ChildItem -Path "build-artifacts" -Recurse -Filter "RELEASE_NOTES.md" | + Select-Object -First 1 + if ($notesFile) { + New-Item -ItemType Directory -Path "desktop/.codex-temp/release-assets" -Force + Copy-Item $notesFile.FullName -Destination "desktop/.codex-temp/release-assets/RELEASE_NOTES.md" -Force + } + + - name: Collect all platform assets + id: collect + shell: pwsh + run: | + $assets = @() - $assets = Get-ChildItem -Path $bundleRoot -Recurse -File | + # Windows assets + Get-ChildItem -Path "build-artifacts" -Recurse -File | Where-Object { $_.Extension -in @('.exe', '.msi', '.zip', '.sig') } | - Select-Object -ExpandProperty FullName + ForEach-Object { $assets += $_.FullName } + # macOS assets + Get-ChildItem -Path "build-artifacts" -Recurse -File | + Where-Object { $_.Extension -in @('.dmg', '.app', '.sig') } | + ForEach-Object { $assets += $_.FullName } + + # Merged manifest + $manifestPath = (Resolve-Path "desktop/.codex-temp/release-assets/latest.json").Path $assets += $manifestPath if ($assets.Count -eq 0) { - throw "No release assets were found under $bundleRoot." + throw "No release assets were found." } + # Write asset list to file for next step + $assets -join "`n" | Out-File -FilePath "asset-list.txt" -Encoding utf8 + Write-Output "Asset count: $($assets.Count)" + + - name: Create or update draft GitHub release + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $tag = "${{ github.ref_name }}" + $repo = "${{ github.repository }}" + $notesPath = "desktop/.codex-temp/release-assets/RELEASE_NOTES.md" + $assetList = Get-Content "asset-list.txt" | Where-Object { $_.Trim().Length -gt 0 } + & gh release view $tag --repo $repo *> $null $releaseExists = $LASTEXITCODE -eq 0 @@ -132,7 +222,7 @@ jobs: "--title", $tag, "--draft", "--notes-file", $notesPath - ) + $assets + ) + $assetList & gh @createArgs exit $LASTEXITCODE } @@ -153,6 +243,6 @@ jobs: "release", "upload", $tag, "--repo", $repo, "--clobber" - ) + $assets + ) + $assetList & gh @uploadArgs exit $LASTEXITCODE diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 31b5920..7b67c6d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2098,6 +2098,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3571,9 +3581,10 @@ dependencies = [ [[package]] name = "rolerover-desktop" -version = "1.1.0" +version = "1.1.1" dependencies = [ "futures-util", + "keyring", "pdf-extract", "reqwest 0.12.28", "rusqlite", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d290b5f..e07c084 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,3 +27,6 @@ tauri-plugin-updater = "2.0.0-rc.4" uuid = { version = "1.18.1", features = ["v4"] } pdf-extract = "0.7" +[target.'cfg(target_os = "macos")'.dependencies] +keyring = "3" + diff --git a/desktop/src-tauri/entitlements/macOS.entitlements b/desktop/src-tauri/entitlements/macOS.entitlements new file mode 100644 index 0000000..f5093df --- /dev/null +++ b/desktop/src-tauri/entitlements/macOS.entitlements @@ -0,0 +1,13 @@ + + + + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.user-selected.read-write + + + diff --git a/desktop/src-tauri/src/settings.rs b/desktop/src-tauri/src/settings.rs index 515dd6b..d5e0caf 100644 --- a/desktop/src-tauri/src/settings.rs +++ b/desktop/src-tauri/src/settings.rs @@ -880,8 +880,12 @@ fn evaluate_runtime_vault_state( )); } if warnings.is_empty() && keyring_active_count > 0 { - warnings - .push("Active secret descriptors are backed by Windows Credential Manager.".into()); + #[cfg(target_os = "windows")] + warnings.push("Active secret descriptors are backed by Windows Credential Manager.".into()); + #[cfg(target_os = "macos")] + warnings.push("Active secret descriptors are backed by macOS Keychain.".into()); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + warnings.push("Active secret descriptors are backed by OS keyring.".into()); } } @@ -1141,7 +1145,7 @@ fn decode_legacy_entry_to_utf8( } fn os_keyring_backend_supported() -> bool { - cfg!(target_os = "windows") + cfg!(target_os = "windows") || cfg!(target_os = "macos") } fn secret_keyring_target(secret_key: &str) -> String { @@ -1154,7 +1158,11 @@ fn read_secret_from_os_keyring(secret_key: &str) -> Result, Strin { windows_credential::read(&target) } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + macos_keychain::read(&target) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = target; Ok(None) @@ -1167,11 +1175,15 @@ fn write_secret_to_os_keyring(secret_key: &str, value: &str) -> Result<(), Strin { windows_credential::write(&target, value) } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + macos_keychain::write(&target, value) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = target; let _ = value; - Err("OS keyring backend is only available on Windows in this PR6 slice.".into()) + Err("OS keyring backend is only available on Windows and macOS.".into()) } } @@ -1181,7 +1193,11 @@ fn delete_secret_from_os_keyring(secret_key: &str) -> Result<(), String> { { windows_credential::delete(&target) } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + macos_keychain::delete(&target) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = target; Ok(()) @@ -1365,6 +1381,41 @@ mod windows_credential { } } +#[cfg(target_os = "macos")] +mod macos_keychain { + pub fn read(target: &str) -> Result, String> { + let entry = keyring::Entry::new("RoleRoverDesktop", target) + .map_err(|error| format!("failed to create keyring entry for read ({target}): {error}"))?; + match entry.get_password() { + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(format!( + "failed to read secret from macOS Keychain ({target}): {error}" + )), + } + } + + pub fn write(target: &str, value: &str) -> Result<(), String> { + let entry = keyring::Entry::new("RoleRoverDesktop", target) + .map_err(|error| format!("failed to create keyring entry for write ({target}): {error}"))?; + entry.set_password(value).map_err(|error| { + format!("failed to write secret to macOS Keychain ({target}): {error}") + }) + } + + pub fn delete(target: &str) -> Result<(), String> { + let entry = keyring::Entry::new("RoleRoverDesktop", target) + .map_err(|error| format!("failed to create keyring entry for delete ({target}): {error}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(format!( + "failed to delete secret from macOS Keychain ({target}): {error}" + )), + } + } +} + fn normalize_provider_key(provider: &str) -> Option<&'static str> { match provider.trim().to_ascii_lowercase().as_str() { "openai" | "custom" | "azure" => Some("openai"), diff --git a/desktop/src-tauri/src/storage.rs b/desktop/src-tauri/src/storage.rs index 1155583..4329e47 100644 --- a/desktop/src-tauri/src/storage.rs +++ b/desktop/src-tauri/src/storage.rs @@ -911,6 +911,10 @@ fn resolve_pdf_browser_path() -> Result { let candidates = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/opt/homebrew/bin/google-chrome", + "/opt/homebrew/bin/microsoft-edge", + "/usr/local/bin/google-chrome", + "/usr/local/bin/microsoft-edge", ]; #[cfg(target_os = "linux")] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0cc298f..0728271 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -42,7 +42,17 @@ "active": true, "createUpdaterArtifacts": true, "icon": [ - "icons/icon.ico" - ] + "icons/icon.ico", + "icons/icon.icns", + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.png" + ], + "macOS": { + "minimumSystemVersion": "12.0", + "entitlements": "entitlements/macOS.entitlements", + "signingIdentity": null + } } } diff --git a/scripts/build-local-updater-feed.mjs b/scripts/build-local-updater-feed.mjs index 50f991d..07638c5 100644 --- a/scripts/build-local-updater-feed.mjs +++ b/scripts/build-local-updater-feed.mjs @@ -19,7 +19,7 @@ const bundleDirCandidates = [ path.join(ROOT, "desktop", "src-tauri", "target", "release", "bundle"), ].filter((value) => typeof value === "string" && value.length > 0); const bundleDir = bundleDirCandidates.find((candidate) => fs.existsSync(candidate)); -const supportedArtifactExtensions = [".zip", ".exe", ".msi"]; +const supportedArtifactExtensions = [".zip", ".exe", ".msi", ".dmg"]; function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -67,6 +67,9 @@ const preferred = candidates.sort((left, right) => { if (normalized.includes("nsis") && extension === ".exe") { return 4; } + if (extension === ".dmg") { + return 4; + } if (normalized.includes("msi") && extension === ".msi") { return 3; } @@ -95,7 +98,10 @@ fs.copyFileSync(preferred.sigPath, signatureTargetPath); const signature = fs.readFileSync(preferred.sigPath, "utf8").trim(); const baseUrl = process.env.DESKTOP_UPDATER_FEED_BASE_URL ?? "http://127.0.0.1:8765/artifacts"; -const target = process.env.DESKTOP_UPDATER_TARGET ?? "windows-x86_64"; +const target = process.env.DESKTOP_UPDATER_TARGET?.trim() + || (process.platform === "darwin" + ? (process.arch === "arm64" ? "darwin-aarch64" : "darwin-x86_64") + : "windows-x86_64"); const latestJson = { version: tauriConfig.version, notes: "Local updater smoke-test feed for RoleRover Desktop.", diff --git a/scripts/build-release-updater-manifest.mjs b/scripts/build-release-updater-manifest.mjs index 3f6e2b4..0ba739a 100644 --- a/scripts/build-release-updater-manifest.mjs +++ b/scripts/build-release-updater-manifest.mjs @@ -29,7 +29,7 @@ const bundleDirCandidates = [ path.join(ROOT, ".codex-cargo-target", "desktop-tauri", "release", "bundle"), path.join(ROOT, "desktop", "src-tauri", "target", "release", "bundle"), ].filter((value) => typeof value === "string" && value.length > 0); -const supportedArtifactExtensions = [".zip", ".exe", ".msi"]; +const supportedArtifactExtensions = [".zip", ".exe", ".msi", ".dmg"]; function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -51,6 +51,9 @@ function scoreArtifact(artifactPath) { if (normalized.includes("nsis") && extension === ".exe") { return 4; } + if (extension === ".dmg") { + return 4; + } if (normalized.includes("msi") && extension === ".msi") { return 3; } @@ -114,7 +117,10 @@ const preferred = candidates.sort((left, right) => { const artifactName = path.basename(preferred.artifactPath); const signature = fs.readFileSync(preferred.sigPath, "utf8").trim(); -const target = process.env.DESKTOP_UPDATER_TARGET?.trim() || "windows-x86_64"; +const target = process.env.DESKTOP_UPDATER_TARGET?.trim() + || (process.platform === "darwin" + ? (process.arch === "arm64" ? "darwin-aarch64" : "darwin-x86_64") + : "windows-x86_64"); const downloadUrl = `https://github.com/${repository}/releases/download/${encodeURIComponent(releaseTag)}/${encodeURIComponent(artifactName.replace(/ /g, "."))}`; const releaseNotes = collectReleaseNotes(ROOT, releaseTag, repository); diff --git a/scripts/merge-updater-manifests.mjs b/scripts/merge-updater-manifests.mjs new file mode 100644 index 0000000..610c0fd --- /dev/null +++ b/scripts/merge-updater-manifests.mjs @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const inputDir = process.argv[2]; +const outputPath = process.argv[3]; + +if (!inputDir || !outputPath) { + console.error( + "[merge-updater-manifests] Usage: node merge-updater-manifests.mjs ", + ); + process.exit(1); +} + +const manifestFiles = fs + .readdirSync(inputDir) + .filter((name) => name.endsWith(".json")) + .map((name) => path.join(inputDir, name)); + +if (manifestFiles.length === 0) { + console.error("[merge-updater-manifests] No manifest JSON files found in input directory."); + process.exit(1); +} + +const manifests = manifestFiles.map((filePath) => { + const content = fs.readFileSync(filePath, "utf8"); + return JSON.parse(content); +}); + +const versions = new Set(manifests.map((m) => m.version)); +if (versions.size > 1) { + console.error( + `[merge-updater-manifests] Version mismatch across manifests: ${[...versions].join(", ")}`, + ); + process.exit(1); +} + +const merged = { + version: manifests[0].version, + notes: manifests.reduce((longest, m) => (m.notes?.length > (longest?.length ?? 0) ? m.notes : longest), ""), + pub_date: manifests.reduce((latest, m) => (m.pub_date > latest ? m.pub_date : latest), manifests[0].pub_date), + platforms: {}, +}; + +for (const manifest of manifests) { + if (manifest.platforms && typeof manifest.platforms === "object") { + Object.assign(merged.platforms, manifest.platforms); + } +} + +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(merged, null, 2)}\n`); + +const platformList = Object.keys(merged.platforms).join(", "); +console.log(`[merge-updater-manifests] Merged ${manifests.length} manifest(s) -> ${outputPath}`); +console.log(`[merge-updater-manifests] Platforms: ${platformList}`); +console.log(`[merge-updater-manifests] Version: ${merged.version}`); diff --git a/scripts/verify-desktop-release-readiness.mjs b/scripts/verify-desktop-release-readiness.mjs index 4836153..8377c85 100644 --- a/scripts/verify-desktop-release-readiness.mjs +++ b/scripts/verify-desktop-release-readiness.mjs @@ -159,6 +159,16 @@ function collectChecks() { label: "Windows desktop icon asset exists", detail: `Expected icon at ${TAURI_ICON_PATH}.`, }, + { + status: fileExists("desktop/src-tauri/icons/icon.icns") ? "pass" : "warn", + label: "macOS desktop icon asset exists", + detail: "Expected icon at desktop/src-tauri/icons/icon.icns.", + }, + { + status: fileExists("desktop/src-tauri/entitlements/macOS.entitlements") ? "pass" : "warn", + label: "macOS entitlements file exists", + detail: "Expected entitlements at desktop/src-tauri/entitlements/macOS.entitlements.", + }, { status: fileExists(checklistPath) ? "pass" : "fail", label: "Windows release smoke checklist exists",