feat(desktop): add Windows preview distribution

This commit is contained in:
tianma-if committed 2026-08-30 10:24:03 +08:00
1 parent 6582a891d7
commit 383bafa83f
37 files changed
+1496 -107

No files matched your search

+179 -14
View File
@@ -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
+1 -1
View File
@@ -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`,签名配置保存在仓库外。
+4 -2
View File
@@ -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
View File
@@ -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", () => {
+96 -20
View File
@@ -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 });
}
});
});
+14
View File
@@ -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
View File
@@ -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.",
],
+3 -3
View File
@@ -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"],
},
];
+3 -3
View File
@@ -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。",
},
];
---
+21 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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。
+96
View File
@@ -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.
+80
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+29 -3
View File
@@ -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();
}
+11 -1
View File
@@ -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");
});
});
+3
View File
@@ -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",
]);
+3
View File
@@ -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
View File
@@ -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",
+20
View File
@@ -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");
});
+5
View File
@@ -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: [
{
+13 -3
View File
@@ -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", () => {
+14 -10
View File
@@ -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),
+10 -3
View File
@@ -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, [
+7 -1
View File
@@ -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, {
+91
View File
@@ -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();
}
+35 -4
View File
@@ -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"));
+77
View File
@@ -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();
}
+123
View File
@@ -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 });
}
});
});