diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index add3585c..996107ab 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: release_tag: - description: "Prepare macOS assets on this existing Draft Release (optional)" + description: "Prepare macOS and Windows assets on this existing Draft Release (optional)" required: false type: string release: @@ -103,10 +103,10 @@ jobs: needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'false' }} - name: Reuse macOS release assets + name: Reuse desktop release assets runs-on: ubuntu-latest steps: - - name: Copy latest compatible macOS assets without renaming + - name: Copy latest compatible desktop assets without renaming env: CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} GH_TOKEN: ${{ github.token }} @@ -124,8 +124,13 @@ jobs: --pattern 'EdgeEver-*-mac-x64.zip' \ --pattern 'EdgeEver-*-mac-x64.zip.blockmap' \ --pattern 'latest-mac.yml' \ + --pattern 'EdgeEver-*-windows-x64.exe' \ + --pattern 'latest.yml' \ + --pattern 'latest-windows.json' \ + --pattern 'latest-windows.json.sig' \ + --pattern 'SHA256SUMS-windows.txt' \ --dir "$RUNNER_TEMP/native-assets" - test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "9" + test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "14" gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \ --repo "$GITHUB_REPOSITORY" \ --clobber @@ -327,15 +332,137 @@ jobs: if-no-files-found: error retention-days: 14 - native-build-timings: - needs: desktop + windows: + needs: release-plan if: >- ${{ always() && github.repository == 'tianma-if/edgeever' && - needs.desktop.result != 'skipped' + ( + (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || + ( + github.event_name == 'workflow_dispatch' && + inputs.release_tag != '' && + needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'false' && + needs.release-plan.outputs.rebuild == 'true' + ) + ) }} - name: Report macOS build timings + name: Windows x64 unsigned Preview + runs-on: windows-latest + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Bun dependencies + uses: actions/cache@v5 + with: + path: ~\AppData\Local\bun\install\cache + key: ${{ runner.os }}-x64-bun-${{ hashFiles('bun.lock') }} + + - name: Cache Rust dependencies and sidecar builds + uses: actions/cache@v5 + with: + path: | + ~\.cargo\registry + ~\.cargo\git + crates\desktop-sidecar\target + key: ${{ runner.os }}-x64-rust-${{ hashFiles('crates/desktop-sidecar/Cargo.lock', 'crates/desktop-sidecar/**/*.rs') }} + restore-keys: | + ${{ runner.os }}-x64-rust- + + - name: Cache Electron downloads + uses: actions/cache@v5 + with: + path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + key: ${{ runner.os }}-x64-electron-${{ hashFiles('bun.lock', 'apps/desktop/package.json') }} + + - name: Install dependencies + shell: pwsh + run: | + for ($attempt = 1; $attempt -le 3; $attempt++) { + bun install --frozen-lockfile + $installExitCode = $LASTEXITCODE + if ($installExitCode -eq 0) { exit 0 } + if ($attempt -eq 3) { exit $installExitCode } + Write-Warning "Dependency installation failed on attempt $attempt; retrying." + Start-Sleep -Seconds (15 * $attempt) + } + + - name: Build Web renderer + env: + EDGE_EVER_DESKTOP_BUILD: "1" + run: bun run build:web + + - name: Build x64 Rust sidecar + run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release + + - name: Package unsigned Windows installer + env: + EDGE_EVER_DESKTOP_ARCH: x64 + EDGE_EVER_DESKTOP_TARGET: win + run: bun scripts/run-desktop-builder.mjs --publish never + + - name: Verify Windows package contents and architecture + env: + EDGE_EVER_DESKTOP_ARCH: x64 + EDGE_EVER_VERIFY_TARGET: win32 + run: bun scripts/verify-desktop-package.mjs + + - name: Verify that Preview executables are unsigned + shell: pwsh + run: | + $version = (Get-Content apps/desktop/package.json | ConvertFrom-Json).version + $paths = @( + "release/desktop/EdgeEver-$version-windows-x64.exe", + "release/desktop/win-unpacked/EdgeEver.exe", + "release/desktop/win-unpacked/resources/sidecar/edgeever-sidecar.exe" + ) + foreach ($path in $paths) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne "NotSigned") { + throw "Unsigned Preview asset unexpectedly has an Authenticode signature: $path ($($signature.Status))" + } + } + + - name: Create Windows update metadata + shell: pwsh + run: | + $version = (Get-Content apps/desktop/package.json | ConvertFrom-Json).version + node scripts/create-windows-update-metadata.mjs release/desktop $version + + - name: Stage Windows Preview assets + uses: actions/upload-artifact@v6 + with: + name: edgeever-desktop-Windows-x64-staging + path: | + release/desktop/EdgeEver-*-windows-x64.exe + release/desktop/latest.yml + release/desktop/latest-windows.json + release/desktop/SHA256SUMS-windows.txt + if-no-files-found: error + retention-days: 14 + + native-build-timings: + needs: [desktop, windows] + if: >- + ${{ + always() && + github.repository == 'tianma-if/edgeever' && + (needs.desktop.result != 'skipped' || needs.windows.result != 'skipped') + }} + name: Report desktop build timings runs-on: ubuntu-latest steps: - name: Check out timing reporter @@ -364,12 +491,13 @@ jobs: retention-days: 90 desktop-assets: - needs: [release-plan, desktop] + needs: [release-plan, desktop, windows] if: >- ${{ always() && github.repository == 'tianma-if/edgeever' && needs.desktop.result == 'success' && + needs.windows.result == 'success' && ( (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || ( @@ -381,16 +509,16 @@ jobs: ) ) }} - name: Finalize macOS release assets + name: Finalize desktop release assets runs-on: ubuntu-latest steps: - name: Check out source uses: actions/checkout@v5 - - name: Download architecture-specific assets + - name: Download platform-specific assets uses: actions/download-artifact@v7 with: - pattern: edgeever-desktop-macOS-*-staging + pattern: edgeever-desktop-*-staging path: release/desktop merge-multiple: true @@ -400,7 +528,7 @@ jobs: node scripts/create-mac-update-metadata.mjs release/desktop "$version" asset_ready="$( find release/desktop -maxdepth 1 -type f -exec basename {} \; | - node scripts/check-native-release-assets.mjs desktop true "v${version}" "$version" + node scripts/check-native-release-assets.mjs desktop true "v${version}" "$version" allow-missing-windows-signature )" test "$asset_ready" = "true" @@ -417,7 +545,44 @@ jobs: - name: Upload combined desktop installers and metadata uses: actions/upload-artifact@v6 with: - name: edgeever-desktop-macOS-arm64-x64 + name: edgeever-desktop-macOS-and-Windows-x64 path: release/desktop/* if-no-files-found: error retention-days: 14 + + windows-update-audit: + needs: release-plan + if: >- + ${{ + always() && + github.repository == 'tianma-if/edgeever' && + needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'true' && + ( + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + ) + }} + name: Audit signed Windows update + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Download Windows release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + run: | + mkdir -p release/desktop + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'EdgeEver-*-windows-x64.exe' \ + --pattern 'latest.yml' \ + --pattern 'latest-windows.json' \ + --pattern 'latest-windows.json.sig' \ + --pattern 'SHA256SUMS-windows.txt' \ + --dir release/desktop + + - name: Verify signature and installer digests + run: node scripts/verify-windows-update-release.mjs release/desktop diff --git a/AGENTS.md b/AGENTS.md index f548ec73..ea89a79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ 1. **Fork 工作流边界**:配置 GitHub Actions 时必须考虑大量用户会 Fork 仓库进行自部署;仅官方仓库需要的 Job 必须使用 `github.repository == 'tianma-if/edgeever'` 门禁,严禁在下游 Fork 中分配 Runner 或执行。 2. **版本号与基线**:`vX.Y.Z`(非 Draft/Prerelease)。发布须显式 `--bump patch|minor|major`(脚本不自动选级);按 SemVer 选择,**禁止因发版节奏把用户可感知的新能力或新平台压成 patch**。递增根目录 `package.json`;含移动端修改时同步 `apps/mobile/app.json` 的 `expo.version` 并递增 `android.versionCode`。上一个正式 Release 为审计基线。 -3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG 和 Android arm64 APK。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。 +3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG、Windows x64 Preview 安装包和 Android arm64 APK。Windows Preview 未使用 Authenticode 签名时必须同时提供经仓库外 Ed25519 私钥签名的更新清单,并在发布前通过独立下载与摘要审计。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。 4. **验证命令**:必须通过 `bun run typecheck`、`bun run typecheck:mobile` 和 `bun run build:web`。 5. **测试职责边界**:正式 Release 必须先在官方仓库及与下游一致的 Ubuntu 环境通过完整非 E2E 测试,严禁将上游自身的测试失败转嫁给下游 Fork 发现。只读部署 Fork 仅同步产品快照且不运行测试;只有显式保留定制改动的 Fork 才验证合并结果,失败时必须保持 `main` 与生产环境不变。 6. **原生资产构建与复用**:由 `scripts/plan-native-release.mjs` 决定重建或复用;桌面资产包含 `apps/web`。修改判定规则时同步更新测试。移动端重建使用 `bun run build:android:apk:local`,签名配置保存在仓库外。 diff --git a/README.md b/README.md index 20d93dd0..90b90a3f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s - **Batch Operations & Flexible Sorting**: Easily merge or relocate multiple notes, with drag-and-drop notebook reordering. - **Offline Drafts & Queueing**: Draft and edit uninterrupted while offline; changes automatically sync once reconnected. - **Multi-Tenant Account Isolation**: Host multiple user accounts on a single instance with strictly partitioned spaces and clean admin account management. -- **Everywhere You Need It**: Available on the Web, [Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile), [macOS](https://github.com/tianma-if/edgeever/releases), and [iOS](https://apps.apple.com/us/app/edgeever/id6792625631), with Windows coming soon; the Web Clipper supports [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), [Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), and [Firefox](https://addons.mozilla.org/firefox/addon/edgeever-web-clipper/). +- **Everywhere You Need It**: Available on the Web, [Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile), [macOS](https://github.com/tianma-if/edgeever/releases), [Windows x64 Preview](https://github.com/tianma-if/edgeever/releases/latest), and [iOS](https://apps.apple.com/us/app/edgeever/id6792625631); the Web Clipper supports [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), [Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), and [Firefox](https://addons.mozilla.org/firefox/addon/edgeever-web-clipper/). ## Deployment @@ -139,7 +139,9 @@ Native clients offer a smoother, more reliable experience with deeper system int The Android app is now available on [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile), with signed APKs also available from [GitHub Releases](https://github.com/tianma-if/edgeever/releases). The iOS app is available on the [App Store](https://apps.apple.com/us/app/edgeever/id6792625631); use a non-mainland China Apple ID to download it. -The macOS app is available from [GitHub Releases](https://github.com/tianma-if/edgeever/releases). The Windows version will be released once the code-signing certificate issue is resolved. +The macOS app and the unsigned [Windows x64 Preview](https://github.com/tianma-if/edgeever/releases/latest) are available from GitHub Releases. Windows or organization policy may show a warning or block the Preview because it is not yet Authenticode-signed; download it only from the official EdgeEver Release. Do not weaken Windows security settings for EdgeEver—use the Web/PWA client if policy blocks installation. + +After installation, the Windows Preview keeps the normal automatic update experience: EdgeEver checks releases, validates an independently Ed25519-signed manifest before downloading, validates the downloaded installer again, and then prompts for restart installation (or installs when EdgeEver later quits). Missing or invalid metadata fails closed. See [Windows Preview security and updates](docs/windows-preview.md). On platforms without a native client, EdgeEver can be installed as a PWA using Chrome or Edge. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ff69931..311e6067 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,7 +56,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印 - **高效多选与批量操作**:支持笔记批量合并、批量移动,以及笔记本拖拽排序与层级调整。 - **离线草稿与同步队列**:网络不稳定时自动保存离线草稿,恢复连线后自动入队同步。 - **多账号与个人空间隔离**:单实例支持创建多个独立账号,用户数据相互隔离,配备直观的管理员账号管理与安全加密机制。 -- **全平台多端覆盖**:支持 Web、[Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile)、[macOS](https://github.com/tianma-if/edgeever/releases) 和 [iOS](https://apps.apple.com/us/app/edgeever/id6792625631),Windows 版即将推出;网页裁剪插件支持 [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo)、[Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo) 和 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/edgeever-web-clipper/)。 +- **全平台多端覆盖**:支持 Web、[Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile)、[macOS](https://github.com/tianma-if/edgeever/releases)、[Windows x64 预览版](https://github.com/tianma-if/edgeever/releases/latest) 和 [iOS](https://apps.apple.com/us/app/edgeever/id6792625631);网页裁剪插件支持 [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo)、[Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo) 和 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/edgeever-web-clipper/)。 ## 部署 @@ -135,7 +135,9 @@ EdgeEver 官方容器镜像托管于 GitHub Container Registry(GHCR)。部 Android App 现已上架 [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile),也可从 [GitHub Releases](https://github.com/tianma-if/edgeever/releases) 下载签名 APK。iOS App 现已上架 [App Store](https://apps.apple.com/us/app/edgeever/id6792625631),可使用非大陆区的 Apple ID 下载。 -macOS App 可从 [GitHub Releases](https://github.com/tianma-if/edgeever/releases) 下载。Windows 版本正在处理代码签名证书问题,解决后即可发布。 +macOS App 与未签名的 [Windows x64 预览版](https://github.com/tianma-if/edgeever/releases/latest) 均可从 GitHub Releases 下载。Windows 预览版尚未使用 Authenticode 签名,系统或组织策略可能显示警告或阻止安装;请仅从 EdgeEver 官方 Release 下载。不要为安装 EdgeEver 降低 Windows 安全设置;如策略阻止安装,请继续使用 Web/PWA 客户端。 + +安装后,Windows 预览版仍保留正常的自动更新体验:EdgeEver 发现新 Release 后,会在下载前验证独立 Ed25519 签名的更新清单,下载完成后再次验证安装包,再提示重启安装(选择稍后则在退出 EdgeEver 时自动安装);清单缺失、签名错误或文件不一致都会停止更新。详见 [Windows 预览版安全与更新说明](docs/windows-preview.zh-CN.md)。 暂无原生客户端的平台,可通过 Chrome 或 Edge 将 EdgeEver 安装为 PWA 使用。 diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index 3146c0f3..efa91345 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -9,10 +9,20 @@ const notebookPaneSource = readFileSync(new URL("../../../web/src/components/Not describe("desktop update flow", () => { test("downloads updates in the background and relaunches after installation", () => { - expect(mainSource).toContain("autoUpdater.autoDownload = true"); + expect(mainSource).toContain('autoUpdater.autoDownload = process.platform !== "win32"'); expect(mainSource).toContain("autoUpdater.autoRunAppAfterInstall = true"); expect(mainSource).toContain("isQuitting = true;\n autoUpdater.quitAndInstall(false, true)"); expect(mainSource).toContain("result?.downloadPromise"); + expect(mainSource).toContain("downloadTrustedDesktopUpdate(reason)"); + }); + + test("fails closed around unsigned Windows automatic updates", () => { + expect(mainSource).toContain("fetchTrustedWindowsUpdate({"); + expect(mainSource).toContain("verifyDownloadedWindowsUpdate({"); + expect(mainSource).toContain('autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"'); + expect(mainSource).toContain("windowsDownloadedUpdateVerified = true"); + expect(mainSource).toContain('writeDiagnostic("update.windows-package-blocked"'); + expect(mainSource).toContain('downloadTrustedDesktopUpdate("manual-download")'); }); test("offers a manual update check in desktop system settings", () => { diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 5e4eec56..13ab9a78 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -25,6 +25,10 @@ import { trayIconPath } from "./tray-icon.mjs"; import { writeRichClipboard } from "./clipboard-write.mjs"; import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs"; import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs"; +import { + fetchTrustedWindowsUpdate, + verifyDownloadedWindowsUpdate, +} from "./windows-update-trust.mjs"; import electronUpdater from "electron-updater"; const { autoUpdater } = electronUpdater; @@ -82,6 +86,8 @@ let updateCheckTimer = null; let lastUpdateCheckAt = 0; let downloadedUpdateVersion = null; let promptedUpdateVersion = null; +let trustedWindowsUpdate = null; +let windowsDownloadedUpdateVerified = false; let sidecarScopeKey = "anonymous"; let activeAccountId = null; let shutdownCleanupStarted = false; @@ -539,7 +545,10 @@ const publishDesktopUpdateStatus = () => { }; const installDownloadedUpdate = () => { - if (updateState !== "downloaded") return { started: false }; + if ( + updateState !== "downloaded" || + (process.platform === "win32" && !windowsDownloadedUpdateVerified) + ) return { started: false }; // The normal window close handler hides the app. Mark this as a real quit // before electron-updater closes windows so installation can proceed. isQuitting = true; @@ -547,6 +556,28 @@ const installDownloadedUpdate = () => { return { started: true }; }; +const trackDesktopUpdateDownload = (downloadPromise, reason) => { + updateDownloadInFlight = Promise.resolve(downloadPromise) + .catch(async (error) => { + updateState = "idle"; + downloadedUpdateVersion = null; + windowsDownloadedUpdateVerified = false; + refreshTrayMenu(); + publishDesktopUpdateStatus(); + await writeDiagnostic("update.download-failed", { reason, message: error.message }); + }) + .finally(() => { updateDownloadInFlight = null; }); + return updateDownloadInFlight; +}; + +const downloadTrustedDesktopUpdate = (reason) => { + if (updateDownloadInFlight) return updateDownloadInFlight; + if (process.platform === "win32" && !trustedWindowsUpdate) { + return Promise.reject(new Error("Windows update metadata has not passed the signature gate")); + } + return trackDesktopUpdateDownload(autoUpdater.downloadUpdate(), reason); +}; + const promptForDownloadedUpdate = async (version) => { const promptKey = version || "unknown"; if (isQuitting || promptedUpdateVersion === promptKey) return; @@ -588,19 +619,32 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = lastUpdateCheckAt = now; void writeDiagnostic("update.check-started", { reason }); updateCheckInFlight = autoUpdater.checkForUpdates() - .then((result) => { + .then(async (result) => { + if (process.platform === "win32" && result?.isUpdateAvailable) { + trustedWindowsUpdate = await fetchTrustedWindowsUpdate({ + version: result.updateInfo.version, + updateInfo: result.updateInfo, + fetchImpl: net.fetch, + }); + windowsDownloadedUpdateVerified = false; + void writeDiagnostic("update.windows-manifest-verified", { + version: trustedWindowsUpdate.version, + keyId: trustedWindowsUpdate.keyId, + }); + void downloadTrustedDesktopUpdate(reason); + } if (result?.downloadPromise) { - updateDownloadInFlight = result.downloadPromise - .catch(async (error) => { - updateState = "idle"; - refreshTrayMenu(); - await writeDiagnostic("update.download-failed", { reason, message: error.message }); - }) - .finally(() => { updateDownloadInFlight = null; }); + trackDesktopUpdateDownload(result.downloadPromise, reason); } return result; }) .catch(async (error) => { + updateState = "idle"; + downloadedUpdateVersion = null; + trustedWindowsUpdate = null; + windowsDownloadedUpdateVerified = false; + refreshTrayMenu(); + publishDesktopUpdateStatus(); await writeDiagnostic("update.check-failed", { reason, message: error.message }); throw error; }) @@ -610,12 +654,16 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = const configureAutoUpdater = () => { if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return; - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.autoDownload = process.platform !== "win32"; + autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"; autoUpdater.autoRunAppAfterInstall = true; autoUpdater.on("update-available", (info) => { updateState = "available"; downloadedUpdateVersion = info?.version || null; + if (process.platform === "win32") { + trustedWindowsUpdate = null; + windowsDownloadedUpdateVerified = false; + } refreshTrayMenu(); publishDesktopUpdateStatus(); void writeDiagnostic("update.available", { version: info?.version }); @@ -623,20 +671,47 @@ const configureAutoUpdater = () => { autoUpdater.on("update-not-available", () => { updateState = "idle"; downloadedUpdateVersion = null; + trustedWindowsUpdate = null; + windowsDownloadedUpdateVerified = false; refreshTrayMenu(); publishDesktopUpdateStatus(); void writeDiagnostic("update.not-available"); }); autoUpdater.on("download-progress", (progress) => { void writeDiagnostic("update.download-progress", { percent: progress.percent }); }); autoUpdater.on("update-downloaded", (info) => { - updateState = "downloaded"; - downloadedUpdateVersion = info?.version || downloadedUpdateVersion; - refreshTrayMenu(); - publishDesktopUpdateStatus(); - void writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion }); - void promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => { - promptedUpdateVersion = null; - void writeDiagnostic("update.prompt-failed", { message: error.message }); + void (async () => { + if (process.platform === "win32") { + if (!trustedWindowsUpdate || trustedWindowsUpdate.version !== info?.version) { + throw new Error("Downloaded Windows update has no matching trusted manifest"); + } + await verifyDownloadedWindowsUpdate({ + path: info.downloadedFile, + manifest: trustedWindowsUpdate, + }); + windowsDownloadedUpdateVerified = true; + autoUpdater.autoInstallOnAppQuit = true; + } + updateState = "downloaded"; + downloadedUpdateVersion = info?.version || downloadedUpdateVersion; + refreshTrayMenu(); + publishDesktopUpdateStatus(); + await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion }); + await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => { + promptedUpdateVersion = null; + void writeDiagnostic("update.prompt-failed", { message: error.message }); + }); + })().catch(async (error) => { + if (process.platform !== "win32") { + await writeDiagnostic("update.download-handler-failed", { message: error.message }); + return; + } + autoUpdater.autoInstallOnAppQuit = false; + updateState = "idle"; + downloadedUpdateVersion = null; + windowsDownloadedUpdateVerified = false; + refreshTrayMenu(); + publishDesktopUpdateStatus(); + await writeDiagnostic("update.windows-package-blocked", { message: error.message }); }); }); autoUpdater.on("error", (error) => { @@ -644,6 +719,7 @@ const configureAutoUpdater = () => { if (updateState !== "downloaded") { updateState = "idle"; downloadedUpdateVersion = null; + windowsDownloadedUpdateVerified = false; } refreshTrayMenu(); publishDesktopUpdateStatus(); @@ -1049,7 +1125,7 @@ app.whenReady().then(async () => { await checkForDesktopUpdate("manual", { force: true, throwOnError: true }); return desktopUpdateStatus(); }); - ipcMain.handle("desktop:download-update", () => autoUpdater.downloadUpdate()); + ipcMain.handle("desktop:download-update", () => downloadTrustedDesktopUpdate("manual-download")); ipcMain.handle("desktop:install-update", () => installDownloadedUpdate()); ipcMain.handle("desktop:stage-resource", async (_event, input) => { const { memoId, name, type, bytes } = normalizeStagedResourceInput(input); diff --git a/apps/desktop/src/main/windows-update-trust.mjs b/apps/desktop/src/main/windows-update-trust.mjs new file mode 100644 index 00000000..ea1108e7 --- /dev/null +++ b/apps/desktop/src/main/windows-update-trust.mjs @@ -0,0 +1,175 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { basename } from "node:path"; + +export const WINDOWS_UPDATE_KEY_ID = "edgeever-windows-update-2026-01"; +export const WINDOWS_UPDATE_MANIFEST_NAME = "latest-windows.json"; +export const WINDOWS_UPDATE_SIGNATURE_NAME = `${WINDOWS_UPDATE_MANIFEST_NAME}.sig`; + +export const WINDOWS_UPDATE_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAOuQwxSey8jGVqrvnkw9LgeBPmeqRieNg/KR0cpUy6RE= +-----END PUBLIC KEY-----`; + +const WINDOWS_UPDATE_PUBLIC_KEYS = Object.freeze({ + [WINDOWS_UPDATE_KEY_ID]: WINDOWS_UPDATE_PUBLIC_KEY_PEM, +}); + +const MAX_METADATA_BYTES = 64 * 1024; +const STABLE_VERSION = /^\d+\.\d+\.\d+$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const SHA512_BASE64 = /^[A-Za-z0-9+/]{86}==$/; + +const releaseAssetUrl = (version, name) => { + if (!STABLE_VERSION.test(version)) { + throw new Error(`Windows update version must be stable X.Y.Z: ${version}`); + } + return `https://github.com/tianma-if/edgeever/releases/download/v${version}/${name}`; +}; + +const responseBytes = async (response, label) => { + if (!response?.ok) { + throw new Error(`${label} request failed with HTTP ${response?.status ?? "unknown"}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_METADATA_BYTES) { + throw new Error(`${label} has an invalid size`); + } + return bytes; +}; + +const parseJson = (bytes, label) => { + try { + return JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error(`${label} is not valid JSON`); + } +}; + +const updateFileName = (value) => { + const withoutQuery = String(value ?? "").split(/[?#]/, 1)[0]; + try { + return basename(decodeURIComponent(withoutQuery)); + } catch { + return ""; + } +}; + +const assertManifestShape = (manifest, expectedVersion) => { + if ( + manifest?.schemaVersion !== 1 || + manifest.keyId !== WINDOWS_UPDATE_KEY_ID || + manifest.version !== expectedVersion || + manifest.platform !== "win32" || + manifest.arch !== "x64" || + typeof manifest.releaseDate !== "string" || + Number.isNaN(Date.parse(manifest.releaseDate)) || + typeof manifest.file?.name !== "string" || + !/^EdgeEver-\d+\.\d+\.\d+-windows-x64\.exe$/.test(manifest.file.name) || + typeof manifest.file.size !== "number" || + !Number.isSafeInteger(manifest.file.size) || + manifest.file.size <= 0 || + typeof manifest.file.sha512 !== "string" || + !SHA512_BASE64.test(manifest.file.sha512) || + typeof manifest.file.sha256 !== "string" || + !SHA256_HEX.test(manifest.file.sha256) + ) { + throw new Error("Windows update manifest fields are invalid"); + } + if (manifest.file.name !== `EdgeEver-${expectedVersion}-windows-x64.exe`) { + throw new Error("Windows update manifest filename does not match its version"); + } +}; + +const assertUpdateInfoMatches = (manifest, updateInfo) => { + const files = updateInfo?.files ?? []; + const matchingFiles = files.filter( + (file) => updateFileName(file?.url) === manifest.file.name, + ); + if (files.length !== 1 || matchingFiles.length !== 1) { + throw new Error("Electron update metadata does not contain exactly one trusted installer"); + } + const [file] = matchingFiles; + if (file.sha512 !== manifest.file.sha512 || file.size !== manifest.file.size) { + throw new Error("Electron update metadata does not match the signed manifest"); + } +}; + +export const verifyWindowsUpdateMetadata = ({ + manifestBytes, + signatureBytes, + expectedVersion, + updateInfo, + trustedPublicKeys = WINDOWS_UPDATE_PUBLIC_KEYS, +}) => { + const signatureEnvelope = parseJson(signatureBytes, "Windows update signature"); + if ( + signatureEnvelope?.schemaVersion !== 1 || + signatureEnvelope.keyId !== WINDOWS_UPDATE_KEY_ID || + typeof signatureEnvelope.signature !== "string" + ) { + throw new Error("Windows update signature fields are invalid"); + } + const signature = Buffer.from(signatureEnvelope.signature, "base64"); + if (signature.byteLength !== 64) { + throw new Error("Windows update signature has an invalid length"); + } + const publicKey = trustedPublicKeys[signatureEnvelope.keyId]; + const verificationKey = publicKey?.type === "public" ? publicKey : publicKey ? createPublicKey(publicKey) : null; + if (!verificationKey || !verify(null, manifestBytes, verificationKey, signature)) { + throw new Error("Windows update manifest signature is invalid"); + } + + const manifest = parseJson(manifestBytes, "Windows update manifest"); + assertManifestShape(manifest, expectedVersion); + assertUpdateInfoMatches(manifest, updateInfo); + return manifest; +}; + +export const fetchTrustedWindowsUpdate = async ({ + version, + updateInfo, + fetchImpl, +}) => { + const request = (name) => fetchImpl(releaseAssetUrl(version, name), { + cache: "no-store", + redirect: "follow", + }); + const [manifestResponse, signatureResponse] = await Promise.all([ + request(WINDOWS_UPDATE_MANIFEST_NAME), + request(WINDOWS_UPDATE_SIGNATURE_NAME), + ]); + const [manifestBytes, signatureBytes] = await Promise.all([ + responseBytes(manifestResponse, "Windows update manifest"), + responseBytes(signatureResponse, "Windows update signature"), + ]); + return verifyWindowsUpdateMetadata({ + manifestBytes, + signatureBytes, + expectedVersion: version, + updateInfo, + }); +}; + +const hashFile = (path, algorithm, encoding) => new Promise((resolve, reject) => { + const hash = createHash(algorithm); + const stream = createReadStream(path); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest(encoding))); +}); + +export const verifyDownloadedWindowsUpdate = async ({ path, manifest }) => { + const fileStat = await stat(path); + if (!fileStat.isFile() || fileStat.size !== manifest.file.size) { + throw new Error("Downloaded Windows installer size does not match the signed manifest"); + } + const [sha512, sha256] = await Promise.all([ + hashFile(path, "sha512", "base64"), + hashFile(path, "sha256", "hex"), + ]); + if (sha512 !== manifest.file.sha512 || sha256 !== manifest.file.sha256) { + throw new Error("Downloaded Windows installer checksum does not match the signed manifest"); + } + return true; +}; diff --git a/apps/desktop/src/main/windows-update-trust.test.mjs b/apps/desktop/src/main/windows-update-trust.test.mjs new file mode 100644 index 00000000..9d301443 --- /dev/null +++ b/apps/desktop/src/main/windows-update-trust.test.mjs @@ -0,0 +1,110 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { + WINDOWS_UPDATE_KEY_ID, + verifyDownloadedWindowsUpdate, + verifyWindowsUpdateMetadata, +} from "./windows-update-trust.mjs"; + +const manifest = { + schemaVersion: 1, + keyId: WINDOWS_UPDATE_KEY_ID, + version: "1.49.0", + platform: "win32", + arch: "x64", + releaseDate: "2026-08-30T00:00:00.000Z", + file: { + name: "EdgeEver-1.49.0-windows-x64.exe", + size: 7, + sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==", + sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + }, +}; + +const updateInfo = { + version: manifest.version, + files: [{ + url: manifest.file.name, + size: manifest.file.size, + sha512: manifest.file.sha512, + }], +}; + +const signedFixture = () => { + const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + const signature = "Itc3UD0G6Hn6BWdk9qlJ4SoqAUDn8efi1VRVUWDtA3Ip5+gwpAj7HxElxM6D/GXcAlHFxnVv19soEWxlfZLCDA=="; + return { + manifestBytes: bytes, + signatureBytes: Buffer.from(`${JSON.stringify({ schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, signature })}\n`), + }; +}; + +describe("Windows update trust", () => { + test("accepts metadata signed by the pinned release key", () => { + const signed = signedFixture(); + expect(verifyWindowsUpdateMetadata({ + ...signed, + expectedVersion: manifest.version, + updateInfo, + })).toEqual(manifest); + }); + + test("rejects metadata signed by any other key", () => { + const { privateKey } = generateKeyPairSync("ed25519"); + const bytes = Buffer.from(`${JSON.stringify(manifest)}\n`); + const signature = sign(null, bytes, privateKey).toString("base64"); + expect(() => verifyWindowsUpdateMetadata({ + manifestBytes: bytes, + signatureBytes: Buffer.from(JSON.stringify({ + schemaVersion: 1, + keyId: WINDOWS_UPDATE_KEY_ID, + signature, + })), + expectedVersion: manifest.version, + updateInfo, + })).toThrow("signature is invalid"); + }); + + test("rejects a signed manifest that disagrees with latest.yml", () => { + const signed = signedFixture(); + expect(() => verifyWindowsUpdateMetadata({ + ...signed, + expectedVersion: manifest.version, + updateInfo: { + ...updateInfo, + files: [{ ...updateInfo.files[0], size: manifest.file.size + 1 }], + }, + })).toThrow("does not match the signed manifest"); + }); + + test("rejects update metadata that could select an extra installer", () => { + const signed = signedFixture(); + expect(() => verifyWindowsUpdateMetadata({ + ...signed, + expectedVersion: manifest.version, + updateInfo: { + ...updateInfo, + files: [ + { url: "unexpected.exe", size: 1, sha512: manifest.file.sha512 }, + ...updateInfo.files, + ], + }, + })).toThrow("exactly one trusted installer"); + }); + + test("checks the downloaded installer size and both digests", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-update-test-")); + const path = join(directory, manifest.file.name); + try { + await writeFile(path, "payload"); + await expect(verifyDownloadedWindowsUpdate({ path, manifest })).resolves.toBe(true); + await writeFile(path, "tampered"); + await expect(verifyDownloadedWindowsUpdate({ path, manifest })).rejects.toThrow("size does not match"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/site/src/components/hero.astro b/apps/site/src/components/hero.astro index 82a26d09..a462c163 100644 --- a/apps/site/src/components/hero.astro +++ b/apps/site/src/components/hero.astro @@ -91,6 +91,16 @@ const copy = siteCopy[locale].hero; macOS + + + {copy.windowsPreview} + {locale === "en-US" ? "Clipper" : "剪藏插件"} +

+ + {copy.windowsPreviewWarning} +

diff --git a/apps/site/src/i18n.ts b/apps/site/src/i18n.ts index 054ce84e..512ee10b 100644 --- a/apps/site/src/i18n.ts +++ b/apps/site/src/i18n.ts @@ -28,7 +28,7 @@ export const siteCopy = { "zh-CN": { layout: { defaultDescription: - "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区。保留经典印象笔记三栏体验,覆盖 macOS、iOS、Android 与浏览器剪藏,支持在 Cloudflare 免费额度内运行或使用 Docker 一键自托管。", + "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区。保留经典印象笔记三栏体验,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器剪藏,支持在 Cloudflare 免费额度内运行或使用 Docker 一键自托管。", defaultTitle: `EdgeEver - ${siteTaglines["zh-CN"]}`, imageAlt: "EdgeEver 笔记应用截图", ogLocale: "zh_CN", @@ -60,8 +60,10 @@ export const siteCopy = { popHighlight: "印象笔记开源经典平替 · 全平台客户端覆盖", demo: "在线演示", agentInstall: "一键 AI 部署", + windowsPreview: "Windows 预览版", + windowsPreviewWarning: "Windows x64 为未签名预览版,系统或组织策略可能警告或阻止安装。请仅从官方 GitHub Release 下载。", imageAlt: "EdgeEver product preview", - badgeText: "💡 全平台覆盖:macOS、iOS、Android、浏览器剪藏 · Cloudflare 免费额度 & Docker 一键安装", + badgeText: "💡 全平台覆盖:macOS、Windows 预览版、iOS、Android、浏览器剪藏 · Cloudflare 免费额度 & Docker 一键安装", }, bento: { eyebrow: "WHY EDGEEVER", @@ -116,7 +118,7 @@ export const siteCopy = { card5: { badge: "全端主权", title: "全平台客户端与无损 ZIP 归档", - desc: "覆盖 macOS(Electron+Rust+SQLite)、原生 iOS(SwiftUI)、Android 及剪藏插件;基于标准 SQLite 与无损 ZIP 导出,数据完全自主掌控。", + desc: "覆盖 macOS、Windows x64 预览版、原生 iOS(SwiftUI)、Android 及剪藏插件;基于标准 SQLite 与无损 ZIP 导出,数据完全自主掌控。", archiveTitle: "edgeever-backup.zip", archiveSub: "包含 Markdown、Front Matter、附件与历史版本", }, @@ -240,10 +242,11 @@ export const siteCopy = { }, { title: "全平台客户端与多端同步", - summary: "覆盖 macOS、iOS、Android、浏览器剪藏插件与 Web/PWA,摆脱商业软件的设备数限制。", + summary: "覆盖 macOS、Windows x64 预览版、iOS、Android、浏览器剪藏插件与 Web/PWA,摆脱商业软件的设备数限制。", points: [ "不限设备登录数:自建专属 API,彻底打碎商业笔记“免费版仅限 2 台设备”的枷锁。", "macOS 桌面端:基于 Electron + Rust sidecar + SQLite,兼顾极致本地性能与离线编辑同步。", + "Windows x64 预览版:提供本地 SQLite 与自动更新;当前未使用 Authenticode 签名,安装时可能出现系统或组织策略警告。", "移动原生端:iOS 原生 SwiftUI App(已上架 App Store)与 Android App(Google Play / APK),支持微信文章剪藏。", "浏览器剪藏插件:Chrome、Microsoft Edge 与 Firefox 商店全上架,一键捕获网页全文与书签。", ], @@ -289,7 +292,7 @@ export const siteCopy = { "en-US": { layout: { defaultDescription: - "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative. Enjoy a classic three-pane workspace across macOS, iOS, Android, and Web Clipper, running within Cloudflare's free tier or on Docker.", + "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative. Enjoy a classic three-pane workspace across macOS, Windows Preview, iOS, Android, and Web Clipper, running within Cloudflare's free tier or on Docker.", defaultTitle: "Open-Source, Self-Hosted Evernote Alternative | EdgeEver", imageAlt: "EdgeEver notes app screenshot", ogLocale: "en_US", @@ -321,8 +324,10 @@ export const siteCopy = { popHighlight: "Open-Source Evernote Alternative · Apps Across Platforms", demo: "Live demo", agentInstall: "Deploy with AI", + windowsPreview: "Windows Preview", + windowsPreviewWarning: "Windows x64 is an unsigned Preview. Windows or organization policy may warn or block it; download only from the official GitHub Release.", imageAlt: "EdgeEver product preview", - badgeText: "💡 All Platforms: macOS, iOS, Android, Web Clipper · Cloudflare Free Tier & 1-Line Docker Deploy", + badgeText: "💡 All Platforms: macOS, Windows Preview, iOS, Android, Web Clipper · Cloudflare Free Tier & 1-Line Docker Deploy", }, bento: { eyebrow: "WHY EDGEEVER", @@ -377,7 +382,7 @@ export const siteCopy = { card5: { badge: "Data Sovereignty", title: "Apps Across Platforms & Lossless ZIP Backup", - desc: "Available on macOS (Electron+Rust+SQLite), native iOS (SwiftUI), Android (Google Play/APK), and Web Clipper. Lossless ZIP export and standard SQLite provide full ownership.", + desc: "Available on macOS, Windows x64 Preview, native iOS (SwiftUI), Android (Google Play/APK), and Web Clipper. Lossless ZIP export and standard SQLite provide full ownership.", archiveTitle: "edgeever-backup.zip", archiveSub: "Markdown + Attachments + Revision History", }, @@ -501,10 +506,11 @@ export const siteCopy = { }, { title: "Apps Across Platforms & Multi-Device Sync", - summary: "Available across macOS, iOS, Android, browser Web Clipper, and Web/PWA, freeing you from commercial device caps.", + summary: "Available across macOS, Windows x64 Preview, iOS, Android, browser Web Clipper, and Web/PWA, freeing you from commercial device caps.", points: [ "Unlimited Devices: Self-hosted API eliminates commercial restrictions like the '2-device login limit'.", "macOS Desktop App: Electron + Rust sidecar + SQLite for blazing fast local performance and offline sync.", + "Windows x64 Preview: Local SQLite and automatic updates; the current build is not Authenticode-signed, so Windows or organization policy may warn or block installation.", "Mobile Native Apps: Native SwiftUI iOS App (on App Store) and Android App (on Google Play & GitHub APK).", "Web Clipper Extension: Official extensions on Chrome Web Store, Microsoft Edge, and Firefox Add-ons.", ], diff --git a/apps/site/src/pages/en/index.astro b/apps/site/src/pages/en/index.astro index be668b7f..1b545820 100644 --- a/apps/site/src/pages/en/index.astro +++ b/apps/site/src/pages/en/index.astro @@ -18,7 +18,7 @@ const homeJsonLd = [ url: siteUrl, inLanguage: "en-US", description: - "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative across macOS, iOS, Android, Web Clipper with Cloudflare & Docker deployment.", + "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative across macOS, Windows Preview, iOS, Android, and Web Clipper with Cloudflare & Docker deployment.", keywords: ["Evernote alternative", "open-source notes app", "self-hosted notes app", "AI notes app"], }, { @@ -29,10 +29,10 @@ const homeJsonLd = [ url: siteUrl, codeRepository: "https://github.com/tianma-if/edgeever", applicationCategory: "ProductivityApplication", - operatingSystem: "macOS, iOS, Android, Web, Chrome, Edge, Firefox", + operatingSystem: "macOS, Windows, iOS, Android, Web, Chrome, Edge, Firefox", isAccessibleForFree: true, description: - "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative with a classic three-pane workflow, apps across macOS, iOS, Android and browsers, plus a REST API, OpenAPI schema, and MCP endpoint.", + "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative with a classic three-pane workflow, apps across macOS, Windows Preview, iOS, Android and browsers, plus a REST API, OpenAPI schema, and MCP endpoint.", keywords: ["Evernote alternative", "open-source notes app", "self-hosted notes app", "AI notes app"], }, ]; diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 58617c94..1a69fa8e 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -17,7 +17,7 @@ const homeJsonLd = [ url: siteUrl, inLanguage: "zh-CN", description: - "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,支持 Cloudflare 与 Docker 自由部署,覆盖 macOS、iOS、Android 与浏览器插件。", + "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,支持 Cloudflare 与 Docker 自由部署,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器插件。", }, { "@context": "https://schema.org", @@ -26,10 +26,10 @@ const homeJsonLd = [ url: siteUrl, codeRepository: "https://github.com/tianma-if/edgeever", applicationCategory: "ProductivityApplication", - operatingSystem: "macOS, iOS, Android, Web, Chrome, Edge, Firefox", + operatingSystem: "macOS, Windows, iOS, Android, Web, Chrome, Edge, Firefox", isAccessibleForFree: true, description: - "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,保留经典印象笔记三栏体验,覆盖 macOS、iOS、Android 与浏览器插件,并提供 REST API、OpenAPI schema 和 MCP endpoint。", + "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,保留经典印象笔记三栏体验,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器插件,并提供 REST API、OpenAPI schema 和 MCP endpoint。", }, ]; --- diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index a925627d..fd9e6924 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -727,19 +727,27 @@ export const NotebookPane = ({ - -
- - - - {t("pwa.sidebarWindows") || "Windows"} -
- - {t("pwa.sidebarWindowsBadge") || "即将推出"} - + + +
+ + + + {t("pwa.sidebarWindows") || "Windows"} +
+
+ + {t("pwa.sidebarWindowsBadge") || "Preview"} + + +
+
)} diff --git a/docs/release.md b/docs/release.md index 616e6c32..26a4a0d4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -4,6 +4,13 @@ Run from a clean `main` branch on macOS that matches `origin/main`: +For any Release that rebuilds desktop assets, first point the release shell at +the repository-external Ed25519 key used for the Windows update manifest: + +```bash +export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem +``` + ```bash bun run release -- \ --bump patch \ @@ -47,8 +54,9 @@ previous installation check is actually needed. change only when that native runtime is rebuilt. Android `versionCode` and iOS build numbers remain independent, monotonically increasing identifiers. - A formal Release contains macOS arm64 and x64 DMGs, architecture-specific - updater ZIPs, and an Android arm64 APK. Unchanged native assets are reused - with their original filenames, versions, and checksums. + updater ZIPs, an unsigned Windows x64 Preview installer with an independently + signed update manifest, and an Android arm64 APK. Unchanged native assets are + reused with their original filenames, versions, and checksums. - Desktop and Android update checks use the version embedded in the applicable Release asset rather than the overall GitHub tag. This prevents a Web-only or API-only Release from prompting an unnecessary native update. @@ -69,6 +77,11 @@ previous installation check is actually needed. `bun run publish:stores -- --release vX.Y.Z --platform android --android-track production` for that Draft, then rerun the original release command to resume. See [Mobile Store Delivery](store-delivery.md). +- After rebuilt desktop assets are uploaded to the Draft, the local release + command signs only `latest-windows.json`; the private key never enters GitHub + Actions. A second desktop workflow run downloads the Windows installer, + `latest.yml`, manifest, signature, and checksum file and independently audits + them before publication. See [Windows Preview security and updates](windows-preview.md). ## Registry Credentials @@ -84,7 +97,8 @@ Independent builds are not required to have the same registry digest. ## Failure and Resume -- Validation, Draft asset, or GHCR image failures leave the Release unpublished. +- Validation, Windows signature/audit, Draft asset, or GHCR image failures leave + the Release unpublished. - An asynchronous CNB/TCR build failure leaves the formal Release intact and is repaired or rerun independently. - Rerunning the same command resumes a matching Draft created by an interrupted diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index 87a2493b..81152afc 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -4,6 +4,13 @@ 在 macOS 上从与 `origin/main` 一致且工作区干净的 `main` 分支执行: +任何需要重建桌面资产的 Release,都应先让发布 shell 指向仓库外保存的 Windows +更新清单 Ed25519 私钥: + +```bash +export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem +``` + ```bash bun run release -- \ --bump patch \ @@ -41,8 +48,9 @@ bun run release -- \ 禁止因发版节奏把用户可感知的新能力或新平台压成 patch(详见 `AGENTS.md`)。 - 根版本表示整体产品 Release。只有对应原生运行时重建时,才更新原生展示版本。 Android `versionCode` 和 iOS Build Number 是相互独立且严格递增的标识。 -- 每个正式 Release 包含 macOS arm64 与 x64 DMG、按架构区分的更新 ZIP,以及 - Android arm64 APK。未变化的原生资产沿用原文件名、版本和校验和。 +- 每个正式 Release 包含 macOS arm64 与 x64 DMG、按架构区分的更新 ZIP、带独立 + 签名更新清单的未签名 Windows x64 预览版安装包,以及 Android arm64 APK。 + 未变化的原生资产沿用原文件名、版本和校验和。 - 桌面端和 Android 更新检查使用对应 Release 资产中记录的版本,而不是整体 GitHub Tag,避免仅涉及 Web 或 API 的 Release 触发无效原生更新。 - 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、准备并审计 @@ -58,6 +66,10 @@ bun run release -- \ `bun run publish:stores -- --release vX.Y.Z --platform android --android-track production`, 再重新执行原发布命令续跑。详见 [移动端商店交付](store-delivery.zh-CN.md)。 +- 重建后的桌面资产上传到 Draft 后,本地发布命令只签署 + `latest-windows.json`,私钥不会进入 GitHub Actions。第二次桌面工作流会重新 + 下载 Windows 安装包、`latest.yml`、清单、签名和校验和文件并独立审计,通过后 + 才能公开发布。详见 [Windows 预览版安全与更新说明](windows-preview.zh-CN.md)。 ## 镜像仓库凭据 @@ -70,7 +82,8 @@ GitHub 官方仓库必须配置 `CNB_TCR_BUILD_PUSH_TOKEN` Actions Secret,该 ## 失败与续跑 -- 本地验证、Draft 资产或 GHCR 镜像失败时,Release 保持未发布状态。 +- 本地验证、Windows 签名/审计、Draft 资产或 GHCR 镜像失败时,Release 保持 + 未发布状态。 - CNB/TCR 异步构建失败时保留正式 Release,并独立修复、重跑。 - 中断后重新执行相同命令,会续跑匹配的 Draft,不会重复创建 Issue、提交或 Release。 diff --git a/docs/windows-preview.md b/docs/windows-preview.md new file mode 100644 index 00000000..0c2dfac9 --- /dev/null +++ b/docs/windows-preview.md @@ -0,0 +1,96 @@ +# Windows x64 Preview + +## Distribution status + +EdgeEver distributes a Windows x64 Preview from the official +[GitHub Releases](https://github.com/tianma-if/edgeever/releases/latest) page. +The current installer and packaged executables are not Authenticode-signed. +Windows SmartScreen, antivirus software, or organization policy may therefore +warn about or block the installer. This warning is expected for the Preview, +but it is not proof that an arbitrary copy is safe. + +- Download only from the official `tianma-if/edgeever` Release. +- Do not disable SmartScreen, antivirus software, or organization security + controls for EdgeEver. +- If policy blocks the installer, use the Web/PWA client until an + Authenticode-signed build is available. + +## Automatic updates + +An unsigned installer does not prevent the NSIS updater from downloading and +installing a later Release. EdgeEver adds an independent trust gate so that the +update channel does not rely only on an unsigned `latest.yml` file: + +1. The client reads `latest.yml` to discover an update but does not begin the + Windows download yet. +2. It fetches `latest-windows.json` and `latest-windows.json.sig` from that + exact version's official Release. +3. It verifies the Ed25519 signature with a public key pinned in the packaged + client, then requires the version, filename, size, and SHA-512 digest to + match `latest.yml`. +4. It downloads the installer automatically and validates its size, SHA-512, + and SHA-256 against the signed manifest. +5. Only a verified installer becomes eligible for restart installation or + automatic installation when the user quits EdgeEver. + +Missing metadata, an unknown key, an invalid signature, a version mismatch, or +a changed installer fails closed. The app never enables install-on-quit for a +Windows package that has not passed the final local-file check. + +This protects the update decision and installer bytes. It does not remove the +initial Windows reputation warning, provide publisher identity in Explorer, +or bypass organization application-control policy; those require trusted +Authenticode signing. + +## Release assets and offline signing + +Every formal Release carries this Windows set: + +- `EdgeEver--windows-x64.exe` +- `latest.yml` +- `latest-windows.json` +- `latest-windows.json.sig` +- `SHA256SUMS-windows.txt` + +GitHub Actions builds and verifies the unsigned package, then uploads the first +four unsigned inputs except the signature to the Draft Release. The release +command signs the exact manifest locally using the repository-external key +identified by `EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY`. It then dispatches an +independent GitHub Actions audit that downloads all five assets and verifies +the signature plus installer digests before publication is allowed. + +The private key must be an Ed25519 PKCS#8 PEM file, must remain outside the +repository, and must be backed up in a separate secure location. Configure the +release shell with an absolute path: + +```bash +export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem +``` + +The initial trust anchor uses key ID `edgeever-windows-update-2026-01`; its +SPKI DER SHA-256 fingerprint is +`ec12b4b5673a2e6ac3666d0cc90dd5c418f3650418cd2b91fa09cec969d50db9`. + +If the key is missing or does not match the public key pinned in the desktop +client, the Release remains a Draft. Key rotation is a two-release process: +first ship a client that trusts both old and new public keys while continuing +to sign with the old key, then switch manifest signing to the new key in a +later Release. + +## Future Authenticode migration + +When a trusted certificate becomes available: + +1. Sign every shipped PE executable, including `EdgeEver.exe`, the Rust + sidecar, helper executables, and the final NSIS installer. +2. Configure the exact certificate subject as electron-builder's + `publisherName` so signed clients enforce publisher continuity. +3. Generate `latest.yml`, the signed EdgeEver manifest, and checksums only + after all Authenticode signing has finished, because signing changes bytes. +4. Keep the independent Ed25519 gate during and after migration. Existing + unsigned Preview clients can accept the first signed installer because they + do not claim an Authenticode publisher; newly signed clients then add the + publisher check for subsequent updates. + +The first public Windows platform Release is a user-visible platform addition +and must use a SemVer minor bump, not a patch bump. diff --git a/docs/windows-preview.zh-CN.md b/docs/windows-preview.zh-CN.md new file mode 100644 index 00000000..0e9afa08 --- /dev/null +++ b/docs/windows-preview.zh-CN.md @@ -0,0 +1,80 @@ +# Windows x64 预览版 + +## 分发状态 + +EdgeEver 通过官方 [GitHub Releases](https://github.com/tianma-if/edgeever/releases/latest) +页面分发 Windows x64 预览版。当前安装包与应用内可执行文件尚未使用 +Authenticode 签名,因此 Windows SmartScreen、杀毒软件或组织策略可能提示风险或 +直接阻止安装。这类提示是预览阶段的预期现象,但并不表示任意来源的副本都安全。 + +- 仅从 `tianma-if/edgeever` 官方 Release 下载。 +- 不要为 EdgeEver 关闭 SmartScreen、杀毒软件或组织安全控制。 +- 如果组织策略阻止安装,请先使用 Web/PWA 客户端,等待后续 + Authenticode 签名版本。 + +## 自动更新 + +安装包未签名并不会阻止 NSIS 更新器下载和安装后续 Release。EdgeEver 增加了独立 +信任门禁,避免更新通道只依赖同样未签名的 `latest.yml`: + +1. 客户端读取 `latest.yml` 发现新版本,但此时不会开始 Windows 下载。 +2. 客户端从该版本的官方 Release 获取 `latest-windows.json` 与 + `latest-windows.json.sig`。 +3. 客户端使用安装包内固定的公钥验证 Ed25519 签名,并要求版本、文件名、大小与 + SHA-512 和 `latest.yml` 完全一致。 +4. 客户端自动下载安装包,再用已签名清单核验其大小、SHA-512 与 SHA-256。 +5. 只有通过本地文件复核的安装包,才能在用户确认重启时安装,或在用户退出 + EdgeEver 时自动安装。 + +清单缺失、密钥未知、签名无效、版本不一致或安装包被修改时,更新一律停止。 +Windows 安装包未通过最终本地校验前,应用不会开启退出时自动安装。 + +该机制保护更新决策和安装包字节,但不会消除首次安装时的 Windows 信誉提示、 +不会在资源管理器中提供发布者身份,也无法绕过组织的应用控制策略;这些能力仍需 +可信 Authenticode 签名。 + +## Release 资产与离线签名 + +每个正式 Release 包含以下 Windows 资产: + +- `EdgeEver--windows-x64.exe` +- `latest.yml` +- `latest-windows.json` +- `latest-windows.json.sig` +- `SHA256SUMS-windows.txt` + +GitHub Actions 构建并验证未签名安装包,随后把除签名文件以外的四项输入上传到 +Draft Release。发布命令使用 `EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY` 指向的 +仓库外密钥,在本机为精确清单离线签名;然后触发独立 GitHub Actions 审计,重新 +下载全部五项资产并验证签名及安装包摘要,全部通过后才允许公开发布。 + +私钥必须是 Ed25519 PKCS#8 PEM 文件,必须保存在仓库外,并在另一个安全位置留有 +备份。发布 shell 使用绝对路径配置: + +```bash +export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem +``` + +首个信任锚的密钥 ID 为 `edgeever-windows-update-2026-01`,SPKI DER SHA-256 +指纹为 `ec12b4b5673a2e6ac3666d0cc90dd5c418f3650418cd2b91fa09cec969d50db9`。 + +私钥缺失或与桌面客户端固定的公钥不匹配时,Release 会保持 Draft。密钥轮换必须 +跨两个 Release:先发布同时信任新旧公钥、但仍由旧密钥签名的客户端;后续 Release +再切换到新密钥签名。 + +## 未来迁移到 Authenticode + +获得可信证书后: + +1. 签署所有随包发布的 PE,包括 `EdgeEver.exe`、Rust sidecar、辅助可执行文件和 + 最终 NSIS 安装包。 +2. 把证书精确主题配置为 electron-builder 的 `publisherName`,让已签名客户端 + 强制校验发布者连续性。 +3. 必须在全部 Authenticode 签名完成后再生成 `latest.yml`、EdgeEver 签名清单与 + 校验和,因为签名会改变文件字节。 +4. 迁移期间及迁移后继续保留独立 Ed25519 门禁。现有未签名预览版没有声明 + Authenticode 发布者,因此可以接收首个已签名安装包;新签名客户端随后会对后续 + 更新增加发布者校验。 + +首次公开 Windows 平台属于用户可感知的新平台,必须按 SemVer 使用 minor 递增, +不能作为 patch 发布。 diff --git a/package.json b/package.json index 29f993de..7d1320ba 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,11 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", - "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs", "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", "build:worker": "bun scripts/build-cloudflare-worker.mjs", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index c7cf3281..d50e9938 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1688,8 +1688,8 @@ export const enUS = { sidebarGroupClippers: "Browser Web Clipper", sidebarMac: "macOS", sidebarWindows: "Windows", - sidebarWindowsBadge: "Coming soon", - sidebarWindowsAvailability: "Coming soon — stay tuned", + sidebarWindowsBadge: "Preview", + sidebarWindowsAvailability: "Unsigned Preview — Windows or your organization may warn or block it. Download only from the official GitHub Release.", sidebarOtherDownloads: "Other client downloads", sidebarAndroid: "Android", sidebarAndroidGooglePlay: "Google Play", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 43bcbe11..e755a357 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1686,8 +1686,8 @@ export const zhCN = { sidebarGroupClippers: "浏览器剪藏插件", sidebarMac: "macOS", sidebarWindows: "Windows", - sidebarWindowsBadge: "即将推出", - sidebarWindowsAvailability: "即将推出,敬请期待", + sidebarWindowsBadge: "预览版", + sidebarWindowsAvailability: "未签名预览版——Windows 或组织策略可能警告或阻止安装。请仅从官方 GitHub Release 下载。", sidebarOtherDownloads: "其他客户端下载", sidebarAndroid: "Android", sidebarAndroidGooglePlay: "Google Play", diff --git a/scripts/check-native-release-assets.mjs b/scripts/check-native-release-assets.mjs index 5b940346..385e0d10 100644 --- a/scripts/check-native-release-assets.mjs +++ b/scripts/check-native-release-assets.mjs @@ -8,6 +8,7 @@ export const nativeReleaseAssetsReady = ({ currentTag, desktopVersion, assetNames, + requireWindowsSignature = true, }) => { if (platform === "mobile") { const apkNames = assetNames.filter((name) => @@ -47,6 +48,29 @@ export const nativeReleaseAssetsReady = ({ } } } + const windowsInstallerNames = assetNames.filter((name) => + /^EdgeEver-(.+)-windows-x64\.exe$/.test(name) + ); + if (windowsInstallerNames.length !== 1) return false; + const windowsVersion = /^EdgeEver-(.+)-windows-x64\.exe$/ + .exec(windowsInstallerNames[0])?.[1]; + if (!windowsVersion) return false; + versions.add(windowsVersion); + for (const name of [ + "latest.yml", + "latest-windows.json", + "SHA256SUMS-windows.txt", + ]) { + if (assetNames.filter((assetName) => assetName === name).length !== 1) { + return false; + } + } + const signatureCount = assetNames.filter( + (name) => name === "latest-windows.json.sig", + ).length; + if (requireWindowsSignature ? signatureCount !== 1 : signatureCount > 1) { + return false; + } return ( versions.size === 1 && (!rebuild || versions.has(desktopVersion)) && @@ -58,15 +82,16 @@ export const nativeReleaseAssetsReady = ({ }; const run = () => { - const [platform, rebuildValue, currentTag, desktopVersion = ""] = + const [platform, rebuildValue, currentTag, desktopVersion = "", signatureMode = "required"] = process.argv.slice(2); if ( !["mobile", "desktop"].includes(platform) || !["true", "false"].includes(rebuildValue) || - !currentTag + !currentTag || + !["required", "allow-missing-windows-signature"].includes(signatureMode) ) { console.error( - "Usage: node scripts/check-native-release-assets.mjs [desktop-version]", + "Usage: node scripts/check-native-release-assets.mjs [desktop-version] [required|allow-missing-windows-signature]", ); process.exit(1); } @@ -80,6 +105,7 @@ const run = () => { currentTag, desktopVersion, assetNames, + requireWindowsSignature: signatureMode === "required", }), ), ); diff --git a/scripts/check-native-release-assets.test.mjs b/scripts/check-native-release-assets.test.mjs index 5257f9e3..2d065ed9 100644 --- a/scripts/check-native-release-assets.test.mjs +++ b/scripts/check-native-release-assets.test.mjs @@ -2,6 +2,14 @@ import { describe, expect, test } from "bun:test"; import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs"; describe("native release asset readiness", () => { + const windowsAssets = (version) => [ + `EdgeEver-${version}-windows-x64.exe`, + "latest.yml", + "latest-windows.json", + "latest-windows.json.sig", + "SHA256SUMS-windows.txt", + ]; + test("accepts one reused Android APK but requires the current name after a rebuild", () => { const reused = ["edgeever-android-v1.6.30-arm64-v8a.apk"]; expect( @@ -53,6 +61,7 @@ describe("native release asset readiness", () => { "EdgeEver-1.6.33-mac-x64.zip", "EdgeEver-1.6.33-mac-x64.zip.blockmap", "latest-mac.yml", + ...windowsAssets("1.6.33"), ], }), ).toBe(true); @@ -69,6 +78,7 @@ describe("native release asset readiness", () => { "EdgeEver-1.6.35-mac-x64.zip", "EdgeEver-1.6.35-mac-x64.zip.blockmap", "latest-mac.yml", + ...windowsAssets("1.6.35"), ]; expect( nativeReleaseAssetsReady({ @@ -103,8 +113,39 @@ describe("native release asset readiness", () => { "EdgeEver-1.6.33-mac-arm64.zip", "EdgeEver-1.6.33-mac-arm64.zip.blockmap", "latest-mac.yml", + ...windowsAssets("1.6.33"), ], }), ).toBe(false); }); + + test("allows the CI staging audit to omit only the offline signature", () => { + const assets = [ + "EdgeEver-1.6.35-mac-arm64.dmg", + "EdgeEver-1.6.35-mac-arm64.dmg.blockmap", + "EdgeEver-1.6.35-mac-arm64.zip", + "EdgeEver-1.6.35-mac-arm64.zip.blockmap", + "EdgeEver-1.6.35-mac-x64.dmg", + "EdgeEver-1.6.35-mac-x64.dmg.blockmap", + "EdgeEver-1.6.35-mac-x64.zip", + "EdgeEver-1.6.35-mac-x64.zip.blockmap", + "latest-mac.yml", + ...windowsAssets("1.6.35").filter((name) => name !== "latest-windows.json.sig"), + ]; + expect(nativeReleaseAssetsReady({ + platform: "desktop", + rebuild: true, + currentTag: "v1.6.35", + desktopVersion: "1.6.35", + assetNames: assets, + requireWindowsSignature: false, + })).toBe(true); + expect(nativeReleaseAssetsReady({ + platform: "desktop", + rebuild: true, + currentTag: "v1.6.35", + desktopVersion: "1.6.35", + assetNames: assets, + })).toBe(false); + }); }); diff --git a/scripts/create-windows-update-metadata.mjs b/scripts/create-windows-update-metadata.mjs new file mode 100644 index 00000000..60a42610 --- /dev/null +++ b/scripts/create-windows-update-metadata.mjs @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { createReadStream, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + WINDOWS_UPDATE_KEY_ID, + WINDOWS_UPDATE_MANIFEST_NAME, +} from "../apps/desktop/src/main/windows-update-trust.mjs"; + +const STABLE_VERSION = /^\d+\.\d+\.\d+$/; + +const hashFile = (path, algorithm, encoding) => new Promise((resolveHash, rejectHash) => { + const hash = createHash(algorithm); + const stream = createReadStream(path); + stream.on("error", rejectHash); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolveHash(hash.digest(encoding))); +}); + +export const createWindowsUpdateMetadata = async ({ directory, version }) => { + if (!STABLE_VERSION.test(version)) { + throw new Error(`Windows update version must be stable X.Y.Z: ${version}`); + } + const installerName = `EdgeEver-${version}-windows-x64.exe`; + const installerPath = join(directory, installerName); + const stats = statSync(installerPath); + if (!stats.isFile() || stats.size <= 0) { + throw new Error(`Windows installer is missing or empty: ${installerPath}`); + } + const [sha512, sha256] = await Promise.all([ + hashFile(installerPath, "sha512", "base64"), + hashFile(installerPath, "sha256", "hex"), + ]); + const releaseDate = new Date().toISOString(); + const manifest = { + schemaVersion: 1, + keyId: WINDOWS_UPDATE_KEY_ID, + version, + platform: "win32", + arch: "x64", + releaseDate, + file: { + name: installerName, + size: stats.size, + sha512, + sha256, + }, + }; + writeFileSync( + join(directory, WINDOWS_UPDATE_MANIFEST_NAME), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + writeFileSync(join(directory, "latest.yml"), [ + `version: ${version}`, + "files:", + ` - url: ${installerName}`, + ` sha512: ${sha512}`, + ` size: ${stats.size}`, + `path: ${installerName}`, + `sha512: ${sha512}`, + `releaseDate: '${releaseDate}'`, + "", + ].join("\n")); + writeFileSync( + join(directory, "SHA256SUMS-windows.txt"), + `${sha256} ${installerName}\n`, + ); + return manifest; +}; + +const run = async () => { + const [directoryValue, version] = process.argv.slice(2); + if (!directoryValue || !version) { + throw new Error("Usage: node scripts/create-windows-update-metadata.mjs "); + } + const directory = resolve(directoryValue); + const packageVersion = JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version; + if (version !== packageVersion) { + throw new Error(`Requested version ${version} does not match desktop package version ${packageVersion}`); + } + const manifest = await createWindowsUpdateMetadata({ directory, version }); + process.stdout.write(`${basename(directory)}: ${manifest.file.name}\n`); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await run(); +} diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs index 2b3a3d67..497318d3 100644 --- a/scripts/desktop-build-workflow.test.mjs +++ b/scripts/desktop-build-workflow.test.mjs @@ -61,7 +61,7 @@ describe("desktop release workflow", () => { }); test("reports timings after builds without instrumenting native build steps", () => { - expect(workflow).toContain("name: Report macOS build timings"); + expect(workflow).toContain("name: Report desktop build timings"); expect(workflow).toContain("--platform desktop"); expect(workflow).toContain("name: edgeever-desktop-build-timings"); expect(mobileWorkflow).toContain("name: Report Android build timings"); @@ -69,4 +69,14 @@ describe("desktop release workflow", () => { expect(mobileWorkflow).toContain("name: edgeever-android-build-timings"); expect(mobileWorkflow).not.toContain("Build signed release APK for GitHub Release\n run: time"); }); + + test("builds an unsigned Windows x64 Preview and audits its signed update metadata", () => { + expect(workflow).toContain("name: Windows x64 unsigned Preview"); + expect(workflow).toContain("EDGE_EVER_DESKTOP_TARGET: win"); + expect(workflow).toContain("Get-AuthenticodeSignature"); + expect(workflow).toContain("create-windows-update-metadata.mjs"); + expect(workflow).toContain("allow-missing-windows-signature"); + expect(workflow).toContain("name: Audit signed Windows update"); + expect(workflow).toContain("verify-windows-update-release.mjs"); + }); }); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 61fd3b63..0f6f14aa 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -51,9 +51,12 @@ export const planNativeRelease = (platform, changedFiles) => { ".github/workflows/desktop-build.yml", "bun.lock", "scripts/create-mac-update-metadata.mjs", + "scripts/create-windows-update-metadata.mjs", "scripts/desktop-icns.mjs", "scripts/prepare-desktop-icons.mjs", "scripts/run-desktop-builder.mjs", + "scripts/sign-windows-update-manifest.mjs", + "scripts/verify-windows-update-release.mjs", "scripts/verify-desktop-package.mjs", ]); diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs index d273bc79..09b875f7 100644 --- a/scripts/plan-native-release.test.mjs +++ b/scripts/plan-native-release.test.mjs @@ -71,9 +71,12 @@ describe("native release planning", () => { const changedFiles = [ ".github/workflows/desktop-build.yml", "scripts/create-mac-update-metadata.mjs", + "scripts/create-windows-update-metadata.mjs", "scripts/prepare-desktop-icons.mjs", "scripts/desktop-icns.mjs", "scripts/run-desktop-builder.mjs", + "scripts/sign-windows-update-manifest.mjs", + "scripts/verify-windows-update-release.mjs", ]; expect(planNativeRelease("desktop", changedFiles)).toEqual({ rebuild: true, diff --git a/scripts/release.mjs b/scripts/release.mjs index 925833a0..444bf74c 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -16,6 +16,10 @@ import { join, resolve, sep } from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs"; import { planNativeRelease } from "./plan-native-release.mjs"; +import { + assertWindowsUpdateSigningKey, + signWindowsUpdateManifest, +} from "./sign-windows-update-manifest.mjs"; const DEFAULT_REPOSITORY = "tianma-if/edgeever"; const VERSION_BUMPS = new Set(["patch", "minor", "major"]); @@ -42,6 +46,7 @@ export const RELEASE_VALIDATIONS = [ "test", "scripts/plan-native-release.test.mjs", "scripts/check-native-release-assets.test.mjs", + "scripts/windows-update-metadata.test.mjs", "scripts/release.test.mjs", "scripts/validate-store-delivery.test.mjs", "scripts/store-delivery.test.mjs", @@ -360,7 +365,7 @@ export const buildIssueBody = ({ changesEn, changesZh, commitCoverageAudit }) => "## Acceptance criteria", "", "- Required type checks, Web build, and native release planning tests pass.", - "- The Draft Release contains audited macOS arm64 and x64 DMGs and a Play-signed Android arm64 APK.", + "- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, and a Play-signed Android arm64 APK.", "- Post-publication native asset audits pass.", ].join("\n"); @@ -1010,10 +1015,17 @@ const assertDraftAssets = ({ .map((asset) => asset.name) .filter((name) => /^EdgeEver-.*-mac-(?:arm64|x64)\.(?:dmg|zip)(?:\.blockmap)?$/.test(name) || - name === "latest-mac.yml" + /^EdgeEver-.*-windows-x64\.exe$/.test(name) || + [ + "latest-mac.yml", + "latest.yml", + "latest-windows.json", + "latest-windows.json.sig", + "SHA256SUMS-windows.txt", + ].includes(name) ); if ( - previousDesktopNames.length !== 9 || + previousDesktopNames.length !== 14 || !previousDesktopNames.every((name) => reusedAssetMatches(previousAssets, assets, name) ) @@ -1023,6 +1035,44 @@ const assertDraftAssets = ({ } }; +const signDraftWindowsUpdate = ({ repository, tag }) => { + const temporaryDirectory = mkdtempSync(join(tmpdir(), "edgeever-windows-update-signing-")); + const manifestPath = join(temporaryDirectory, "latest-windows.json"); + const signaturePath = `${manifestPath}.sig`; + try { + run("gh", [ + "release", + "download", + tag, + "--repo", + repository, + "--pattern", + "latest-windows.json", + "--dir", + temporaryDirectory, + ]); + signWindowsUpdateManifest({ + manifestPath, + signaturePath, + privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY, + }); + run("gh", [ + "release", + "upload", + tag, + signaturePath, + "--repo", + repository, + "--clobber", + ]); + console.log(`[release] signed Windows update manifest for ${tag}`); + } finally { + if (temporaryDirectory.startsWith(`${tmpdir()}${sep}edgeever-windows-update-signing-`)) { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } + } +}; + const sha256File = (path) => new Promise((resolveHash, rejectHash) => { const hash = createHash("sha256"); const stream = createReadStream(path); @@ -1221,6 +1271,13 @@ const releaseMain = async (options) => { return; } + if (desktopPlan.rebuild) { + assertWindowsUpdateSigningKey({ + privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY, + }); + console.log("[release] Windows update signing key matches the pinned desktop trust anchor"); + } + let issueNumber; let releaseSha; if (resumedDraft) { @@ -1372,6 +1429,23 @@ const releaseMain = async (options) => { androidReleaseReady, ]); + if (desktopPlan.rebuild) { + signDraftWindowsUpdate({ repository: options.repository, tag }); + } + const windowsUpdateAuditRunId = await dispatchReleaseWorkflow({ + repository: options.repository, + workflow: RELEASE_WORKFLOWS.desktop, + tag, + headSha: releaseSha, + }); + checkpoint.windowsUpdateAuditRunId = windowsUpdateAuditRunId; + persistCheckpoint(); + await waitForRun({ + repository: options.repository, + runId: windowsUpdateAuditRunId, + label: "Draft signed Windows update audit", + }); + const draft = ghJson([ "release", "view", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 3a0aa4c8..e8cf3982 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -44,6 +44,25 @@ describe("release automation", () => { expect(publication).toBeGreaterThan(signatureGate); }); + test("blocks publication until the offline-signed Windows update passes an independent audit", () => { + const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8"); + const signing = releaseSource.indexOf("signDraftWindowsUpdate({"); + const audit = releaseSource.indexOf('label: "Draft signed Windows update audit"'); + const publication = releaseSource.indexOf('"--draft=false"'); + expect(signing).toBeGreaterThanOrEqual(0); + expect(audit).toBeGreaterThan(signing); + expect(publication).toBeGreaterThan(audit); + }); + + test("checks the offline Windows key before creating release state", () => { + const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8"); + const releaseMain = releaseSource.indexOf("const releaseMain = async"); + const keyCheck = releaseSource.indexOf("assertWindowsUpdateSigningKey({", releaseMain); + const issueCreation = releaseSource.indexOf('"issue",\n "create"', releaseMain); + expect(keyCheck).toBeGreaterThan(releaseMain); + expect(issueCreation).toBeGreaterThan(keyCheck); + }); + test("starts Play delivery as soon as Android preparation finishes", () => { const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8"); const androidReady = releaseSource.lastIndexOf("const androidReleaseReady"); @@ -337,6 +356,7 @@ describe("release automation", () => { expect(body).toContain("- 并行检查。"); expect(body).toContain("## Commit coverage audit"); expect(body).toContain("Play-signed Android arm64 APK"); + expect(body).toContain("unsigned Windows x64 Preview"); expect(body).toContain("- Change 1: `aaaaaaaa`"); expect(body).toContain("- Excluded `bbbbbbbb`: test-only coverage"); }); diff --git a/scripts/report-native-build-timings.mjs b/scripts/report-native-build-timings.mjs index d4858028..dd9a5d51 100644 --- a/scripts/report-native-build-timings.mjs +++ b/scripts/report-native-build-timings.mjs @@ -12,6 +12,11 @@ const TARGETS = { jobName: "macOS x64", packageSteps: ["Package desktop installer"], }, + { + name: "Windows x64 Preview", + jobName: "Windows x64 unsigned Preview", + packageSteps: ["Package unsigned Windows installer"], + }, ], android: [ { diff --git a/scripts/report-native-build-timings.test.mjs b/scripts/report-native-build-timings.test.mjs index 55893730..d796a6b9 100644 --- a/scripts/report-native-build-timings.test.mjs +++ b/scripts/report-native-build-timings.test.mjs @@ -22,7 +22,11 @@ function job(name, startedAt, completedAt, packageDurationSeconds) { completed_at: packageStart, }, { - name: name.startsWith("macOS") ? "Package desktop installer" : "Build signed release APK for GitHub Release", + name: name.startsWith("macOS") + ? "Package desktop installer" + : name.startsWith("Windows") + ? "Package unsigned Windows installer" + : "Build signed release APK for GitHub Release", conclusion: "success", started_at: packageStart, completed_at: packageEnd, @@ -38,18 +42,24 @@ describe("native build timing reports", () => { expect(formatDuration(123_000)).toBe("2m 03s"); }); - test("reports both desktop architectures from Actions timestamps", () => { + test("reports both macOS architectures and Windows x64 from Actions timestamps", () => { const report = createTimingReport({ jobs: [ job("macOS arm64", "2026-08-13T00:00:00Z", "2026-08-13T00:06:00Z", 180), job("macOS x64", "2026-08-13T00:00:00Z", "2026-08-13T00:09:00Z", 360), + job("Windows x64 unsigned Preview", "2026-08-13T00:00:00Z", "2026-08-13T00:07:00Z", 240), ], }, "desktop", { runId: "123" }); expect(report.runId).toBe("123"); - expect(report.targets.map((target) => target.name)).toEqual(["macOS arm64", "macOS x64"]); + expect(report.targets.map((target) => target.name)).toEqual([ + "macOS arm64", + "macOS x64", + "Windows x64 Preview", + ]); expect(report.targets[1].packageDurationMs).toBe(360_000); expect(renderTimingMarkdown(report)).toContain("| macOS x64 | success | 9m 00s | 1m 00s | 6m 00s | 2m 00s |"); + expect(renderTimingMarkdown(report)).toContain("| Windows x64 Preview | success | 7m 00s | 1m 00s | 4m 00s | 2m 00s |"); }); test("selects the release Android package step", () => { diff --git a/scripts/report-release-timings.mjs b/scripts/report-release-timings.mjs index 07e1df98..09d83379 100644 --- a/scripts/report-release-timings.mjs +++ b/scripts/report-release-timings.mjs @@ -43,28 +43,32 @@ function componentRow({ target, mode, candidate, duration, detail, url }) { function nativeRows(payload, platform, requestedMode) { if (platform === "desktop") { - const architectureJobs = ["macOS arm64", "macOS x64"] - .map((name) => findJob(payload, name)) - .filter(Boolean); + const architectureJobs = [ + { name: "macOS arm64", packageStep: "Package desktop installer", detail: "package + notarize" }, + { name: "macOS x64", packageStep: "Package desktop installer", detail: "package + notarize" }, + { name: "Windows x64 unsigned Preview", packageStep: "Package unsigned Windows installer", detail: "package" }, + ] + .map((target) => ({ target, job: findJob(payload, target.name) })) + .filter(({ job }) => Boolean(job)); if (architectureJobs.length > 0) { - return architectureJobs.map((job) => { - const packageStep = findStep(job, "Package desktop installer"); + return architectureJobs.map(({ target, job }) => { + const packageStep = findStep(job, target.packageStep); return componentRow({ - target: job.name, + target: target.name === "Windows x64 unsigned Preview" ? "Windows x64 Preview" : target.name, mode: "rebuild", candidate: job, duration: jobDurationMs(job), - detail: `package + notarize ${formatDuration(stepDurationMs(packageStep))}`, + detail: `${target.detail} ${formatDuration(stepDurationMs(packageStep))}`, url: job.html_url ?? payload.run.html_url, }); }); } - const reuseJob = findJob(payload, "Reuse macOS release assets"); + const reuseJob = findJob(payload, "Reuse desktop release assets"); if (reuseJob) { return [ componentRow({ - target: "macOS arm64 + x64", + target: "macOS arm64 + x64 + Windows x64", mode: "reuse", candidate: reuseJob, duration: jobDurationMs(reuseJob), @@ -77,7 +81,7 @@ function nativeRows(payload, platform, requestedMode) { const planJob = findJob(payload, "Plan desktop release asset"); return [ componentRow({ - target: "macOS arm64 + x64", + target: "macOS arm64 + x64 + Windows x64", mode: "already prepared", candidate: planJob ?? payload.run, duration: jobDurationMs(planJob) ?? runDurationMs(payload.run), diff --git a/scripts/report-release-timings.test.mjs b/scripts/report-release-timings.test.mjs index 02470527..176876dd 100644 --- a/scripts/report-release-timings.test.mjs +++ b/scripts/report-release-timings.test.mjs @@ -44,6 +44,9 @@ describe("release timing report", () => { job("macOS x64", start, 600, [ step("Package desktop installer", start, 300), ]), + job("Windows x64 unsigned Preview", start, 420, [ + step("Package unsigned Windows installer", start, 240), + ]), ]), desktopMode: "rebuild", mobile: payload("Mobile", start, 540, [ @@ -83,6 +86,7 @@ describe("release timing report", () => { "Docker TCR (amd64 + arm64)", "macOS arm64", "macOS x64", + "Windows x64 Preview", "Android arm64", "Google Play signed APK", ]); @@ -96,6 +100,9 @@ describe("release timing report", () => { expect(markdown).toContain( "| macOS x64 | rebuild | success | 10m 00s | package + notarize 5m 00s |", ); + expect(markdown).toContain( + "| Windows x64 Preview | rebuild | success | 7m 00s | package 4m 00s |", + ); expect(markdown).toContain( "| Google Play signed APK | build + deliver | success | 11m 00s | AAB build 3m 00s; Play upload 4m 00s |", ); @@ -120,7 +127,7 @@ describe("release timing report", () => { const report = createReleaseTimingReport({ ...common, desktop: payload("Desktop", start, 40, [ - job("Reuse macOS release assets", start, 40), + job("Reuse desktop release assets", start, 40), ]), desktopMode: "reuse", mobile: payload("Mobile", start, 30, [ @@ -130,7 +137,7 @@ describe("release timing report", () => { }); expect(report.rows.at(-2)).toMatchObject({ - target: "macOS arm64 + x64", + target: "macOS arm64 + x64 + Windows x64", mode: "reuse", durationMs: 40_000, }); @@ -146,7 +153,7 @@ describe("release timing report", () => { const report = createReleaseTimingReport({ release: { tag: "v1.41.2", sha: "ghi", publishedAt: start }, desktop: payload("Desktop", start, 40, [ - job("Reuse macOS release assets", start, 40), + job("Reuse desktop release assets", start, 40), ]), desktopMode: "reuse", mobile: payload("Mobile", start, 30, [ diff --git a/scripts/run-desktop-builder.mjs b/scripts/run-desktop-builder.mjs index 650dc17b..46201999 100644 --- a/scripts/run-desktop-builder.mjs +++ b/scripts/run-desktop-builder.mjs @@ -4,10 +4,16 @@ const publishIndex = process.argv.indexOf("--publish"); const publishMode = publishIndex >= 0 ? process.argv[publishIndex + 1] ?? "never" : "never"; const environment = { ...process.env }; const requestedArch = environment.EDGE_EVER_DESKTOP_ARCH; +const requestedTarget = environment.EDGE_EVER_DESKTOP_TARGET || ( + process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : "linux" +); if (requestedArch && !["arm64", "x64"].includes(requestedArch)) { throw new Error(`EDGE_EVER_DESKTOP_ARCH must be arm64 or x64, received: ${requestedArch}`); } +if (!["mac", "win", "linux"].includes(requestedTarget)) { + throw new Error(`EDGE_EVER_DESKTOP_TARGET must be mac, win, or linux, received: ${requestedTarget}`); +} // electron-builder treats an explicitly present empty CSC_LINK/WIN_CSC_LINK // as a path. Remove empty optional signing variables so unsigned CI builds @@ -35,7 +41,7 @@ const builderArgs = [ publishMode, ]; if (requestedArch) { - builderArgs.push("--mac", `--${requestedArch}`); + builderArgs.push(`--${requestedTarget}`, `--${requestedArch}`); } const result = spawnSync(process.execPath, builderArgs, { diff --git a/scripts/sign-windows-update-manifest.mjs b/scripts/sign-windows-update-manifest.mjs new file mode 100644 index 00000000..a48a1253 --- /dev/null +++ b/scripts/sign-windows-update-manifest.mjs @@ -0,0 +1,91 @@ +import { createPrivateKey, createPublicKey, sign } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + WINDOWS_UPDATE_KEY_ID, + WINDOWS_UPDATE_PUBLIC_KEY_PEM, + WINDOWS_UPDATE_SIGNATURE_NAME, + verifyWindowsUpdateMetadata, +} from "../apps/desktop/src/main/windows-update-trust.mjs"; + +const normalizedPublicKey = (key) => (key?.type === "public" ? key : createPublicKey(key)).export({ + type: "spki", + format: "pem", +}).trim(); + +export const assertWindowsUpdateSigningKey = ({ + privateKeyPath, + expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM, +}) => { + if (!privateKeyPath) { + throw new Error("EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY must point to the offline Ed25519 private key"); + } + const privateKey = createPrivateKey(readFileSync(privateKeyPath, "utf8")); + if (privateKey.asymmetricKeyType !== "ed25519") { + throw new Error("Windows update signing key must be Ed25519"); + } + const derivedPublicKey = normalizedPublicKey(createPublicKey(privateKey)); + if (derivedPublicKey !== normalizedPublicKey(expectedPublicKey)) { + throw new Error("Windows update signing key does not match the public key pinned in the desktop client"); + } + return privateKey; +}; + +export const signWindowsUpdateManifest = ({ + manifestPath, + signaturePath, + privateKeyPath, + expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM, +}) => { + const privateKey = assertWindowsUpdateSigningKey({ privateKeyPath, expectedPublicKey }); + const manifestBytes = readFileSync(manifestPath); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + if (manifest?.schemaVersion !== 1 || manifest.keyId !== WINDOWS_UPDATE_KEY_ID) { + throw new Error("Windows update manifest key metadata is invalid"); + } + const envelope = { + schemaVersion: 1, + keyId: WINDOWS_UPDATE_KEY_ID, + signature: sign(null, manifestBytes, privateKey).toString("base64"), + }; + const signatureBytes = Buffer.from(`${JSON.stringify(envelope)}\n`); + verifyWindowsUpdateMetadata({ + manifestBytes, + signatureBytes, + expectedVersion: manifest.version, + updateInfo: { + version: manifest.version, + files: [{ + url: manifest.file?.name, + size: manifest.file?.size, + sha512: manifest.file?.sha512, + }], + }, + trustedPublicKeys: { [WINDOWS_UPDATE_KEY_ID]: expectedPublicKey }, + }); + writeFileSync(signaturePath, signatureBytes, { mode: 0o644 }); + return envelope; +}; + +const run = () => { + const [manifestValue, signatureValue] = process.argv.slice(2); + if (!manifestValue) { + throw new Error("Usage: node scripts/sign-windows-update-manifest.mjs [signature]"); + } + const manifestPath = resolve(manifestValue); + const signaturePath = resolve(signatureValue || `${manifestPath}.sig`); + if (!signaturePath.endsWith(WINDOWS_UPDATE_SIGNATURE_NAME)) { + throw new Error(`Signature filename must end with ${WINDOWS_UPDATE_SIGNATURE_NAME}`); + } + signWindowsUpdateManifest({ + manifestPath, + signaturePath, + privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY, + }); + process.stdout.write(`${signaturePath}\n`); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + run(); +} diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs index 96aee5f1..4632d1e7 100644 --- a/scripts/verify-desktop-package.mjs +++ b/scripts/verify-desktop-package.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, rmSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { listPackage } from "@electron/asar"; import { assertMacIcnsComplete } from "./desktop-icns.mjs"; @@ -33,6 +33,22 @@ const verifyMachOArch = (path, arch, label) => { assert.deepEqual(result.stdout.trim().split(/\s+/), [expectedArch], `${label} must contain only ${expectedArch}`); }; +const verifyPeX64 = (path, label) => { + const descriptor = openSync(path, "r"); + try { + const dosHeader = Buffer.alloc(64); + assert.equal(readSync(descriptor, dosHeader, 0, dosHeader.length, 0), dosHeader.length, `${label} DOS header is incomplete`); + assert.equal(dosHeader.toString("ascii", 0, 2), "MZ", `${label} is not a PE executable`); + const peOffset = dosHeader.readUInt32LE(0x3c); + const peHeader = Buffer.alloc(6); + assert.equal(readSync(descriptor, peHeader, 0, peHeader.length, peOffset), peHeader.length, `${label} PE header is incomplete`); + assert.equal(peHeader.toString("ascii", 0, 4), "PE\0\0", `${label} PE signature is invalid`); + assert.equal(peHeader.readUInt16LE(4), 0x8664, `${label} must target Windows x64`); + } finally { + closeSync(descriptor); + } +}; + for (const sidecarPath of files.filter((path) => /[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i.test(path))) { const bundleRoot = sidecarPath.replace(/[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i, ""); assert.ok(existsSync(join(bundleRoot, "resources", "web", "index.html")), `Desktop bundle is missing the Web renderer: ${bundleRoot}`); @@ -69,9 +85,24 @@ if (requestedPlatform === "darwin") { // disposable unpacked bundle once its contents have passed verification. rmSync(unpackedApp, { recursive: true, force: true }); } else if (requestedPlatform === "win32") { - const installer = matchingPrefix(`EdgeEver-${version}-windows-`).some((path) => path.endsWith(".exe")); - const unpacked = existsSync(join(outputDirectory, "win-arm64-unpacked", "EdgeEver.exe")); - assert.ok(installer || unpacked, "Windows package must contain the current NSIS installer or an unpacked executable"); + assert.equal(requestedArch, "x64", `Windows release package must target x64, received: ${requestedArch}`); + const installer = join(outputDirectory, `EdgeEver-${version}-windows-x64.exe`); + const unpackedDirectory = join(outputDirectory, "win-unpacked"); + const executable = join(unpackedDirectory, "EdgeEver.exe"); + const resources = join(unpackedDirectory, "resources"); + const sidecar = join(resources, "sidecar", "edgeever-sidecar.exe"); + assert.ok(existsSync(installer), `Windows package must contain the current x64 NSIS installer: ${installer}`); + assert.ok(existsSync(executable), `Windows package must contain the unpacked x64 executable: ${executable}`); + assert.ok(existsSync(sidecar), `Windows package must contain the x64 sidecar: ${sidecar}`); + assert.ok(existsSync(join(resources, "web", "index.html")), "Windows app bundle is missing the Web renderer"); + assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations"); + verifyPeX64(executable, "Electron executable"); + verifyPeX64(sidecar, "Rust sidecar"); + const asarPath = join(resources, "app.asar"); + assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`); + const asarFiles = new Set(listPackage(asarPath)); + assert.ok(asarFiles.has("/src/preload/index.cjs"), "Windows app bundle must contain the sandbox-compatible CommonJS preload"); + assert.ok(!asarFiles.has("/src/preload/index.mjs"), "Windows app bundle must not contain the unsupported ESM preload"); } else if (requestedPlatform === "linux") { assert.ok(matchingPrefix(`EdgeEver-${version}-linux-`).some((path) => path.endsWith(".AppImage")), "Linux package must contain the current AppImage"); const unpacked = files.find((path) => path.endsWith("/resources/sidecar/edgeever-sidecar") && path.includes("linux-") && path.includes("-unpacked")); diff --git a/scripts/verify-windows-update-release.mjs b/scripts/verify-windows-update-release.mjs new file mode 100644 index 00000000..950cc626 --- /dev/null +++ b/scripts/verify-windows-update-release.mjs @@ -0,0 +1,77 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + WINDOWS_UPDATE_MANIFEST_NAME, + WINDOWS_UPDATE_SIGNATURE_NAME, + verifyDownloadedWindowsUpdate, + verifyWindowsUpdateMetadata, +} from "../apps/desktop/src/main/windows-update-trust.mjs"; + +export const verifyWindowsUpdateRelease = async (directory) => { + const manifestBytes = readFileSync(join(directory, WINDOWS_UPDATE_MANIFEST_NAME)); + const signatureBytes = readFileSync(join(directory, WINDOWS_UPDATE_SIGNATURE_NAME)); + const untrustedManifest = JSON.parse(manifestBytes.toString("utf8")); + const expectedNames = [ + untrustedManifest.file?.name, + "latest.yml", + WINDOWS_UPDATE_MANIFEST_NAME, + WINDOWS_UPDATE_SIGNATURE_NAME, + "SHA256SUMS-windows.txt", + ].sort(); + const actualNames = readdirSync(directory).sort(); + if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { + throw new Error(`Windows update audit expected exactly: ${expectedNames.join(", ")}`); + } + const manifest = verifyWindowsUpdateMetadata({ + manifestBytes, + signatureBytes, + expectedVersion: untrustedManifest.version, + updateInfo: { + version: untrustedManifest.version, + files: [{ + url: untrustedManifest.file?.name, + size: untrustedManifest.file?.size, + sha512: untrustedManifest.file?.sha512, + }], + }, + }); + await verifyDownloadedWindowsUpdate({ + path: join(directory, manifest.file.name), + manifest, + }); + + const latestYml = readFileSync(join(directory, "latest.yml"), "utf8"); + const expectedLatestYml = [ + `version: ${manifest.version}`, + "files:", + ` - url: ${manifest.file.name}`, + ` sha512: ${manifest.file.sha512}`, + ` size: ${manifest.file.size}`, + `path: ${manifest.file.name}`, + `sha512: ${manifest.file.sha512}`, + `releaseDate: '${manifest.releaseDate}'`, + "", + ].join("\n"); + if (latestYml !== expectedLatestYml) { + throw new Error("latest.yml does not exactly match the signed manifest"); + } + const checksum = readFileSync(join(directory, "SHA256SUMS-windows.txt"), "utf8"); + if (checksum !== `${manifest.file.sha256} ${manifest.file.name}\n`) { + throw new Error("SHA256SUMS-windows.txt does not match the signed manifest"); + } + return manifest; +}; + +const run = async () => { + const [directoryValue] = process.argv.slice(2); + if (!directoryValue) { + throw new Error("Usage: node scripts/verify-windows-update-release.mjs "); + } + const manifest = await verifyWindowsUpdateRelease(resolve(directoryValue)); + process.stdout.write(`Verified ${manifest.file.name} with ${manifest.keyId}\n`); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await run(); +} diff --git a/scripts/windows-update-metadata.test.mjs b/scripts/windows-update-metadata.test.mjs new file mode 100644 index 00000000..9349eb8d --- /dev/null +++ b/scripts/windows-update-metadata.test.mjs @@ -0,0 +1,123 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { createWindowsUpdateMetadata } from "./create-windows-update-metadata.mjs"; +import { + assertWindowsUpdateSigningKey, + signWindowsUpdateManifest, +} from "./sign-windows-update-manifest.mjs"; +import { verifyWindowsUpdateRelease } from "./verify-windows-update-release.mjs"; + +describe("Windows update release metadata", () => { + test("creates installer metadata consumed by electron-updater and the trust gate", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-metadata-")); + try { + await writeFile(join(directory, "EdgeEver-1.49.0-windows-x64.exe"), "payload"); + const manifest = await createWindowsUpdateMetadata({ directory, version: "1.49.0" }); + expect(manifest.file).toMatchObject({ + name: "EdgeEver-1.49.0-windows-x64.exe", + size: 7, + sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + }); + expect(await readFile(join(directory, "latest.yml"), "utf8")) + .toContain(`sha512: ${manifest.file.sha512}`); + expect(await readFile(join(directory, "SHA256SUMS-windows.txt"), "utf8")) + .toBe(`${manifest.file.sha256} ${manifest.file.name}\n`); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("signs only with the private key matching the expected public key", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-signing-")); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const { publicKey: otherPublicKey } = generateKeyPairSync("ed25519"); + const manifestPath = join(directory, "latest-windows.json"); + const signaturePath = `${manifestPath}.sig`; + const privateKeyPath = join(directory, "private.pem"); + try { + await writeFile(manifestPath, `${JSON.stringify({ + schemaVersion: 1, + keyId: "edgeever-windows-update-2026-01", + version: "1.49.0", + platform: "win32", + arch: "x64", + releaseDate: "2026-08-30T00:00:00.000Z", + file: { + name: "EdgeEver-1.49.0-windows-x64.exe", + size: 7, + sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==", + sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + }, + })}\n`); + await writeFile(privateKeyPath, privateKey.export({ type: "pkcs8", format: "pem" })); + expect(assertWindowsUpdateSigningKey({ + privateKeyPath, + expectedPublicKey: publicKey, + }).asymmetricKeyType).toBe("ed25519"); + expect(signWindowsUpdateManifest({ + manifestPath, + signaturePath, + privateKeyPath, + expectedPublicKey: publicKey, + }).signature).toHaveLength(88); + expect(() => signWindowsUpdateManifest({ + manifestPath, + signaturePath, + privateKeyPath, + expectedPublicKey: otherPublicKey, + })).toThrow("does not match"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("audits the complete production release asset set", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-release-audit-")); + const manifest = { + schemaVersion: 1, + keyId: "edgeever-windows-update-2026-01", + version: "1.49.0", + platform: "win32", + arch: "x64", + releaseDate: "2026-08-30T00:00:00.000Z", + file: { + name: "EdgeEver-1.49.0-windows-x64.exe", + size: 7, + sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==", + sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + }, + }; + try { + await writeFile(join(directory, manifest.file.name), "payload"); + await writeFile(join(directory, "latest-windows.json"), `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile(join(directory, "latest-windows.json.sig"), `${JSON.stringify({ + schemaVersion: 1, + keyId: manifest.keyId, + signature: "Itc3UD0G6Hn6BWdk9qlJ4SoqAUDn8efi1VRVUWDtA3Ip5+gwpAj7HxElxM6D/GXcAlHFxnVv19soEWxlfZLCDA==", + })}\n`); + await writeFile(join(directory, "latest.yml"), [ + `version: ${manifest.version}`, + "files:", + ` - url: ${manifest.file.name}`, + ` sha512: ${manifest.file.sha512}`, + ` size: ${manifest.file.size}`, + `path: ${manifest.file.name}`, + `sha512: ${manifest.file.sha512}`, + `releaseDate: '${manifest.releaseDate}'`, + "", + ].join("\n")); + await writeFile( + join(directory, "SHA256SUMS-windows.txt"), + `${manifest.file.sha256} ${manifest.file.name}\n`, + ); + await expect(verifyWindowsUpdateRelease(directory)).resolves.toEqual(manifest); + await writeFile(join(directory, manifest.file.name), "tampered"); + await expect(verifyWindowsUpdateRelease(directory)).rejects.toThrow("size does not match"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +});