feat(desktop): add Windows preview distribution
This commit is contained in:
1 parent
6582a891d7
commit
383bafa83f
37 files changed
+1496
-107
No files matched your search
@@ -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
|
||||
@@ -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`,签名配置保存在仓库外。
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+4
-2
@@ -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 使用。
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,16 @@ const copy = siteCopy[locale].hero;
|
||||
<Icon name="bx:bxl-apple" class="w-3.5 h-3.5" />
|
||||
<span>macOS</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/tianma-if/edgeever/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label={copy.windowsPreviewWarning}
|
||||
class="inline-flex min-h-11 sm:min-h-0 items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-50/90 hover:bg-amber-100 hover:text-amber-800 hover:border-amber-400 border border-amber-200/90 text-[11px] font-medium text-amber-800 transition-all shadow-2xs"
|
||||
>
|
||||
<Icon name="bx:bxl-windows" class="w-3.5 h-3.5" />
|
||||
<span>{copy.windowsPreview}</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://apps.apple.com/us/app/edgeever/id6792625631"
|
||||
target="_blank"
|
||||
@@ -119,6 +129,10 @@ const copy = siteCopy[locale].hero;
|
||||
<span>{locale === "en-US" ? "Clipper" : "剪藏插件"}</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-2 flex max-w-xl items-start gap-1.5 text-[10px] leading-4 text-amber-800">
|
||||
<Icon name="bx:bx-error" class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{copy.windowsPreviewWarning}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+14
-8
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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。",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
@@ -727,19 +727,27 @@ export const NotebookPane = ({
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="flex cursor-not-allowed items-center justify-between gap-2.5 rounded-md px-2 py-1.5 text-sm text-slate-400 opacity-60 dark:text-slate-500"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<BrandIconContainer className="opacity-60">
|
||||
<BrandIcon path={WINDOWS_ICON_PATH} color="#0078D4" />
|
||||
</BrandIconContainer>
|
||||
<span className="truncate">{t("pwa.sidebarWindows") || "Windows"}</span>
|
||||
</div>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{t("pwa.sidebarWindowsBadge") || "即将推出"}
|
||||
</span>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={DESKTOP_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`${t("pwa.sidebarWindows")} ${t("pwa.sidebarWindowsBadge")}`}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2.5 rounded-md px-2 py-1.5 text-sm text-slate-700 outline-none transition-colors hover:bg-slate-100 hover:text-slate-900 focus:bg-slate-100 focus:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<BrandIconContainer>
|
||||
<BrandIcon path={WINDOWS_ICON_PATH} color="#0078D4" />
|
||||
</BrandIconContainer>
|
||||
<span className="truncate font-medium">{t("pwa.sidebarWindows") || "Windows"}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 text-amber-700 dark:text-amber-400">
|
||||
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold dark:bg-amber-950/40">
|
||||
{t("pwa.sidebarWindowsBadge") || "Preview"}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
+16
-3
@@ -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。
|
||||
|
||||
@@ -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-<version>-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.
|
||||
@@ -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-<version>-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 发布。
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <mobile|desktop> <true|false> <current-tag> [desktop-version]",
|
||||
"Usage: node scripts/check-native-release-assets.mjs <mobile|desktop> <true|false> <current-tag> [desktop-version] [required|allow-missing-windows-signature]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -80,6 +105,7 @@ const run = () => {
|
||||
currentTag,
|
||||
desktopVersion,
|
||||
assetNames,
|
||||
requireWindowsSignature: signatureMode === "required",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 <directory> <version>");
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+77
-3
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 <manifest> [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();
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 <directory>");
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user