diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index add3585c..996107ab 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
- description: "Prepare macOS assets on this existing Draft Release (optional)"
+ description: "Prepare macOS and Windows assets on this existing Draft Release (optional)"
required: false
type: string
release:
@@ -103,10 +103,10 @@ jobs:
needs.release-plan.outputs.asset_ready == 'false' &&
needs.release-plan.outputs.rebuild == 'false'
}}
- name: Reuse macOS release assets
+ name: Reuse desktop release assets
runs-on: ubuntu-latest
steps:
- - name: Copy latest compatible macOS assets without renaming
+ - name: Copy latest compatible desktop assets without renaming
env:
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
GH_TOKEN: ${{ github.token }}
@@ -124,8 +124,13 @@ jobs:
--pattern 'EdgeEver-*-mac-x64.zip' \
--pattern 'EdgeEver-*-mac-x64.zip.blockmap' \
--pattern 'latest-mac.yml' \
+ --pattern 'EdgeEver-*-windows-x64.exe' \
+ --pattern 'latest.yml' \
+ --pattern 'latest-windows.json' \
+ --pattern 'latest-windows.json.sig' \
+ --pattern 'SHA256SUMS-windows.txt' \
--dir "$RUNNER_TEMP/native-assets"
- test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "9"
+ test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "14"
gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \
--repo "$GITHUB_REPOSITORY" \
--clobber
@@ -327,15 +332,137 @@ jobs:
if-no-files-found: error
retention-days: 14
- native-build-timings:
- needs: desktop
+ windows:
+ needs: release-plan
if: >-
${{
always() &&
github.repository == 'tianma-if/edgeever' &&
- needs.desktop.result != 'skipped'
+ (
+ (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') ||
+ (
+ github.event_name == 'workflow_dispatch' &&
+ inputs.release_tag != '' &&
+ needs.release-plan.result == 'success' &&
+ needs.release-plan.outputs.asset_ready == 'false' &&
+ needs.release-plan.outputs.rebuild == 'true'
+ )
+ )
}}
- name: Report macOS build timings
+ name: Windows x64 unsigned Preview
+ runs-on: windows-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v5
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~\AppData\Local\bun\install\cache
+ key: ${{ runner.os }}-x64-bun-${{ hashFiles('bun.lock') }}
+
+ - name: Cache Rust dependencies and sidecar builds
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~\.cargo\registry
+ ~\.cargo\git
+ crates\desktop-sidecar\target
+ key: ${{ runner.os }}-x64-rust-${{ hashFiles('crates/desktop-sidecar/Cargo.lock', 'crates/desktop-sidecar/**/*.rs') }}
+ restore-keys: |
+ ${{ runner.os }}-x64-rust-
+
+ - name: Cache Electron downloads
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~\AppData\Local\electron\Cache
+ ~\AppData\Local\electron-builder\Cache
+ key: ${{ runner.os }}-x64-electron-${{ hashFiles('bun.lock', 'apps/desktop/package.json') }}
+
+ - name: Install dependencies
+ shell: pwsh
+ run: |
+ for ($attempt = 1; $attempt -le 3; $attempt++) {
+ bun install --frozen-lockfile
+ $installExitCode = $LASTEXITCODE
+ if ($installExitCode -eq 0) { exit 0 }
+ if ($attempt -eq 3) { exit $installExitCode }
+ Write-Warning "Dependency installation failed on attempt $attempt; retrying."
+ Start-Sleep -Seconds (15 * $attempt)
+ }
+
+ - name: Build Web renderer
+ env:
+ EDGE_EVER_DESKTOP_BUILD: "1"
+ run: bun run build:web
+
+ - name: Build x64 Rust sidecar
+ run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release
+
+ - name: Package unsigned Windows installer
+ env:
+ EDGE_EVER_DESKTOP_ARCH: x64
+ EDGE_EVER_DESKTOP_TARGET: win
+ run: bun scripts/run-desktop-builder.mjs --publish never
+
+ - name: Verify Windows package contents and architecture
+ env:
+ EDGE_EVER_DESKTOP_ARCH: x64
+ EDGE_EVER_VERIFY_TARGET: win32
+ run: bun scripts/verify-desktop-package.mjs
+
+ - name: Verify that Preview executables are unsigned
+ shell: pwsh
+ run: |
+ $version = (Get-Content apps/desktop/package.json | ConvertFrom-Json).version
+ $paths = @(
+ "release/desktop/EdgeEver-$version-windows-x64.exe",
+ "release/desktop/win-unpacked/EdgeEver.exe",
+ "release/desktop/win-unpacked/resources/sidecar/edgeever-sidecar.exe"
+ )
+ foreach ($path in $paths) {
+ $signature = Get-AuthenticodeSignature -LiteralPath $path
+ if ($signature.Status -ne "NotSigned") {
+ throw "Unsigned Preview asset unexpectedly has an Authenticode signature: $path ($($signature.Status))"
+ }
+ }
+
+ - name: Create Windows update metadata
+ shell: pwsh
+ run: |
+ $version = (Get-Content apps/desktop/package.json | ConvertFrom-Json).version
+ node scripts/create-windows-update-metadata.mjs release/desktop $version
+
+ - name: Stage Windows Preview assets
+ uses: actions/upload-artifact@v6
+ with:
+ name: edgeever-desktop-Windows-x64-staging
+ path: |
+ release/desktop/EdgeEver-*-windows-x64.exe
+ release/desktop/latest.yml
+ release/desktop/latest-windows.json
+ release/desktop/SHA256SUMS-windows.txt
+ if-no-files-found: error
+ retention-days: 14
+
+ native-build-timings:
+ needs: [desktop, windows]
+ if: >-
+ ${{
+ always() &&
+ github.repository == 'tianma-if/edgeever' &&
+ (needs.desktop.result != 'skipped' || needs.windows.result != 'skipped')
+ }}
+ name: Report desktop build timings
runs-on: ubuntu-latest
steps:
- name: Check out timing reporter
@@ -364,12 +491,13 @@ jobs:
retention-days: 90
desktop-assets:
- needs: [release-plan, desktop]
+ needs: [release-plan, desktop, windows]
if: >-
${{
always() &&
github.repository == 'tianma-if/edgeever' &&
needs.desktop.result == 'success' &&
+ needs.windows.result == 'success' &&
(
(github.event_name == 'workflow_dispatch' && inputs.release_tag == '') ||
(
@@ -381,16 +509,16 @@ jobs:
)
)
}}
- name: Finalize macOS release assets
+ name: Finalize desktop release assets
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v5
- - name: Download architecture-specific assets
+ - name: Download platform-specific assets
uses: actions/download-artifact@v7
with:
- pattern: edgeever-desktop-macOS-*-staging
+ pattern: edgeever-desktop-*-staging
path: release/desktop
merge-multiple: true
@@ -400,7 +528,7 @@ jobs:
node scripts/create-mac-update-metadata.mjs release/desktop "$version"
asset_ready="$(
find release/desktop -maxdepth 1 -type f -exec basename {} \; |
- node scripts/check-native-release-assets.mjs desktop true "v${version}" "$version"
+ node scripts/check-native-release-assets.mjs desktop true "v${version}" "$version" allow-missing-windows-signature
)"
test "$asset_ready" = "true"
@@ -417,7 +545,44 @@ jobs:
- name: Upload combined desktop installers and metadata
uses: actions/upload-artifact@v6
with:
- name: edgeever-desktop-macOS-arm64-x64
+ name: edgeever-desktop-macOS-and-Windows-x64
path: release/desktop/*
if-no-files-found: error
retention-days: 14
+
+ windows-update-audit:
+ needs: release-plan
+ if: >-
+ ${{
+ always() &&
+ github.repository == 'tianma-if/edgeever' &&
+ needs.release-plan.result == 'success' &&
+ needs.release-plan.outputs.asset_ready == 'true' &&
+ (
+ github.event_name == 'release' ||
+ (github.event_name == 'workflow_dispatch' && inputs.release_tag != '')
+ )
+ }}
+ name: Audit signed Windows update
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v5
+
+ - name: Download Windows release assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
+ run: |
+ mkdir -p release/desktop
+ gh release download "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern 'EdgeEver-*-windows-x64.exe' \
+ --pattern 'latest.yml' \
+ --pattern 'latest-windows.json' \
+ --pattern 'latest-windows.json.sig' \
+ --pattern 'SHA256SUMS-windows.txt' \
+ --dir release/desktop
+
+ - name: Verify signature and installer digests
+ run: node scripts/verify-windows-update-release.mjs release/desktop
diff --git a/AGENTS.md b/AGENTS.md
index f548ec73..ea89a79e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@
1. **Fork 工作流边界**:配置 GitHub Actions 时必须考虑大量用户会 Fork 仓库进行自部署;仅官方仓库需要的 Job 必须使用 `github.repository == 'tianma-if/edgeever'` 门禁,严禁在下游 Fork 中分配 Runner 或执行。
2. **版本号与基线**:`vX.Y.Z`(非 Draft/Prerelease)。发布须显式 `--bump patch|minor|major`(脚本不自动选级);按 SemVer 选择,**禁止因发版节奏把用户可感知的新能力或新平台压成 patch**。递增根目录 `package.json`;含移动端修改时同步 `apps/mobile/app.json` 的 `expo.version` 并递增 `android.versionCode`。上一个正式 Release 为审计基线。
-3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG 和 Android arm64 APK。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。
+3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG、Windows x64 Preview 安装包和 Android arm64 APK。Windows Preview 未使用 Authenticode 签名时必须同时提供经仓库外 Ed25519 私钥签名的更新清单,并在发布前通过独立下载与摘要审计。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。
4. **验证命令**:必须通过 `bun run typecheck`、`bun run typecheck:mobile` 和 `bun run build:web`。
5. **测试职责边界**:正式 Release 必须先在官方仓库及与下游一致的 Ubuntu 环境通过完整非 E2E 测试,严禁将上游自身的测试失败转嫁给下游 Fork 发现。只读部署 Fork 仅同步产品快照且不运行测试;只有显式保留定制改动的 Fork 才验证合并结果,失败时必须保持 `main` 与生产环境不变。
6. **原生资产构建与复用**:由 `scripts/plan-native-release.mjs` 决定重建或复用;桌面资产包含 `apps/web`。修改判定规则时同步更新测试。移动端重建使用 `bun run build:android:apk:local`,签名配置保存在仓库外。
diff --git a/README.md b/README.md
index 20d93dd0..90b90a3f 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s
- **Batch Operations & Flexible Sorting**: Easily merge or relocate multiple notes, with drag-and-drop notebook reordering.
- **Offline Drafts & Queueing**: Draft and edit uninterrupted while offline; changes automatically sync once reconnected.
- **Multi-Tenant Account Isolation**: Host multiple user accounts on a single instance with strictly partitioned spaces and clean admin account management.
-- **Everywhere You Need It**: Available on the Web, [Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile), [macOS](https://github.com/tianma-if/edgeever/releases), and [iOS](https://apps.apple.com/us/app/edgeever/id6792625631), with Windows coming soon; the Web Clipper supports [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), [Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), and [Firefox](https://addons.mozilla.org/firefox/addon/edgeever-web-clipper/).
+- **Everywhere You Need It**: Available on the Web, [Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile), [macOS](https://github.com/tianma-if/edgeever/releases), [Windows x64 Preview](https://github.com/tianma-if/edgeever/releases/latest), and [iOS](https://apps.apple.com/us/app/edgeever/id6792625631); the Web Clipper supports [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), [Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo), and [Firefox](https://addons.mozilla.org/firefox/addon/edgeever-web-clipper/).
## Deployment
@@ -139,7 +139,9 @@ Native clients offer a smoother, more reliable experience with deeper system int
The Android app is now available on [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile), with signed APKs also available from [GitHub Releases](https://github.com/tianma-if/edgeever/releases). The iOS app is available on the [App Store](https://apps.apple.com/us/app/edgeever/id6792625631); use a non-mainland China Apple ID to download it.
-The macOS app is available from [GitHub Releases](https://github.com/tianma-if/edgeever/releases). The Windows version will be released once the code-signing certificate issue is resolved.
+The macOS app and the unsigned [Windows x64 Preview](https://github.com/tianma-if/edgeever/releases/latest) are available from GitHub Releases. Windows or organization policy may show a warning or block the Preview because it is not yet Authenticode-signed; download it only from the official EdgeEver Release. Do not weaken Windows security settings for EdgeEver—use the Web/PWA client if policy blocks installation.
+
+After installation, the Windows Preview keeps the normal automatic update experience: EdgeEver checks releases, validates an independently Ed25519-signed manifest before downloading, validates the downloaded installer again, and then prompts for restart installation (or installs when EdgeEver later quits). Missing or invalid metadata fails closed. See [Windows Preview security and updates](docs/windows-preview.md).
On platforms without a native client, EdgeEver can be installed as a PWA using Chrome or Edge.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2ff69931..311e6067 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -56,7 +56,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印
- **高效多选与批量操作**:支持笔记批量合并、批量移动,以及笔记本拖拽排序与层级调整。
- **离线草稿与同步队列**:网络不稳定时自动保存离线草稿,恢复连线后自动入队同步。
- **多账号与个人空间隔离**:单实例支持创建多个独立账号,用户数据相互隔离,配备直观的管理员账号管理与安全加密机制。
-- **全平台多端覆盖**:支持 Web、[Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile)、[macOS](https://github.com/tianma-if/edgeever/releases) 和 [iOS](https://apps.apple.com/us/app/edgeever/id6792625631),Windows 版即将推出;网页裁剪插件支持 [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo)、[Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo) 和 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/edgeever-web-clipper/)。
+- **全平台多端覆盖**:支持 Web、[Android](https://play.google.com/store/apps/details?id=org.edgeever.mobile)、[macOS](https://github.com/tianma-if/edgeever/releases)、[Windows x64 预览版](https://github.com/tianma-if/edgeever/releases/latest) 和 [iOS](https://apps.apple.com/us/app/edgeever/id6792625631);网页裁剪插件支持 [Chrome](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo)、[Edge](https://chromewebstore.google.com/detail/edgeever-web-clipper/gjadpfmanienmlofajibkfkkpfdkclgo) 和 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/edgeever-web-clipper/)。
## 部署
@@ -135,7 +135,9 @@ EdgeEver 官方容器镜像托管于 GitHub Container Registry(GHCR)。部
Android App 现已上架 [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile),也可从 [GitHub Releases](https://github.com/tianma-if/edgeever/releases) 下载签名 APK。iOS App 现已上架 [App Store](https://apps.apple.com/us/app/edgeever/id6792625631),可使用非大陆区的 Apple ID 下载。
-macOS App 可从 [GitHub Releases](https://github.com/tianma-if/edgeever/releases) 下载。Windows 版本正在处理代码签名证书问题,解决后即可发布。
+macOS App 与未签名的 [Windows x64 预览版](https://github.com/tianma-if/edgeever/releases/latest) 均可从 GitHub Releases 下载。Windows 预览版尚未使用 Authenticode 签名,系统或组织策略可能显示警告或阻止安装;请仅从 EdgeEver 官方 Release 下载。不要为安装 EdgeEver 降低 Windows 安全设置;如策略阻止安装,请继续使用 Web/PWA 客户端。
+
+安装后,Windows 预览版仍保留正常的自动更新体验:EdgeEver 发现新 Release 后,会在下载前验证独立 Ed25519 签名的更新清单,下载完成后再次验证安装包,再提示重启安装(选择稍后则在退出 EdgeEver 时自动安装);清单缺失、签名错误或文件不一致都会停止更新。详见 [Windows 预览版安全与更新说明](docs/windows-preview.zh-CN.md)。
暂无原生客户端的平台,可通过 Chrome 或 Edge 将 EdgeEver 安装为 PWA 使用。
diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs
index 3146c0f3..efa91345 100644
--- a/apps/desktop/src/main/desktop-update-flow.test.mjs
+++ b/apps/desktop/src/main/desktop-update-flow.test.mjs
@@ -9,10 +9,20 @@ const notebookPaneSource = readFileSync(new URL("../../../web/src/components/Not
describe("desktop update flow", () => {
test("downloads updates in the background and relaunches after installation", () => {
- expect(mainSource).toContain("autoUpdater.autoDownload = true");
+ expect(mainSource).toContain('autoUpdater.autoDownload = process.platform !== "win32"');
expect(mainSource).toContain("autoUpdater.autoRunAppAfterInstall = true");
expect(mainSource).toContain("isQuitting = true;\n autoUpdater.quitAndInstall(false, true)");
expect(mainSource).toContain("result?.downloadPromise");
+ expect(mainSource).toContain("downloadTrustedDesktopUpdate(reason)");
+ });
+
+ test("fails closed around unsigned Windows automatic updates", () => {
+ expect(mainSource).toContain("fetchTrustedWindowsUpdate({");
+ expect(mainSource).toContain("verifyDownloadedWindowsUpdate({");
+ expect(mainSource).toContain('autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"');
+ expect(mainSource).toContain("windowsDownloadedUpdateVerified = true");
+ expect(mainSource).toContain('writeDiagnostic("update.windows-package-blocked"');
+ expect(mainSource).toContain('downloadTrustedDesktopUpdate("manual-download")');
});
test("offers a manual update check in desktop system settings", () => {
diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs
index 5e4eec56..13ab9a78 100644
--- a/apps/desktop/src/main/index.mjs
+++ b/apps/desktop/src/main/index.mjs
@@ -25,6 +25,10 @@ import { trayIconPath } from "./tray-icon.mjs";
import { writeRichClipboard } from "./clipboard-write.mjs";
import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs";
import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs";
+import {
+ fetchTrustedWindowsUpdate,
+ verifyDownloadedWindowsUpdate,
+} from "./windows-update-trust.mjs";
import electronUpdater from "electron-updater";
const { autoUpdater } = electronUpdater;
@@ -82,6 +86,8 @@ let updateCheckTimer = null;
let lastUpdateCheckAt = 0;
let downloadedUpdateVersion = null;
let promptedUpdateVersion = null;
+let trustedWindowsUpdate = null;
+let windowsDownloadedUpdateVerified = false;
let sidecarScopeKey = "anonymous";
let activeAccountId = null;
let shutdownCleanupStarted = false;
@@ -539,7 +545,10 @@ const publishDesktopUpdateStatus = () => {
};
const installDownloadedUpdate = () => {
- if (updateState !== "downloaded") return { started: false };
+ if (
+ updateState !== "downloaded" ||
+ (process.platform === "win32" && !windowsDownloadedUpdateVerified)
+ ) return { started: false };
// The normal window close handler hides the app. Mark this as a real quit
// before electron-updater closes windows so installation can proceed.
isQuitting = true;
@@ -547,6 +556,28 @@ const installDownloadedUpdate = () => {
return { started: true };
};
+const trackDesktopUpdateDownload = (downloadPromise, reason) => {
+ updateDownloadInFlight = Promise.resolve(downloadPromise)
+ .catch(async (error) => {
+ updateState = "idle";
+ downloadedUpdateVersion = null;
+ windowsDownloadedUpdateVerified = false;
+ refreshTrayMenu();
+ publishDesktopUpdateStatus();
+ await writeDiagnostic("update.download-failed", { reason, message: error.message });
+ })
+ .finally(() => { updateDownloadInFlight = null; });
+ return updateDownloadInFlight;
+};
+
+const downloadTrustedDesktopUpdate = (reason) => {
+ if (updateDownloadInFlight) return updateDownloadInFlight;
+ if (process.platform === "win32" && !trustedWindowsUpdate) {
+ return Promise.reject(new Error("Windows update metadata has not passed the signature gate"));
+ }
+ return trackDesktopUpdateDownload(autoUpdater.downloadUpdate(), reason);
+};
+
const promptForDownloadedUpdate = async (version) => {
const promptKey = version || "unknown";
if (isQuitting || promptedUpdateVersion === promptKey) return;
@@ -588,19 +619,32 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } =
lastUpdateCheckAt = now;
void writeDiagnostic("update.check-started", { reason });
updateCheckInFlight = autoUpdater.checkForUpdates()
- .then((result) => {
+ .then(async (result) => {
+ if (process.platform === "win32" && result?.isUpdateAvailable) {
+ trustedWindowsUpdate = await fetchTrustedWindowsUpdate({
+ version: result.updateInfo.version,
+ updateInfo: result.updateInfo,
+ fetchImpl: net.fetch,
+ });
+ windowsDownloadedUpdateVerified = false;
+ void writeDiagnostic("update.windows-manifest-verified", {
+ version: trustedWindowsUpdate.version,
+ keyId: trustedWindowsUpdate.keyId,
+ });
+ void downloadTrustedDesktopUpdate(reason);
+ }
if (result?.downloadPromise) {
- updateDownloadInFlight = result.downloadPromise
- .catch(async (error) => {
- updateState = "idle";
- refreshTrayMenu();
- await writeDiagnostic("update.download-failed", { reason, message: error.message });
- })
- .finally(() => { updateDownloadInFlight = null; });
+ trackDesktopUpdateDownload(result.downloadPromise, reason);
}
return result;
})
.catch(async (error) => {
+ updateState = "idle";
+ downloadedUpdateVersion = null;
+ trustedWindowsUpdate = null;
+ windowsDownloadedUpdateVerified = false;
+ refreshTrayMenu();
+ publishDesktopUpdateStatus();
await writeDiagnostic("update.check-failed", { reason, message: error.message });
throw error;
})
@@ -610,12 +654,16 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } =
const configureAutoUpdater = () => {
if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return;
- autoUpdater.autoDownload = true;
- autoUpdater.autoInstallOnAppQuit = true;
+ autoUpdater.autoDownload = process.platform !== "win32";
+ autoUpdater.autoInstallOnAppQuit = process.platform !== "win32";
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on("update-available", (info) => {
updateState = "available";
downloadedUpdateVersion = info?.version || null;
+ if (process.platform === "win32") {
+ trustedWindowsUpdate = null;
+ windowsDownloadedUpdateVerified = false;
+ }
refreshTrayMenu();
publishDesktopUpdateStatus();
void writeDiagnostic("update.available", { version: info?.version });
@@ -623,20 +671,47 @@ const configureAutoUpdater = () => {
autoUpdater.on("update-not-available", () => {
updateState = "idle";
downloadedUpdateVersion = null;
+ trustedWindowsUpdate = null;
+ windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
publishDesktopUpdateStatus();
void writeDiagnostic("update.not-available");
});
autoUpdater.on("download-progress", (progress) => { void writeDiagnostic("update.download-progress", { percent: progress.percent }); });
autoUpdater.on("update-downloaded", (info) => {
- updateState = "downloaded";
- downloadedUpdateVersion = info?.version || downloadedUpdateVersion;
- refreshTrayMenu();
- publishDesktopUpdateStatus();
- void writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion });
- void promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => {
- promptedUpdateVersion = null;
- void writeDiagnostic("update.prompt-failed", { message: error.message });
+ void (async () => {
+ if (process.platform === "win32") {
+ if (!trustedWindowsUpdate || trustedWindowsUpdate.version !== info?.version) {
+ throw new Error("Downloaded Windows update has no matching trusted manifest");
+ }
+ await verifyDownloadedWindowsUpdate({
+ path: info.downloadedFile,
+ manifest: trustedWindowsUpdate,
+ });
+ windowsDownloadedUpdateVerified = true;
+ autoUpdater.autoInstallOnAppQuit = true;
+ }
+ updateState = "downloaded";
+ downloadedUpdateVersion = info?.version || downloadedUpdateVersion;
+ refreshTrayMenu();
+ publishDesktopUpdateStatus();
+ await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion });
+ await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => {
+ promptedUpdateVersion = null;
+ void writeDiagnostic("update.prompt-failed", { message: error.message });
+ });
+ })().catch(async (error) => {
+ if (process.platform !== "win32") {
+ await writeDiagnostic("update.download-handler-failed", { message: error.message });
+ return;
+ }
+ autoUpdater.autoInstallOnAppQuit = false;
+ updateState = "idle";
+ downloadedUpdateVersion = null;
+ windowsDownloadedUpdateVerified = false;
+ refreshTrayMenu();
+ publishDesktopUpdateStatus();
+ await writeDiagnostic("update.windows-package-blocked", { message: error.message });
});
});
autoUpdater.on("error", (error) => {
@@ -644,6 +719,7 @@ const configureAutoUpdater = () => {
if (updateState !== "downloaded") {
updateState = "idle";
downloadedUpdateVersion = null;
+ windowsDownloadedUpdateVerified = false;
}
refreshTrayMenu();
publishDesktopUpdateStatus();
@@ -1049,7 +1125,7 @@ app.whenReady().then(async () => {
await checkForDesktopUpdate("manual", { force: true, throwOnError: true });
return desktopUpdateStatus();
});
- ipcMain.handle("desktop:download-update", () => autoUpdater.downloadUpdate());
+ ipcMain.handle("desktop:download-update", () => downloadTrustedDesktopUpdate("manual-download"));
ipcMain.handle("desktop:install-update", () => installDownloadedUpdate());
ipcMain.handle("desktop:stage-resource", async (_event, input) => {
const { memoId, name, type, bytes } = normalizeStagedResourceInput(input);
diff --git a/apps/desktop/src/main/windows-update-trust.mjs b/apps/desktop/src/main/windows-update-trust.mjs
new file mode 100644
index 00000000..ea1108e7
--- /dev/null
+++ b/apps/desktop/src/main/windows-update-trust.mjs
@@ -0,0 +1,175 @@
+import { createHash, createPublicKey, verify } from "node:crypto";
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { basename } from "node:path";
+
+export const WINDOWS_UPDATE_KEY_ID = "edgeever-windows-update-2026-01";
+export const WINDOWS_UPDATE_MANIFEST_NAME = "latest-windows.json";
+export const WINDOWS_UPDATE_SIGNATURE_NAME = `${WINDOWS_UPDATE_MANIFEST_NAME}.sig`;
+
+export const WINDOWS_UPDATE_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAOuQwxSey8jGVqrvnkw9LgeBPmeqRieNg/KR0cpUy6RE=
+-----END PUBLIC KEY-----`;
+
+const WINDOWS_UPDATE_PUBLIC_KEYS = Object.freeze({
+ [WINDOWS_UPDATE_KEY_ID]: WINDOWS_UPDATE_PUBLIC_KEY_PEM,
+});
+
+const MAX_METADATA_BYTES = 64 * 1024;
+const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
+const SHA256_HEX = /^[0-9a-f]{64}$/;
+const SHA512_BASE64 = /^[A-Za-z0-9+/]{86}==$/;
+
+const releaseAssetUrl = (version, name) => {
+ if (!STABLE_VERSION.test(version)) {
+ throw new Error(`Windows update version must be stable X.Y.Z: ${version}`);
+ }
+ return `https://github.com/tianma-if/edgeever/releases/download/v${version}/${name}`;
+};
+
+const responseBytes = async (response, label) => {
+ if (!response?.ok) {
+ throw new Error(`${label} request failed with HTTP ${response?.status ?? "unknown"}`);
+ }
+ const bytes = Buffer.from(await response.arrayBuffer());
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_METADATA_BYTES) {
+ throw new Error(`${label} has an invalid size`);
+ }
+ return bytes;
+};
+
+const parseJson = (bytes, label) => {
+ try {
+ return JSON.parse(bytes.toString("utf8"));
+ } catch {
+ throw new Error(`${label} is not valid JSON`);
+ }
+};
+
+const updateFileName = (value) => {
+ const withoutQuery = String(value ?? "").split(/[?#]/, 1)[0];
+ try {
+ return basename(decodeURIComponent(withoutQuery));
+ } catch {
+ return "";
+ }
+};
+
+const assertManifestShape = (manifest, expectedVersion) => {
+ if (
+ manifest?.schemaVersion !== 1 ||
+ manifest.keyId !== WINDOWS_UPDATE_KEY_ID ||
+ manifest.version !== expectedVersion ||
+ manifest.platform !== "win32" ||
+ manifest.arch !== "x64" ||
+ typeof manifest.releaseDate !== "string" ||
+ Number.isNaN(Date.parse(manifest.releaseDate)) ||
+ typeof manifest.file?.name !== "string" ||
+ !/^EdgeEver-\d+\.\d+\.\d+-windows-x64\.exe$/.test(manifest.file.name) ||
+ typeof manifest.file.size !== "number" ||
+ !Number.isSafeInteger(manifest.file.size) ||
+ manifest.file.size <= 0 ||
+ typeof manifest.file.sha512 !== "string" ||
+ !SHA512_BASE64.test(manifest.file.sha512) ||
+ typeof manifest.file.sha256 !== "string" ||
+ !SHA256_HEX.test(manifest.file.sha256)
+ ) {
+ throw new Error("Windows update manifest fields are invalid");
+ }
+ if (manifest.file.name !== `EdgeEver-${expectedVersion}-windows-x64.exe`) {
+ throw new Error("Windows update manifest filename does not match its version");
+ }
+};
+
+const assertUpdateInfoMatches = (manifest, updateInfo) => {
+ const files = updateInfo?.files ?? [];
+ const matchingFiles = files.filter(
+ (file) => updateFileName(file?.url) === manifest.file.name,
+ );
+ if (files.length !== 1 || matchingFiles.length !== 1) {
+ throw new Error("Electron update metadata does not contain exactly one trusted installer");
+ }
+ const [file] = matchingFiles;
+ if (file.sha512 !== manifest.file.sha512 || file.size !== manifest.file.size) {
+ throw new Error("Electron update metadata does not match the signed manifest");
+ }
+};
+
+export const verifyWindowsUpdateMetadata = ({
+ manifestBytes,
+ signatureBytes,
+ expectedVersion,
+ updateInfo,
+ trustedPublicKeys = WINDOWS_UPDATE_PUBLIC_KEYS,
+}) => {
+ const signatureEnvelope = parseJson(signatureBytes, "Windows update signature");
+ if (
+ signatureEnvelope?.schemaVersion !== 1 ||
+ signatureEnvelope.keyId !== WINDOWS_UPDATE_KEY_ID ||
+ typeof signatureEnvelope.signature !== "string"
+ ) {
+ throw new Error("Windows update signature fields are invalid");
+ }
+ const signature = Buffer.from(signatureEnvelope.signature, "base64");
+ if (signature.byteLength !== 64) {
+ throw new Error("Windows update signature has an invalid length");
+ }
+ const publicKey = trustedPublicKeys[signatureEnvelope.keyId];
+ const verificationKey = publicKey?.type === "public" ? publicKey : publicKey ? createPublicKey(publicKey) : null;
+ if (!verificationKey || !verify(null, manifestBytes, verificationKey, signature)) {
+ throw new Error("Windows update manifest signature is invalid");
+ }
+
+ const manifest = parseJson(manifestBytes, "Windows update manifest");
+ assertManifestShape(manifest, expectedVersion);
+ assertUpdateInfoMatches(manifest, updateInfo);
+ return manifest;
+};
+
+export const fetchTrustedWindowsUpdate = async ({
+ version,
+ updateInfo,
+ fetchImpl,
+}) => {
+ const request = (name) => fetchImpl(releaseAssetUrl(version, name), {
+ cache: "no-store",
+ redirect: "follow",
+ });
+ const [manifestResponse, signatureResponse] = await Promise.all([
+ request(WINDOWS_UPDATE_MANIFEST_NAME),
+ request(WINDOWS_UPDATE_SIGNATURE_NAME),
+ ]);
+ const [manifestBytes, signatureBytes] = await Promise.all([
+ responseBytes(manifestResponse, "Windows update manifest"),
+ responseBytes(signatureResponse, "Windows update signature"),
+ ]);
+ return verifyWindowsUpdateMetadata({
+ manifestBytes,
+ signatureBytes,
+ expectedVersion: version,
+ updateInfo,
+ });
+};
+
+const hashFile = (path, algorithm, encoding) => new Promise((resolve, reject) => {
+ const hash = createHash(algorithm);
+ const stream = createReadStream(path);
+ stream.on("error", reject);
+ stream.on("data", (chunk) => hash.update(chunk));
+ stream.on("end", () => resolve(hash.digest(encoding)));
+});
+
+export const verifyDownloadedWindowsUpdate = async ({ path, manifest }) => {
+ const fileStat = await stat(path);
+ if (!fileStat.isFile() || fileStat.size !== manifest.file.size) {
+ throw new Error("Downloaded Windows installer size does not match the signed manifest");
+ }
+ const [sha512, sha256] = await Promise.all([
+ hashFile(path, "sha512", "base64"),
+ hashFile(path, "sha256", "hex"),
+ ]);
+ if (sha512 !== manifest.file.sha512 || sha256 !== manifest.file.sha256) {
+ throw new Error("Downloaded Windows installer checksum does not match the signed manifest");
+ }
+ return true;
+};
diff --git a/apps/desktop/src/main/windows-update-trust.test.mjs b/apps/desktop/src/main/windows-update-trust.test.mjs
new file mode 100644
index 00000000..9d301443
--- /dev/null
+++ b/apps/desktop/src/main/windows-update-trust.test.mjs
@@ -0,0 +1,110 @@
+import { generateKeyPairSync, sign } from "node:crypto";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, test } from "bun:test";
+import {
+ WINDOWS_UPDATE_KEY_ID,
+ verifyDownloadedWindowsUpdate,
+ verifyWindowsUpdateMetadata,
+} from "./windows-update-trust.mjs";
+
+const manifest = {
+ schemaVersion: 1,
+ keyId: WINDOWS_UPDATE_KEY_ID,
+ version: "1.49.0",
+ platform: "win32",
+ arch: "x64",
+ releaseDate: "2026-08-30T00:00:00.000Z",
+ file: {
+ name: "EdgeEver-1.49.0-windows-x64.exe",
+ size: 7,
+ sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==",
+ sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
+ },
+};
+
+const updateInfo = {
+ version: manifest.version,
+ files: [{
+ url: manifest.file.name,
+ size: manifest.file.size,
+ sha512: manifest.file.sha512,
+ }],
+};
+
+const signedFixture = () => {
+ const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
+ const signature = "Itc3UD0G6Hn6BWdk9qlJ4SoqAUDn8efi1VRVUWDtA3Ip5+gwpAj7HxElxM6D/GXcAlHFxnVv19soEWxlfZLCDA==";
+ return {
+ manifestBytes: bytes,
+ signatureBytes: Buffer.from(`${JSON.stringify({ schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, signature })}\n`),
+ };
+};
+
+describe("Windows update trust", () => {
+ test("accepts metadata signed by the pinned release key", () => {
+ const signed = signedFixture();
+ expect(verifyWindowsUpdateMetadata({
+ ...signed,
+ expectedVersion: manifest.version,
+ updateInfo,
+ })).toEqual(manifest);
+ });
+
+ test("rejects metadata signed by any other key", () => {
+ const { privateKey } = generateKeyPairSync("ed25519");
+ const bytes = Buffer.from(`${JSON.stringify(manifest)}\n`);
+ const signature = sign(null, bytes, privateKey).toString("base64");
+ expect(() => verifyWindowsUpdateMetadata({
+ manifestBytes: bytes,
+ signatureBytes: Buffer.from(JSON.stringify({
+ schemaVersion: 1,
+ keyId: WINDOWS_UPDATE_KEY_ID,
+ signature,
+ })),
+ expectedVersion: manifest.version,
+ updateInfo,
+ })).toThrow("signature is invalid");
+ });
+
+ test("rejects a signed manifest that disagrees with latest.yml", () => {
+ const signed = signedFixture();
+ expect(() => verifyWindowsUpdateMetadata({
+ ...signed,
+ expectedVersion: manifest.version,
+ updateInfo: {
+ ...updateInfo,
+ files: [{ ...updateInfo.files[0], size: manifest.file.size + 1 }],
+ },
+ })).toThrow("does not match the signed manifest");
+ });
+
+ test("rejects update metadata that could select an extra installer", () => {
+ const signed = signedFixture();
+ expect(() => verifyWindowsUpdateMetadata({
+ ...signed,
+ expectedVersion: manifest.version,
+ updateInfo: {
+ ...updateInfo,
+ files: [
+ { url: "unexpected.exe", size: 1, sha512: manifest.file.sha512 },
+ ...updateInfo.files,
+ ],
+ },
+ })).toThrow("exactly one trusted installer");
+ });
+
+ test("checks the downloaded installer size and both digests", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-update-test-"));
+ const path = join(directory, manifest.file.name);
+ try {
+ await writeFile(path, "payload");
+ await expect(verifyDownloadedWindowsUpdate({ path, manifest })).resolves.toBe(true);
+ await writeFile(path, "tampered");
+ await expect(verifyDownloadedWindowsUpdate({ path, manifest })).rejects.toThrow("size does not match");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/site/src/components/hero.astro b/apps/site/src/components/hero.astro
index 82a26d09..a462c163 100644
--- a/apps/site/src/components/hero.astro
+++ b/apps/site/src/components/hero.astro
@@ -91,6 +91,16 @@ const copy = siteCopy[locale].hero;
macOS
+
+
+ {copy.windowsPreview}
+
{locale === "en-US" ? "Clipper" : "剪藏插件"}
+
+
+ {copy.windowsPreviewWarning}
+
diff --git a/apps/site/src/i18n.ts b/apps/site/src/i18n.ts
index 054ce84e..512ee10b 100644
--- a/apps/site/src/i18n.ts
+++ b/apps/site/src/i18n.ts
@@ -28,7 +28,7 @@ export const siteCopy = {
"zh-CN": {
layout: {
defaultDescription:
- "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区。保留经典印象笔记三栏体验,覆盖 macOS、iOS、Android 与浏览器剪藏,支持在 Cloudflare 免费额度内运行或使用 Docker 一键自托管。",
+ "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区。保留经典印象笔记三栏体验,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器剪藏,支持在 Cloudflare 免费额度内运行或使用 Docker 一键自托管。",
defaultTitle: `EdgeEver - ${siteTaglines["zh-CN"]}`,
imageAlt: "EdgeEver 笔记应用截图",
ogLocale: "zh_CN",
@@ -60,8 +60,10 @@ export const siteCopy = {
popHighlight: "印象笔记开源经典平替 · 全平台客户端覆盖",
demo: "在线演示",
agentInstall: "一键 AI 部署",
+ windowsPreview: "Windows 预览版",
+ windowsPreviewWarning: "Windows x64 为未签名预览版,系统或组织策略可能警告或阻止安装。请仅从官方 GitHub Release 下载。",
imageAlt: "EdgeEver product preview",
- badgeText: "💡 全平台覆盖:macOS、iOS、Android、浏览器剪藏 · Cloudflare 免费额度 & Docker 一键安装",
+ badgeText: "💡 全平台覆盖:macOS、Windows 预览版、iOS、Android、浏览器剪藏 · Cloudflare 免费额度 & Docker 一键安装",
},
bento: {
eyebrow: "WHY EDGEEVER",
@@ -116,7 +118,7 @@ export const siteCopy = {
card5: {
badge: "全端主权",
title: "全平台客户端与无损 ZIP 归档",
- desc: "覆盖 macOS(Electron+Rust+SQLite)、原生 iOS(SwiftUI)、Android 及剪藏插件;基于标准 SQLite 与无损 ZIP 导出,数据完全自主掌控。",
+ desc: "覆盖 macOS、Windows x64 预览版、原生 iOS(SwiftUI)、Android 及剪藏插件;基于标准 SQLite 与无损 ZIP 导出,数据完全自主掌控。",
archiveTitle: "edgeever-backup.zip",
archiveSub: "包含 Markdown、Front Matter、附件与历史版本",
},
@@ -240,10 +242,11 @@ export const siteCopy = {
},
{
title: "全平台客户端与多端同步",
- summary: "覆盖 macOS、iOS、Android、浏览器剪藏插件与 Web/PWA,摆脱商业软件的设备数限制。",
+ summary: "覆盖 macOS、Windows x64 预览版、iOS、Android、浏览器剪藏插件与 Web/PWA,摆脱商业软件的设备数限制。",
points: [
"不限设备登录数:自建专属 API,彻底打碎商业笔记“免费版仅限 2 台设备”的枷锁。",
"macOS 桌面端:基于 Electron + Rust sidecar + SQLite,兼顾极致本地性能与离线编辑同步。",
+ "Windows x64 预览版:提供本地 SQLite 与自动更新;当前未使用 Authenticode 签名,安装时可能出现系统或组织策略警告。",
"移动原生端:iOS 原生 SwiftUI App(已上架 App Store)与 Android App(Google Play / APK),支持微信文章剪藏。",
"浏览器剪藏插件:Chrome、Microsoft Edge 与 Firefox 商店全上架,一键捕获网页全文与书签。",
],
@@ -289,7 +292,7 @@ export const siteCopy = {
"en-US": {
layout: {
defaultDescription:
- "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative. Enjoy a classic three-pane workspace across macOS, iOS, Android, and Web Clipper, running within Cloudflare's free tier or on Docker.",
+ "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative. Enjoy a classic three-pane workspace across macOS, Windows Preview, iOS, Android, and Web Clipper, running within Cloudflare's free tier or on Docker.",
defaultTitle: "Open-Source, Self-Hosted Evernote Alternative | EdgeEver",
imageAlt: "EdgeEver notes app screenshot",
ogLocale: "en_US",
@@ -321,8 +324,10 @@ export const siteCopy = {
popHighlight: "Open-Source Evernote Alternative · Apps Across Platforms",
demo: "Live demo",
agentInstall: "Deploy with AI",
+ windowsPreview: "Windows Preview",
+ windowsPreviewWarning: "Windows x64 is an unsigned Preview. Windows or organization policy may warn or block it; download only from the official GitHub Release.",
imageAlt: "EdgeEver product preview",
- badgeText: "💡 All Platforms: macOS, iOS, Android, Web Clipper · Cloudflare Free Tier & 1-Line Docker Deploy",
+ badgeText: "💡 All Platforms: macOS, Windows Preview, iOS, Android, Web Clipper · Cloudflare Free Tier & 1-Line Docker Deploy",
},
bento: {
eyebrow: "WHY EDGEEVER",
@@ -377,7 +382,7 @@ export const siteCopy = {
card5: {
badge: "Data Sovereignty",
title: "Apps Across Platforms & Lossless ZIP Backup",
- desc: "Available on macOS (Electron+Rust+SQLite), native iOS (SwiftUI), Android (Google Play/APK), and Web Clipper. Lossless ZIP export and standard SQLite provide full ownership.",
+ desc: "Available on macOS, Windows x64 Preview, native iOS (SwiftUI), Android (Google Play/APK), and Web Clipper. Lossless ZIP export and standard SQLite provide full ownership.",
archiveTitle: "edgeever-backup.zip",
archiveSub: "Markdown + Attachments + Revision History",
},
@@ -501,10 +506,11 @@ export const siteCopy = {
},
{
title: "Apps Across Platforms & Multi-Device Sync",
- summary: "Available across macOS, iOS, Android, browser Web Clipper, and Web/PWA, freeing you from commercial device caps.",
+ summary: "Available across macOS, Windows x64 Preview, iOS, Android, browser Web Clipper, and Web/PWA, freeing you from commercial device caps.",
points: [
"Unlimited Devices: Self-hosted API eliminates commercial restrictions like the '2-device login limit'.",
"macOS Desktop App: Electron + Rust sidecar + SQLite for blazing fast local performance and offline sync.",
+ "Windows x64 Preview: Local SQLite and automatic updates; the current build is not Authenticode-signed, so Windows or organization policy may warn or block installation.",
"Mobile Native Apps: Native SwiftUI iOS App (on App Store) and Android App (on Google Play & GitHub APK).",
"Web Clipper Extension: Official extensions on Chrome Web Store, Microsoft Edge, and Firefox Add-ons.",
],
diff --git a/apps/site/src/pages/en/index.astro b/apps/site/src/pages/en/index.astro
index be668b7f..1b545820 100644
--- a/apps/site/src/pages/en/index.astro
+++ b/apps/site/src/pages/en/index.astro
@@ -18,7 +18,7 @@ const homeJsonLd = [
url: siteUrl,
inLanguage: "en-US",
description:
- "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative across macOS, iOS, Android, Web Clipper with Cloudflare & Docker deployment.",
+ "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative across macOS, Windows Preview, iOS, Android, and Web Clipper with Cloudflare & Docker deployment.",
keywords: ["Evernote alternative", "open-source notes app", "self-hosted notes app", "AI notes app"],
},
{
@@ -29,10 +29,10 @@ const homeJsonLd = [
url: siteUrl,
codeRepository: "https://github.com/tianma-if/edgeever",
applicationCategory: "ProductivityApplication",
- operatingSystem: "macOS, iOS, Android, Web, Chrome, Edge, Firefox",
+ operatingSystem: "macOS, Windows, iOS, Android, Web, Chrome, Edge, Firefox",
isAccessibleForFree: true,
description:
- "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative with a classic three-pane workflow, apps across macOS, iOS, Android and browsers, plus a REST API, OpenAPI schema, and MCP endpoint.",
+ "EdgeEver is an open-source, AI-native, self-hosted Evernote alternative with a classic three-pane workflow, apps across macOS, Windows Preview, iOS, Android and browsers, plus a REST API, OpenAPI schema, and MCP endpoint.",
keywords: ["Evernote alternative", "open-source notes app", "self-hosted notes app", "AI notes app"],
},
];
diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro
index 58617c94..1a69fa8e 100644
--- a/apps/site/src/pages/index.astro
+++ b/apps/site/src/pages/index.astro
@@ -17,7 +17,7 @@ const homeJsonLd = [
url: siteUrl,
inLanguage: "zh-CN",
description:
- "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,支持 Cloudflare 与 Docker 自由部署,覆盖 macOS、iOS、Android 与浏览器插件。",
+ "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,支持 Cloudflare 与 Docker 自由部署,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器插件。",
},
{
"@context": "https://schema.org",
@@ -26,10 +26,10 @@ const homeJsonLd = [
url: siteUrl,
codeRepository: "https://github.com/tianma-if/edgeever",
applicationCategory: "ProductivityApplication",
- operatingSystem: "macOS, iOS, Android, Web, Chrome, Edge, Firefox",
+ operatingSystem: "macOS, Windows, iOS, Android, Web, Chrome, Edge, Firefox",
isAccessibleForFree: true,
description:
- "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,保留经典印象笔记三栏体验,覆盖 macOS、iOS、Android 与浏览器插件,并提供 REST API、OpenAPI schema 和 MCP endpoint。",
+ "EdgeEver 是开源、原生支持 AI 的自托管笔记工作区,保留经典印象笔记三栏体验,覆盖 macOS、Windows 预览版、iOS、Android 与浏览器插件,并提供 REST API、OpenAPI schema 和 MCP endpoint。",
},
];
---
diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx
index a925627d..fd9e6924 100644
--- a/apps/web/src/components/NotebookPane.tsx
+++ b/apps/web/src/components/NotebookPane.tsx
@@ -727,19 +727,27 @@ export const NotebookPane = ({
-
-
-
-
-
- {t("pwa.sidebarWindows") || "Windows"}
-
-
- {t("pwa.sidebarWindowsBadge") || "即将推出"}
-
+
+
+
+
+
+
+ {t("pwa.sidebarWindows") || "Windows"}
+
+
+
+ {t("pwa.sidebarWindowsBadge") || "Preview"}
+
+
+
+
>
)}
diff --git a/docs/release.md b/docs/release.md
index 616e6c32..26a4a0d4 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -4,6 +4,13 @@
Run from a clean `main` branch on macOS that matches `origin/main`:
+For any Release that rebuilds desktop assets, first point the release shell at
+the repository-external Ed25519 key used for the Windows update manifest:
+
+```bash
+export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem
+```
+
```bash
bun run release -- \
--bump patch \
@@ -47,8 +54,9 @@ previous installation check is actually needed.
change only when that native runtime is rebuilt. Android `versionCode` and
iOS build numbers remain independent, monotonically increasing identifiers.
- A formal Release contains macOS arm64 and x64 DMGs, architecture-specific
- updater ZIPs, and an Android arm64 APK. Unchanged native assets are reused
- with their original filenames, versions, and checksums.
+ updater ZIPs, an unsigned Windows x64 Preview installer with an independently
+ signed update manifest, and an Android arm64 APK. Unchanged native assets are
+ reused with their original filenames, versions, and checksums.
- Desktop and Android update checks use the version embedded in the applicable
Release asset rather than the overall GitHub tag. This prevents a Web-only or
API-only Release from prompting an unnecessary native update.
@@ -69,6 +77,11 @@ previous installation check is actually needed.
`bun run publish:stores -- --release vX.Y.Z --platform android --android-track production`
for that Draft, then rerun the original release command to resume. See
[Mobile Store Delivery](store-delivery.md).
+- After rebuilt desktop assets are uploaded to the Draft, the local release
+ command signs only `latest-windows.json`; the private key never enters GitHub
+ Actions. A second desktop workflow run downloads the Windows installer,
+ `latest.yml`, manifest, signature, and checksum file and independently audits
+ them before publication. See [Windows Preview security and updates](windows-preview.md).
## Registry Credentials
@@ -84,7 +97,8 @@ Independent builds are not required to have the same registry digest.
## Failure and Resume
-- Validation, Draft asset, or GHCR image failures leave the Release unpublished.
+- Validation, Windows signature/audit, Draft asset, or GHCR image failures leave
+ the Release unpublished.
- An asynchronous CNB/TCR build failure leaves the formal Release intact and is
repaired or rerun independently.
- Rerunning the same command resumes a matching Draft created by an interrupted
diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md
index 87a2493b..81152afc 100644
--- a/docs/release.zh-CN.md
+++ b/docs/release.zh-CN.md
@@ -4,6 +4,13 @@
在 macOS 上从与 `origin/main` 一致且工作区干净的 `main` 分支执行:
+任何需要重建桌面资产的 Release,都应先让发布 shell 指向仓库外保存的 Windows
+更新清单 Ed25519 私钥:
+
+```bash
+export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem
+```
+
```bash
bun run release -- \
--bump patch \
@@ -41,8 +48,9 @@ bun run release -- \
禁止因发版节奏把用户可感知的新能力或新平台压成 patch(详见 `AGENTS.md`)。
- 根版本表示整体产品 Release。只有对应原生运行时重建时,才更新原生展示版本。
Android `versionCode` 和 iOS Build Number 是相互独立且严格递增的标识。
-- 每个正式 Release 包含 macOS arm64 与 x64 DMG、按架构区分的更新 ZIP,以及
- Android arm64 APK。未变化的原生资产沿用原文件名、版本和校验和。
+- 每个正式 Release 包含 macOS arm64 与 x64 DMG、按架构区分的更新 ZIP、带独立
+ 签名更新清单的未签名 Windows x64 预览版安装包,以及 Android arm64 APK。
+ 未变化的原生资产沿用原文件名、版本和校验和。
- 桌面端和 Android 更新检查使用对应 Release 资产中记录的版本,而不是整体
GitHub Tag,避免仅涉及 Web 或 API 的 Release 触发无效原生更新。
- 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、准备并审计
@@ -58,6 +66,10 @@ bun run release -- \
`bun run publish:stores -- --release vX.Y.Z --platform android --android-track production`,
再重新执行原发布命令续跑。详见
[移动端商店交付](store-delivery.zh-CN.md)。
+- 重建后的桌面资产上传到 Draft 后,本地发布命令只签署
+ `latest-windows.json`,私钥不会进入 GitHub Actions。第二次桌面工作流会重新
+ 下载 Windows 安装包、`latest.yml`、清单、签名和校验和文件并独立审计,通过后
+ 才能公开发布。详见 [Windows 预览版安全与更新说明](windows-preview.zh-CN.md)。
## 镜像仓库凭据
@@ -70,7 +82,8 @@ GitHub 官方仓库必须配置 `CNB_TCR_BUILD_PUSH_TOKEN` Actions Secret,该
## 失败与续跑
-- 本地验证、Draft 资产或 GHCR 镜像失败时,Release 保持未发布状态。
+- 本地验证、Windows 签名/审计、Draft 资产或 GHCR 镜像失败时,Release 保持
+ 未发布状态。
- CNB/TCR 异步构建失败时保留正式 Release,并独立修复、重跑。
- 中断后重新执行相同命令,会续跑匹配的 Draft,不会重复创建 Issue、提交或
Release。
diff --git a/docs/windows-preview.md b/docs/windows-preview.md
new file mode 100644
index 00000000..0c2dfac9
--- /dev/null
+++ b/docs/windows-preview.md
@@ -0,0 +1,96 @@
+# Windows x64 Preview
+
+## Distribution status
+
+EdgeEver distributes a Windows x64 Preview from the official
+[GitHub Releases](https://github.com/tianma-if/edgeever/releases/latest) page.
+The current installer and packaged executables are not Authenticode-signed.
+Windows SmartScreen, antivirus software, or organization policy may therefore
+warn about or block the installer. This warning is expected for the Preview,
+but it is not proof that an arbitrary copy is safe.
+
+- Download only from the official `tianma-if/edgeever` Release.
+- Do not disable SmartScreen, antivirus software, or organization security
+ controls for EdgeEver.
+- If policy blocks the installer, use the Web/PWA client until an
+ Authenticode-signed build is available.
+
+## Automatic updates
+
+An unsigned installer does not prevent the NSIS updater from downloading and
+installing a later Release. EdgeEver adds an independent trust gate so that the
+update channel does not rely only on an unsigned `latest.yml` file:
+
+1. The client reads `latest.yml` to discover an update but does not begin the
+ Windows download yet.
+2. It fetches `latest-windows.json` and `latest-windows.json.sig` from that
+ exact version's official Release.
+3. It verifies the Ed25519 signature with a public key pinned in the packaged
+ client, then requires the version, filename, size, and SHA-512 digest to
+ match `latest.yml`.
+4. It downloads the installer automatically and validates its size, SHA-512,
+ and SHA-256 against the signed manifest.
+5. Only a verified installer becomes eligible for restart installation or
+ automatic installation when the user quits EdgeEver.
+
+Missing metadata, an unknown key, an invalid signature, a version mismatch, or
+a changed installer fails closed. The app never enables install-on-quit for a
+Windows package that has not passed the final local-file check.
+
+This protects the update decision and installer bytes. It does not remove the
+initial Windows reputation warning, provide publisher identity in Explorer,
+or bypass organization application-control policy; those require trusted
+Authenticode signing.
+
+## Release assets and offline signing
+
+Every formal Release carries this Windows set:
+
+- `EdgeEver--windows-x64.exe`
+- `latest.yml`
+- `latest-windows.json`
+- `latest-windows.json.sig`
+- `SHA256SUMS-windows.txt`
+
+GitHub Actions builds and verifies the unsigned package, then uploads the first
+four unsigned inputs except the signature to the Draft Release. The release
+command signs the exact manifest locally using the repository-external key
+identified by `EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY`. It then dispatches an
+independent GitHub Actions audit that downloads all five assets and verifies
+the signature plus installer digests before publication is allowed.
+
+The private key must be an Ed25519 PKCS#8 PEM file, must remain outside the
+repository, and must be backed up in a separate secure location. Configure the
+release shell with an absolute path:
+
+```bash
+export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem
+```
+
+The initial trust anchor uses key ID `edgeever-windows-update-2026-01`; its
+SPKI DER SHA-256 fingerprint is
+`ec12b4b5673a2e6ac3666d0cc90dd5c418f3650418cd2b91fa09cec969d50db9`.
+
+If the key is missing or does not match the public key pinned in the desktop
+client, the Release remains a Draft. Key rotation is a two-release process:
+first ship a client that trusts both old and new public keys while continuing
+to sign with the old key, then switch manifest signing to the new key in a
+later Release.
+
+## Future Authenticode migration
+
+When a trusted certificate becomes available:
+
+1. Sign every shipped PE executable, including `EdgeEver.exe`, the Rust
+ sidecar, helper executables, and the final NSIS installer.
+2. Configure the exact certificate subject as electron-builder's
+ `publisherName` so signed clients enforce publisher continuity.
+3. Generate `latest.yml`, the signed EdgeEver manifest, and checksums only
+ after all Authenticode signing has finished, because signing changes bytes.
+4. Keep the independent Ed25519 gate during and after migration. Existing
+ unsigned Preview clients can accept the first signed installer because they
+ do not claim an Authenticode publisher; newly signed clients then add the
+ publisher check for subsequent updates.
+
+The first public Windows platform Release is a user-visible platform addition
+and must use a SemVer minor bump, not a patch bump.
diff --git a/docs/windows-preview.zh-CN.md b/docs/windows-preview.zh-CN.md
new file mode 100644
index 00000000..0e9afa08
--- /dev/null
+++ b/docs/windows-preview.zh-CN.md
@@ -0,0 +1,80 @@
+# Windows x64 预览版
+
+## 分发状态
+
+EdgeEver 通过官方 [GitHub Releases](https://github.com/tianma-if/edgeever/releases/latest)
+页面分发 Windows x64 预览版。当前安装包与应用内可执行文件尚未使用
+Authenticode 签名,因此 Windows SmartScreen、杀毒软件或组织策略可能提示风险或
+直接阻止安装。这类提示是预览阶段的预期现象,但并不表示任意来源的副本都安全。
+
+- 仅从 `tianma-if/edgeever` 官方 Release 下载。
+- 不要为 EdgeEver 关闭 SmartScreen、杀毒软件或组织安全控制。
+- 如果组织策略阻止安装,请先使用 Web/PWA 客户端,等待后续
+ Authenticode 签名版本。
+
+## 自动更新
+
+安装包未签名并不会阻止 NSIS 更新器下载和安装后续 Release。EdgeEver 增加了独立
+信任门禁,避免更新通道只依赖同样未签名的 `latest.yml`:
+
+1. 客户端读取 `latest.yml` 发现新版本,但此时不会开始 Windows 下载。
+2. 客户端从该版本的官方 Release 获取 `latest-windows.json` 与
+ `latest-windows.json.sig`。
+3. 客户端使用安装包内固定的公钥验证 Ed25519 签名,并要求版本、文件名、大小与
+ SHA-512 和 `latest.yml` 完全一致。
+4. 客户端自动下载安装包,再用已签名清单核验其大小、SHA-512 与 SHA-256。
+5. 只有通过本地文件复核的安装包,才能在用户确认重启时安装,或在用户退出
+ EdgeEver 时自动安装。
+
+清单缺失、密钥未知、签名无效、版本不一致或安装包被修改时,更新一律停止。
+Windows 安装包未通过最终本地校验前,应用不会开启退出时自动安装。
+
+该机制保护更新决策和安装包字节,但不会消除首次安装时的 Windows 信誉提示、
+不会在资源管理器中提供发布者身份,也无法绕过组织的应用控制策略;这些能力仍需
+可信 Authenticode 签名。
+
+## Release 资产与离线签名
+
+每个正式 Release 包含以下 Windows 资产:
+
+- `EdgeEver--windows-x64.exe`
+- `latest.yml`
+- `latest-windows.json`
+- `latest-windows.json.sig`
+- `SHA256SUMS-windows.txt`
+
+GitHub Actions 构建并验证未签名安装包,随后把除签名文件以外的四项输入上传到
+Draft Release。发布命令使用 `EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY` 指向的
+仓库外密钥,在本机为精确清单离线签名;然后触发独立 GitHub Actions 审计,重新
+下载全部五项资产并验证签名及安装包摘要,全部通过后才允许公开发布。
+
+私钥必须是 Ed25519 PKCS#8 PEM 文件,必须保存在仓库外,并在另一个安全位置留有
+备份。发布 shell 使用绝对路径配置:
+
+```bash
+export EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY=/absolute/path/to/windows-update-ed25519-private.pem
+```
+
+首个信任锚的密钥 ID 为 `edgeever-windows-update-2026-01`,SPKI DER SHA-256
+指纹为 `ec12b4b5673a2e6ac3666d0cc90dd5c418f3650418cd2b91fa09cec969d50db9`。
+
+私钥缺失或与桌面客户端固定的公钥不匹配时,Release 会保持 Draft。密钥轮换必须
+跨两个 Release:先发布同时信任新旧公钥、但仍由旧密钥签名的客户端;后续 Release
+再切换到新密钥签名。
+
+## 未来迁移到 Authenticode
+
+获得可信证书后:
+
+1. 签署所有随包发布的 PE,包括 `EdgeEver.exe`、Rust sidecar、辅助可执行文件和
+ 最终 NSIS 安装包。
+2. 把证书精确主题配置为 electron-builder 的 `publisherName`,让已签名客户端
+ 强制校验发布者连续性。
+3. 必须在全部 Authenticode 签名完成后再生成 `latest.yml`、EdgeEver 签名清单与
+ 校验和,因为签名会改变文件字节。
+4. 迁移期间及迁移后继续保留独立 Ed25519 门禁。现有未签名预览版没有声明
+ Authenticode 发布者,因此可以接收首个已签名安装包;新签名客户端随后会对后续
+ 更新增加发布者校验。
+
+首次公开 Windows 平台属于用户可感知的新平台,必须按 SemVer 使用 minor 递增,
+不能作为 patch 发布。
diff --git a/package.json b/package.json
index 29f993de..7d1320ba 100644
--- a/package.json
+++ b/package.json
@@ -54,11 +54,11 @@
"build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist",
"benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs",
"verify:web-performance": "bun scripts/verify-web-performance.mjs",
- "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
+ "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
- "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
+ "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:worker": "bun scripts/build-cloudflare-worker.mjs",
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index c7cf3281..d50e9938 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -1688,8 +1688,8 @@ export const enUS = {
sidebarGroupClippers: "Browser Web Clipper",
sidebarMac: "macOS",
sidebarWindows: "Windows",
- sidebarWindowsBadge: "Coming soon",
- sidebarWindowsAvailability: "Coming soon — stay tuned",
+ sidebarWindowsBadge: "Preview",
+ sidebarWindowsAvailability: "Unsigned Preview — Windows or your organization may warn or block it. Download only from the official GitHub Release.",
sidebarOtherDownloads: "Other client downloads",
sidebarAndroid: "Android",
sidebarAndroidGooglePlay: "Google Play",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 43bcbe11..e755a357 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -1686,8 +1686,8 @@ export const zhCN = {
sidebarGroupClippers: "浏览器剪藏插件",
sidebarMac: "macOS",
sidebarWindows: "Windows",
- sidebarWindowsBadge: "即将推出",
- sidebarWindowsAvailability: "即将推出,敬请期待",
+ sidebarWindowsBadge: "预览版",
+ sidebarWindowsAvailability: "未签名预览版——Windows 或组织策略可能警告或阻止安装。请仅从官方 GitHub Release 下载。",
sidebarOtherDownloads: "其他客户端下载",
sidebarAndroid: "Android",
sidebarAndroidGooglePlay: "Google Play",
diff --git a/scripts/check-native-release-assets.mjs b/scripts/check-native-release-assets.mjs
index 5b940346..385e0d10 100644
--- a/scripts/check-native-release-assets.mjs
+++ b/scripts/check-native-release-assets.mjs
@@ -8,6 +8,7 @@ export const nativeReleaseAssetsReady = ({
currentTag,
desktopVersion,
assetNames,
+ requireWindowsSignature = true,
}) => {
if (platform === "mobile") {
const apkNames = assetNames.filter((name) =>
@@ -47,6 +48,29 @@ export const nativeReleaseAssetsReady = ({
}
}
}
+ const windowsInstallerNames = assetNames.filter((name) =>
+ /^EdgeEver-(.+)-windows-x64\.exe$/.test(name)
+ );
+ if (windowsInstallerNames.length !== 1) return false;
+ const windowsVersion = /^EdgeEver-(.+)-windows-x64\.exe$/
+ .exec(windowsInstallerNames[0])?.[1];
+ if (!windowsVersion) return false;
+ versions.add(windowsVersion);
+ for (const name of [
+ "latest.yml",
+ "latest-windows.json",
+ "SHA256SUMS-windows.txt",
+ ]) {
+ if (assetNames.filter((assetName) => assetName === name).length !== 1) {
+ return false;
+ }
+ }
+ const signatureCount = assetNames.filter(
+ (name) => name === "latest-windows.json.sig",
+ ).length;
+ if (requireWindowsSignature ? signatureCount !== 1 : signatureCount > 1) {
+ return false;
+ }
return (
versions.size === 1 &&
(!rebuild || versions.has(desktopVersion)) &&
@@ -58,15 +82,16 @@ export const nativeReleaseAssetsReady = ({
};
const run = () => {
- const [platform, rebuildValue, currentTag, desktopVersion = ""] =
+ const [platform, rebuildValue, currentTag, desktopVersion = "", signatureMode = "required"] =
process.argv.slice(2);
if (
!["mobile", "desktop"].includes(platform) ||
!["true", "false"].includes(rebuildValue) ||
- !currentTag
+ !currentTag ||
+ !["required", "allow-missing-windows-signature"].includes(signatureMode)
) {
console.error(
- "Usage: node scripts/check-native-release-assets.mjs [desktop-version]",
+ "Usage: node scripts/check-native-release-assets.mjs [desktop-version] [required|allow-missing-windows-signature]",
);
process.exit(1);
}
@@ -80,6 +105,7 @@ const run = () => {
currentTag,
desktopVersion,
assetNames,
+ requireWindowsSignature: signatureMode === "required",
}),
),
);
diff --git a/scripts/check-native-release-assets.test.mjs b/scripts/check-native-release-assets.test.mjs
index 5257f9e3..2d065ed9 100644
--- a/scripts/check-native-release-assets.test.mjs
+++ b/scripts/check-native-release-assets.test.mjs
@@ -2,6 +2,14 @@ import { describe, expect, test } from "bun:test";
import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs";
describe("native release asset readiness", () => {
+ const windowsAssets = (version) => [
+ `EdgeEver-${version}-windows-x64.exe`,
+ "latest.yml",
+ "latest-windows.json",
+ "latest-windows.json.sig",
+ "SHA256SUMS-windows.txt",
+ ];
+
test("accepts one reused Android APK but requires the current name after a rebuild", () => {
const reused = ["edgeever-android-v1.6.30-arm64-v8a.apk"];
expect(
@@ -53,6 +61,7 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.33-mac-x64.zip",
"EdgeEver-1.6.33-mac-x64.zip.blockmap",
"latest-mac.yml",
+ ...windowsAssets("1.6.33"),
],
}),
).toBe(true);
@@ -69,6 +78,7 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.35-mac-x64.zip",
"EdgeEver-1.6.35-mac-x64.zip.blockmap",
"latest-mac.yml",
+ ...windowsAssets("1.6.35"),
];
expect(
nativeReleaseAssetsReady({
@@ -103,8 +113,39 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.33-mac-arm64.zip",
"EdgeEver-1.6.33-mac-arm64.zip.blockmap",
"latest-mac.yml",
+ ...windowsAssets("1.6.33"),
],
}),
).toBe(false);
});
+
+ test("allows the CI staging audit to omit only the offline signature", () => {
+ const assets = [
+ "EdgeEver-1.6.35-mac-arm64.dmg",
+ "EdgeEver-1.6.35-mac-arm64.dmg.blockmap",
+ "EdgeEver-1.6.35-mac-arm64.zip",
+ "EdgeEver-1.6.35-mac-arm64.zip.blockmap",
+ "EdgeEver-1.6.35-mac-x64.dmg",
+ "EdgeEver-1.6.35-mac-x64.dmg.blockmap",
+ "EdgeEver-1.6.35-mac-x64.zip",
+ "EdgeEver-1.6.35-mac-x64.zip.blockmap",
+ "latest-mac.yml",
+ ...windowsAssets("1.6.35").filter((name) => name !== "latest-windows.json.sig"),
+ ];
+ expect(nativeReleaseAssetsReady({
+ platform: "desktop",
+ rebuild: true,
+ currentTag: "v1.6.35",
+ desktopVersion: "1.6.35",
+ assetNames: assets,
+ requireWindowsSignature: false,
+ })).toBe(true);
+ expect(nativeReleaseAssetsReady({
+ platform: "desktop",
+ rebuild: true,
+ currentTag: "v1.6.35",
+ desktopVersion: "1.6.35",
+ assetNames: assets,
+ })).toBe(false);
+ });
});
diff --git a/scripts/create-windows-update-metadata.mjs b/scripts/create-windows-update-metadata.mjs
new file mode 100644
index 00000000..60a42610
--- /dev/null
+++ b/scripts/create-windows-update-metadata.mjs
@@ -0,0 +1,87 @@
+import { createHash } from "node:crypto";
+import { createReadStream, readFileSync, statSync, writeFileSync } from "node:fs";
+import { basename, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ WINDOWS_UPDATE_KEY_ID,
+ WINDOWS_UPDATE_MANIFEST_NAME,
+} from "../apps/desktop/src/main/windows-update-trust.mjs";
+
+const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
+
+const hashFile = (path, algorithm, encoding) => new Promise((resolveHash, rejectHash) => {
+ const hash = createHash(algorithm);
+ const stream = createReadStream(path);
+ stream.on("error", rejectHash);
+ stream.on("data", (chunk) => hash.update(chunk));
+ stream.on("end", () => resolveHash(hash.digest(encoding)));
+});
+
+export const createWindowsUpdateMetadata = async ({ directory, version }) => {
+ if (!STABLE_VERSION.test(version)) {
+ throw new Error(`Windows update version must be stable X.Y.Z: ${version}`);
+ }
+ const installerName = `EdgeEver-${version}-windows-x64.exe`;
+ const installerPath = join(directory, installerName);
+ const stats = statSync(installerPath);
+ if (!stats.isFile() || stats.size <= 0) {
+ throw new Error(`Windows installer is missing or empty: ${installerPath}`);
+ }
+ const [sha512, sha256] = await Promise.all([
+ hashFile(installerPath, "sha512", "base64"),
+ hashFile(installerPath, "sha256", "hex"),
+ ]);
+ const releaseDate = new Date().toISOString();
+ const manifest = {
+ schemaVersion: 1,
+ keyId: WINDOWS_UPDATE_KEY_ID,
+ version,
+ platform: "win32",
+ arch: "x64",
+ releaseDate,
+ file: {
+ name: installerName,
+ size: stats.size,
+ sha512,
+ sha256,
+ },
+ };
+ writeFileSync(
+ join(directory, WINDOWS_UPDATE_MANIFEST_NAME),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ );
+ writeFileSync(join(directory, "latest.yml"), [
+ `version: ${version}`,
+ "files:",
+ ` - url: ${installerName}`,
+ ` sha512: ${sha512}`,
+ ` size: ${stats.size}`,
+ `path: ${installerName}`,
+ `sha512: ${sha512}`,
+ `releaseDate: '${releaseDate}'`,
+ "",
+ ].join("\n"));
+ writeFileSync(
+ join(directory, "SHA256SUMS-windows.txt"),
+ `${sha256} ${installerName}\n`,
+ );
+ return manifest;
+};
+
+const run = async () => {
+ const [directoryValue, version] = process.argv.slice(2);
+ if (!directoryValue || !version) {
+ throw new Error("Usage: node scripts/create-windows-update-metadata.mjs ");
+ }
+ const directory = resolve(directoryValue);
+ const packageVersion = JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version;
+ if (version !== packageVersion) {
+ throw new Error(`Requested version ${version} does not match desktop package version ${packageVersion}`);
+ }
+ const manifest = await createWindowsUpdateMetadata({ directory, version });
+ process.stdout.write(`${basename(directory)}: ${manifest.file.name}\n`);
+};
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ await run();
+}
diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs
index 2b3a3d67..497318d3 100644
--- a/scripts/desktop-build-workflow.test.mjs
+++ b/scripts/desktop-build-workflow.test.mjs
@@ -61,7 +61,7 @@ describe("desktop release workflow", () => {
});
test("reports timings after builds without instrumenting native build steps", () => {
- expect(workflow).toContain("name: Report macOS build timings");
+ expect(workflow).toContain("name: Report desktop build timings");
expect(workflow).toContain("--platform desktop");
expect(workflow).toContain("name: edgeever-desktop-build-timings");
expect(mobileWorkflow).toContain("name: Report Android build timings");
@@ -69,4 +69,14 @@ describe("desktop release workflow", () => {
expect(mobileWorkflow).toContain("name: edgeever-android-build-timings");
expect(mobileWorkflow).not.toContain("Build signed release APK for GitHub Release\n run: time");
});
+
+ test("builds an unsigned Windows x64 Preview and audits its signed update metadata", () => {
+ expect(workflow).toContain("name: Windows x64 unsigned Preview");
+ expect(workflow).toContain("EDGE_EVER_DESKTOP_TARGET: win");
+ expect(workflow).toContain("Get-AuthenticodeSignature");
+ expect(workflow).toContain("create-windows-update-metadata.mjs");
+ expect(workflow).toContain("allow-missing-windows-signature");
+ expect(workflow).toContain("name: Audit signed Windows update");
+ expect(workflow).toContain("verify-windows-update-release.mjs");
+ });
});
diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs
index 61fd3b63..0f6f14aa 100644
--- a/scripts/plan-native-release.mjs
+++ b/scripts/plan-native-release.mjs
@@ -51,9 +51,12 @@ export const planNativeRelease = (platform, changedFiles) => {
".github/workflows/desktop-build.yml",
"bun.lock",
"scripts/create-mac-update-metadata.mjs",
+ "scripts/create-windows-update-metadata.mjs",
"scripts/desktop-icns.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/run-desktop-builder.mjs",
+ "scripts/sign-windows-update-manifest.mjs",
+ "scripts/verify-windows-update-release.mjs",
"scripts/verify-desktop-package.mjs",
]);
diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs
index d273bc79..09b875f7 100644
--- a/scripts/plan-native-release.test.mjs
+++ b/scripts/plan-native-release.test.mjs
@@ -71,9 +71,12 @@ describe("native release planning", () => {
const changedFiles = [
".github/workflows/desktop-build.yml",
"scripts/create-mac-update-metadata.mjs",
+ "scripts/create-windows-update-metadata.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/desktop-icns.mjs",
"scripts/run-desktop-builder.mjs",
+ "scripts/sign-windows-update-manifest.mjs",
+ "scripts/verify-windows-update-release.mjs",
];
expect(planNativeRelease("desktop", changedFiles)).toEqual({
rebuild: true,
diff --git a/scripts/release.mjs b/scripts/release.mjs
index 925833a0..444bf74c 100644
--- a/scripts/release.mjs
+++ b/scripts/release.mjs
@@ -16,6 +16,10 @@ import { join, resolve, sep } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs";
import { planNativeRelease } from "./plan-native-release.mjs";
+import {
+ assertWindowsUpdateSigningKey,
+ signWindowsUpdateManifest,
+} from "./sign-windows-update-manifest.mjs";
const DEFAULT_REPOSITORY = "tianma-if/edgeever";
const VERSION_BUMPS = new Set(["patch", "minor", "major"]);
@@ -42,6 +46,7 @@ export const RELEASE_VALIDATIONS = [
"test",
"scripts/plan-native-release.test.mjs",
"scripts/check-native-release-assets.test.mjs",
+ "scripts/windows-update-metadata.test.mjs",
"scripts/release.test.mjs",
"scripts/validate-store-delivery.test.mjs",
"scripts/store-delivery.test.mjs",
@@ -360,7 +365,7 @@ export const buildIssueBody = ({ changesEn, changesZh, commitCoverageAudit }) =>
"## Acceptance criteria",
"",
"- Required type checks, Web build, and native release planning tests pass.",
- "- The Draft Release contains audited macOS arm64 and x64 DMGs and a Play-signed Android arm64 APK.",
+ "- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, and a Play-signed Android arm64 APK.",
"- Post-publication native asset audits pass.",
].join("\n");
@@ -1010,10 +1015,17 @@ const assertDraftAssets = ({
.map((asset) => asset.name)
.filter((name) =>
/^EdgeEver-.*-mac-(?:arm64|x64)\.(?:dmg|zip)(?:\.blockmap)?$/.test(name) ||
- name === "latest-mac.yml"
+ /^EdgeEver-.*-windows-x64\.exe$/.test(name) ||
+ [
+ "latest-mac.yml",
+ "latest.yml",
+ "latest-windows.json",
+ "latest-windows.json.sig",
+ "SHA256SUMS-windows.txt",
+ ].includes(name)
);
if (
- previousDesktopNames.length !== 9 ||
+ previousDesktopNames.length !== 14 ||
!previousDesktopNames.every((name) =>
reusedAssetMatches(previousAssets, assets, name)
)
@@ -1023,6 +1035,44 @@ const assertDraftAssets = ({
}
};
+const signDraftWindowsUpdate = ({ repository, tag }) => {
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), "edgeever-windows-update-signing-"));
+ const manifestPath = join(temporaryDirectory, "latest-windows.json");
+ const signaturePath = `${manifestPath}.sig`;
+ try {
+ run("gh", [
+ "release",
+ "download",
+ tag,
+ "--repo",
+ repository,
+ "--pattern",
+ "latest-windows.json",
+ "--dir",
+ temporaryDirectory,
+ ]);
+ signWindowsUpdateManifest({
+ manifestPath,
+ signaturePath,
+ privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
+ });
+ run("gh", [
+ "release",
+ "upload",
+ tag,
+ signaturePath,
+ "--repo",
+ repository,
+ "--clobber",
+ ]);
+ console.log(`[release] signed Windows update manifest for ${tag}`);
+ } finally {
+ if (temporaryDirectory.startsWith(`${tmpdir()}${sep}edgeever-windows-update-signing-`)) {
+ rmSync(temporaryDirectory, { recursive: true, force: true });
+ }
+ }
+};
+
const sha256File = (path) => new Promise((resolveHash, rejectHash) => {
const hash = createHash("sha256");
const stream = createReadStream(path);
@@ -1221,6 +1271,13 @@ const releaseMain = async (options) => {
return;
}
+ if (desktopPlan.rebuild) {
+ assertWindowsUpdateSigningKey({
+ privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
+ });
+ console.log("[release] Windows update signing key matches the pinned desktop trust anchor");
+ }
+
let issueNumber;
let releaseSha;
if (resumedDraft) {
@@ -1372,6 +1429,23 @@ const releaseMain = async (options) => {
androidReleaseReady,
]);
+ if (desktopPlan.rebuild) {
+ signDraftWindowsUpdate({ repository: options.repository, tag });
+ }
+ const windowsUpdateAuditRunId = await dispatchReleaseWorkflow({
+ repository: options.repository,
+ workflow: RELEASE_WORKFLOWS.desktop,
+ tag,
+ headSha: releaseSha,
+ });
+ checkpoint.windowsUpdateAuditRunId = windowsUpdateAuditRunId;
+ persistCheckpoint();
+ await waitForRun({
+ repository: options.repository,
+ runId: windowsUpdateAuditRunId,
+ label: "Draft signed Windows update audit",
+ });
+
const draft = ghJson([
"release",
"view",
diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs
index 3a0aa4c8..e8cf3982 100644
--- a/scripts/release.test.mjs
+++ b/scripts/release.test.mjs
@@ -44,6 +44,25 @@ describe("release automation", () => {
expect(publication).toBeGreaterThan(signatureGate);
});
+ test("blocks publication until the offline-signed Windows update passes an independent audit", () => {
+ const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
+ const signing = releaseSource.indexOf("signDraftWindowsUpdate({");
+ const audit = releaseSource.indexOf('label: "Draft signed Windows update audit"');
+ const publication = releaseSource.indexOf('"--draft=false"');
+ expect(signing).toBeGreaterThanOrEqual(0);
+ expect(audit).toBeGreaterThan(signing);
+ expect(publication).toBeGreaterThan(audit);
+ });
+
+ test("checks the offline Windows key before creating release state", () => {
+ const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
+ const releaseMain = releaseSource.indexOf("const releaseMain = async");
+ const keyCheck = releaseSource.indexOf("assertWindowsUpdateSigningKey({", releaseMain);
+ const issueCreation = releaseSource.indexOf('"issue",\n "create"', releaseMain);
+ expect(keyCheck).toBeGreaterThan(releaseMain);
+ expect(issueCreation).toBeGreaterThan(keyCheck);
+ });
+
test("starts Play delivery as soon as Android preparation finishes", () => {
const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
const androidReady = releaseSource.lastIndexOf("const androidReleaseReady");
@@ -337,6 +356,7 @@ describe("release automation", () => {
expect(body).toContain("- 并行检查。");
expect(body).toContain("## Commit coverage audit");
expect(body).toContain("Play-signed Android arm64 APK");
+ expect(body).toContain("unsigned Windows x64 Preview");
expect(body).toContain("- Change 1: `aaaaaaaa`");
expect(body).toContain("- Excluded `bbbbbbbb`: test-only coverage");
});
diff --git a/scripts/report-native-build-timings.mjs b/scripts/report-native-build-timings.mjs
index d4858028..dd9a5d51 100644
--- a/scripts/report-native-build-timings.mjs
+++ b/scripts/report-native-build-timings.mjs
@@ -12,6 +12,11 @@ const TARGETS = {
jobName: "macOS x64",
packageSteps: ["Package desktop installer"],
},
+ {
+ name: "Windows x64 Preview",
+ jobName: "Windows x64 unsigned Preview",
+ packageSteps: ["Package unsigned Windows installer"],
+ },
],
android: [
{
diff --git a/scripts/report-native-build-timings.test.mjs b/scripts/report-native-build-timings.test.mjs
index 55893730..d796a6b9 100644
--- a/scripts/report-native-build-timings.test.mjs
+++ b/scripts/report-native-build-timings.test.mjs
@@ -22,7 +22,11 @@ function job(name, startedAt, completedAt, packageDurationSeconds) {
completed_at: packageStart,
},
{
- name: name.startsWith("macOS") ? "Package desktop installer" : "Build signed release APK for GitHub Release",
+ name: name.startsWith("macOS")
+ ? "Package desktop installer"
+ : name.startsWith("Windows")
+ ? "Package unsigned Windows installer"
+ : "Build signed release APK for GitHub Release",
conclusion: "success",
started_at: packageStart,
completed_at: packageEnd,
@@ -38,18 +42,24 @@ describe("native build timing reports", () => {
expect(formatDuration(123_000)).toBe("2m 03s");
});
- test("reports both desktop architectures from Actions timestamps", () => {
+ test("reports both macOS architectures and Windows x64 from Actions timestamps", () => {
const report = createTimingReport({
jobs: [
job("macOS arm64", "2026-08-13T00:00:00Z", "2026-08-13T00:06:00Z", 180),
job("macOS x64", "2026-08-13T00:00:00Z", "2026-08-13T00:09:00Z", 360),
+ job("Windows x64 unsigned Preview", "2026-08-13T00:00:00Z", "2026-08-13T00:07:00Z", 240),
],
}, "desktop", { runId: "123" });
expect(report.runId).toBe("123");
- expect(report.targets.map((target) => target.name)).toEqual(["macOS arm64", "macOS x64"]);
+ expect(report.targets.map((target) => target.name)).toEqual([
+ "macOS arm64",
+ "macOS x64",
+ "Windows x64 Preview",
+ ]);
expect(report.targets[1].packageDurationMs).toBe(360_000);
expect(renderTimingMarkdown(report)).toContain("| macOS x64 | success | 9m 00s | 1m 00s | 6m 00s | 2m 00s |");
+ expect(renderTimingMarkdown(report)).toContain("| Windows x64 Preview | success | 7m 00s | 1m 00s | 4m 00s | 2m 00s |");
});
test("selects the release Android package step", () => {
diff --git a/scripts/report-release-timings.mjs b/scripts/report-release-timings.mjs
index 07e1df98..09d83379 100644
--- a/scripts/report-release-timings.mjs
+++ b/scripts/report-release-timings.mjs
@@ -43,28 +43,32 @@ function componentRow({ target, mode, candidate, duration, detail, url }) {
function nativeRows(payload, platform, requestedMode) {
if (platform === "desktop") {
- const architectureJobs = ["macOS arm64", "macOS x64"]
- .map((name) => findJob(payload, name))
- .filter(Boolean);
+ const architectureJobs = [
+ { name: "macOS arm64", packageStep: "Package desktop installer", detail: "package + notarize" },
+ { name: "macOS x64", packageStep: "Package desktop installer", detail: "package + notarize" },
+ { name: "Windows x64 unsigned Preview", packageStep: "Package unsigned Windows installer", detail: "package" },
+ ]
+ .map((target) => ({ target, job: findJob(payload, target.name) }))
+ .filter(({ job }) => Boolean(job));
if (architectureJobs.length > 0) {
- return architectureJobs.map((job) => {
- const packageStep = findStep(job, "Package desktop installer");
+ return architectureJobs.map(({ target, job }) => {
+ const packageStep = findStep(job, target.packageStep);
return componentRow({
- target: job.name,
+ target: target.name === "Windows x64 unsigned Preview" ? "Windows x64 Preview" : target.name,
mode: "rebuild",
candidate: job,
duration: jobDurationMs(job),
- detail: `package + notarize ${formatDuration(stepDurationMs(packageStep))}`,
+ detail: `${target.detail} ${formatDuration(stepDurationMs(packageStep))}`,
url: job.html_url ?? payload.run.html_url,
});
});
}
- const reuseJob = findJob(payload, "Reuse macOS release assets");
+ const reuseJob = findJob(payload, "Reuse desktop release assets");
if (reuseJob) {
return [
componentRow({
- target: "macOS arm64 + x64",
+ target: "macOS arm64 + x64 + Windows x64",
mode: "reuse",
candidate: reuseJob,
duration: jobDurationMs(reuseJob),
@@ -77,7 +81,7 @@ function nativeRows(payload, platform, requestedMode) {
const planJob = findJob(payload, "Plan desktop release asset");
return [
componentRow({
- target: "macOS arm64 + x64",
+ target: "macOS arm64 + x64 + Windows x64",
mode: "already prepared",
candidate: planJob ?? payload.run,
duration: jobDurationMs(planJob) ?? runDurationMs(payload.run),
diff --git a/scripts/report-release-timings.test.mjs b/scripts/report-release-timings.test.mjs
index 02470527..176876dd 100644
--- a/scripts/report-release-timings.test.mjs
+++ b/scripts/report-release-timings.test.mjs
@@ -44,6 +44,9 @@ describe("release timing report", () => {
job("macOS x64", start, 600, [
step("Package desktop installer", start, 300),
]),
+ job("Windows x64 unsigned Preview", start, 420, [
+ step("Package unsigned Windows installer", start, 240),
+ ]),
]),
desktopMode: "rebuild",
mobile: payload("Mobile", start, 540, [
@@ -83,6 +86,7 @@ describe("release timing report", () => {
"Docker TCR (amd64 + arm64)",
"macOS arm64",
"macOS x64",
+ "Windows x64 Preview",
"Android arm64",
"Google Play signed APK",
]);
@@ -96,6 +100,9 @@ describe("release timing report", () => {
expect(markdown).toContain(
"| macOS x64 | rebuild | success | 10m 00s | package + notarize 5m 00s |",
);
+ expect(markdown).toContain(
+ "| Windows x64 Preview | rebuild | success | 7m 00s | package 4m 00s |",
+ );
expect(markdown).toContain(
"| Google Play signed APK | build + deliver | success | 11m 00s | AAB build 3m 00s; Play upload 4m 00s |",
);
@@ -120,7 +127,7 @@ describe("release timing report", () => {
const report = createReleaseTimingReport({
...common,
desktop: payload("Desktop", start, 40, [
- job("Reuse macOS release assets", start, 40),
+ job("Reuse desktop release assets", start, 40),
]),
desktopMode: "reuse",
mobile: payload("Mobile", start, 30, [
@@ -130,7 +137,7 @@ describe("release timing report", () => {
});
expect(report.rows.at(-2)).toMatchObject({
- target: "macOS arm64 + x64",
+ target: "macOS arm64 + x64 + Windows x64",
mode: "reuse",
durationMs: 40_000,
});
@@ -146,7 +153,7 @@ describe("release timing report", () => {
const report = createReleaseTimingReport({
release: { tag: "v1.41.2", sha: "ghi", publishedAt: start },
desktop: payload("Desktop", start, 40, [
- job("Reuse macOS release assets", start, 40),
+ job("Reuse desktop release assets", start, 40),
]),
desktopMode: "reuse",
mobile: payload("Mobile", start, 30, [
diff --git a/scripts/run-desktop-builder.mjs b/scripts/run-desktop-builder.mjs
index 650dc17b..46201999 100644
--- a/scripts/run-desktop-builder.mjs
+++ b/scripts/run-desktop-builder.mjs
@@ -4,10 +4,16 @@ const publishIndex = process.argv.indexOf("--publish");
const publishMode = publishIndex >= 0 ? process.argv[publishIndex + 1] ?? "never" : "never";
const environment = { ...process.env };
const requestedArch = environment.EDGE_EVER_DESKTOP_ARCH;
+const requestedTarget = environment.EDGE_EVER_DESKTOP_TARGET || (
+ process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : "linux"
+);
if (requestedArch && !["arm64", "x64"].includes(requestedArch)) {
throw new Error(`EDGE_EVER_DESKTOP_ARCH must be arm64 or x64, received: ${requestedArch}`);
}
+if (!["mac", "win", "linux"].includes(requestedTarget)) {
+ throw new Error(`EDGE_EVER_DESKTOP_TARGET must be mac, win, or linux, received: ${requestedTarget}`);
+}
// electron-builder treats an explicitly present empty CSC_LINK/WIN_CSC_LINK
// as a path. Remove empty optional signing variables so unsigned CI builds
@@ -35,7 +41,7 @@ const builderArgs = [
publishMode,
];
if (requestedArch) {
- builderArgs.push("--mac", `--${requestedArch}`);
+ builderArgs.push(`--${requestedTarget}`, `--${requestedArch}`);
}
const result = spawnSync(process.execPath, builderArgs, {
diff --git a/scripts/sign-windows-update-manifest.mjs b/scripts/sign-windows-update-manifest.mjs
new file mode 100644
index 00000000..a48a1253
--- /dev/null
+++ b/scripts/sign-windows-update-manifest.mjs
@@ -0,0 +1,91 @@
+import { createPrivateKey, createPublicKey, sign } from "node:crypto";
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ WINDOWS_UPDATE_KEY_ID,
+ WINDOWS_UPDATE_PUBLIC_KEY_PEM,
+ WINDOWS_UPDATE_SIGNATURE_NAME,
+ verifyWindowsUpdateMetadata,
+} from "../apps/desktop/src/main/windows-update-trust.mjs";
+
+const normalizedPublicKey = (key) => (key?.type === "public" ? key : createPublicKey(key)).export({
+ type: "spki",
+ format: "pem",
+}).trim();
+
+export const assertWindowsUpdateSigningKey = ({
+ privateKeyPath,
+ expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM,
+}) => {
+ if (!privateKeyPath) {
+ throw new Error("EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY must point to the offline Ed25519 private key");
+ }
+ const privateKey = createPrivateKey(readFileSync(privateKeyPath, "utf8"));
+ if (privateKey.asymmetricKeyType !== "ed25519") {
+ throw new Error("Windows update signing key must be Ed25519");
+ }
+ const derivedPublicKey = normalizedPublicKey(createPublicKey(privateKey));
+ if (derivedPublicKey !== normalizedPublicKey(expectedPublicKey)) {
+ throw new Error("Windows update signing key does not match the public key pinned in the desktop client");
+ }
+ return privateKey;
+};
+
+export const signWindowsUpdateManifest = ({
+ manifestPath,
+ signaturePath,
+ privateKeyPath,
+ expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM,
+}) => {
+ const privateKey = assertWindowsUpdateSigningKey({ privateKeyPath, expectedPublicKey });
+ const manifestBytes = readFileSync(manifestPath);
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
+ if (manifest?.schemaVersion !== 1 || manifest.keyId !== WINDOWS_UPDATE_KEY_ID) {
+ throw new Error("Windows update manifest key metadata is invalid");
+ }
+ const envelope = {
+ schemaVersion: 1,
+ keyId: WINDOWS_UPDATE_KEY_ID,
+ signature: sign(null, manifestBytes, privateKey).toString("base64"),
+ };
+ const signatureBytes = Buffer.from(`${JSON.stringify(envelope)}\n`);
+ verifyWindowsUpdateMetadata({
+ manifestBytes,
+ signatureBytes,
+ expectedVersion: manifest.version,
+ updateInfo: {
+ version: manifest.version,
+ files: [{
+ url: manifest.file?.name,
+ size: manifest.file?.size,
+ sha512: manifest.file?.sha512,
+ }],
+ },
+ trustedPublicKeys: { [WINDOWS_UPDATE_KEY_ID]: expectedPublicKey },
+ });
+ writeFileSync(signaturePath, signatureBytes, { mode: 0o644 });
+ return envelope;
+};
+
+const run = () => {
+ const [manifestValue, signatureValue] = process.argv.slice(2);
+ if (!manifestValue) {
+ throw new Error("Usage: node scripts/sign-windows-update-manifest.mjs [signature]");
+ }
+ const manifestPath = resolve(manifestValue);
+ const signaturePath = resolve(signatureValue || `${manifestPath}.sig`);
+ if (!signaturePath.endsWith(WINDOWS_UPDATE_SIGNATURE_NAME)) {
+ throw new Error(`Signature filename must end with ${WINDOWS_UPDATE_SIGNATURE_NAME}`);
+ }
+ signWindowsUpdateManifest({
+ manifestPath,
+ signaturePath,
+ privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
+ });
+ process.stdout.write(`${signaturePath}\n`);
+};
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ run();
+}
diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs
index 96aee5f1..4632d1e7 100644
--- a/scripts/verify-desktop-package.mjs
+++ b/scripts/verify-desktop-package.mjs
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
-import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
+import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { listPackage } from "@electron/asar";
import { assertMacIcnsComplete } from "./desktop-icns.mjs";
@@ -33,6 +33,22 @@ const verifyMachOArch = (path, arch, label) => {
assert.deepEqual(result.stdout.trim().split(/\s+/), [expectedArch], `${label} must contain only ${expectedArch}`);
};
+const verifyPeX64 = (path, label) => {
+ const descriptor = openSync(path, "r");
+ try {
+ const dosHeader = Buffer.alloc(64);
+ assert.equal(readSync(descriptor, dosHeader, 0, dosHeader.length, 0), dosHeader.length, `${label} DOS header is incomplete`);
+ assert.equal(dosHeader.toString("ascii", 0, 2), "MZ", `${label} is not a PE executable`);
+ const peOffset = dosHeader.readUInt32LE(0x3c);
+ const peHeader = Buffer.alloc(6);
+ assert.equal(readSync(descriptor, peHeader, 0, peHeader.length, peOffset), peHeader.length, `${label} PE header is incomplete`);
+ assert.equal(peHeader.toString("ascii", 0, 4), "PE\0\0", `${label} PE signature is invalid`);
+ assert.equal(peHeader.readUInt16LE(4), 0x8664, `${label} must target Windows x64`);
+ } finally {
+ closeSync(descriptor);
+ }
+};
+
for (const sidecarPath of files.filter((path) => /[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i.test(path))) {
const bundleRoot = sidecarPath.replace(/[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i, "");
assert.ok(existsSync(join(bundleRoot, "resources", "web", "index.html")), `Desktop bundle is missing the Web renderer: ${bundleRoot}`);
@@ -69,9 +85,24 @@ if (requestedPlatform === "darwin") {
// disposable unpacked bundle once its contents have passed verification.
rmSync(unpackedApp, { recursive: true, force: true });
} else if (requestedPlatform === "win32") {
- const installer = matchingPrefix(`EdgeEver-${version}-windows-`).some((path) => path.endsWith(".exe"));
- const unpacked = existsSync(join(outputDirectory, "win-arm64-unpacked", "EdgeEver.exe"));
- assert.ok(installer || unpacked, "Windows package must contain the current NSIS installer or an unpacked executable");
+ assert.equal(requestedArch, "x64", `Windows release package must target x64, received: ${requestedArch}`);
+ const installer = join(outputDirectory, `EdgeEver-${version}-windows-x64.exe`);
+ const unpackedDirectory = join(outputDirectory, "win-unpacked");
+ const executable = join(unpackedDirectory, "EdgeEver.exe");
+ const resources = join(unpackedDirectory, "resources");
+ const sidecar = join(resources, "sidecar", "edgeever-sidecar.exe");
+ assert.ok(existsSync(installer), `Windows package must contain the current x64 NSIS installer: ${installer}`);
+ assert.ok(existsSync(executable), `Windows package must contain the unpacked x64 executable: ${executable}`);
+ assert.ok(existsSync(sidecar), `Windows package must contain the x64 sidecar: ${sidecar}`);
+ assert.ok(existsSync(join(resources, "web", "index.html")), "Windows app bundle is missing the Web renderer");
+ assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations");
+ verifyPeX64(executable, "Electron executable");
+ verifyPeX64(sidecar, "Rust sidecar");
+ const asarPath = join(resources, "app.asar");
+ assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`);
+ const asarFiles = new Set(listPackage(asarPath));
+ assert.ok(asarFiles.has("/src/preload/index.cjs"), "Windows app bundle must contain the sandbox-compatible CommonJS preload");
+ assert.ok(!asarFiles.has("/src/preload/index.mjs"), "Windows app bundle must not contain the unsupported ESM preload");
} else if (requestedPlatform === "linux") {
assert.ok(matchingPrefix(`EdgeEver-${version}-linux-`).some((path) => path.endsWith(".AppImage")), "Linux package must contain the current AppImage");
const unpacked = files.find((path) => path.endsWith("/resources/sidecar/edgeever-sidecar") && path.includes("linux-") && path.includes("-unpacked"));
diff --git a/scripts/verify-windows-update-release.mjs b/scripts/verify-windows-update-release.mjs
new file mode 100644
index 00000000..950cc626
--- /dev/null
+++ b/scripts/verify-windows-update-release.mjs
@@ -0,0 +1,77 @@
+import { readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ WINDOWS_UPDATE_MANIFEST_NAME,
+ WINDOWS_UPDATE_SIGNATURE_NAME,
+ verifyDownloadedWindowsUpdate,
+ verifyWindowsUpdateMetadata,
+} from "../apps/desktop/src/main/windows-update-trust.mjs";
+
+export const verifyWindowsUpdateRelease = async (directory) => {
+ const manifestBytes = readFileSync(join(directory, WINDOWS_UPDATE_MANIFEST_NAME));
+ const signatureBytes = readFileSync(join(directory, WINDOWS_UPDATE_SIGNATURE_NAME));
+ const untrustedManifest = JSON.parse(manifestBytes.toString("utf8"));
+ const expectedNames = [
+ untrustedManifest.file?.name,
+ "latest.yml",
+ WINDOWS_UPDATE_MANIFEST_NAME,
+ WINDOWS_UPDATE_SIGNATURE_NAME,
+ "SHA256SUMS-windows.txt",
+ ].sort();
+ const actualNames = readdirSync(directory).sort();
+ if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
+ throw new Error(`Windows update audit expected exactly: ${expectedNames.join(", ")}`);
+ }
+ const manifest = verifyWindowsUpdateMetadata({
+ manifestBytes,
+ signatureBytes,
+ expectedVersion: untrustedManifest.version,
+ updateInfo: {
+ version: untrustedManifest.version,
+ files: [{
+ url: untrustedManifest.file?.name,
+ size: untrustedManifest.file?.size,
+ sha512: untrustedManifest.file?.sha512,
+ }],
+ },
+ });
+ await verifyDownloadedWindowsUpdate({
+ path: join(directory, manifest.file.name),
+ manifest,
+ });
+
+ const latestYml = readFileSync(join(directory, "latest.yml"), "utf8");
+ const expectedLatestYml = [
+ `version: ${manifest.version}`,
+ "files:",
+ ` - url: ${manifest.file.name}`,
+ ` sha512: ${manifest.file.sha512}`,
+ ` size: ${manifest.file.size}`,
+ `path: ${manifest.file.name}`,
+ `sha512: ${manifest.file.sha512}`,
+ `releaseDate: '${manifest.releaseDate}'`,
+ "",
+ ].join("\n");
+ if (latestYml !== expectedLatestYml) {
+ throw new Error("latest.yml does not exactly match the signed manifest");
+ }
+ const checksum = readFileSync(join(directory, "SHA256SUMS-windows.txt"), "utf8");
+ if (checksum !== `${manifest.file.sha256} ${manifest.file.name}\n`) {
+ throw new Error("SHA256SUMS-windows.txt does not match the signed manifest");
+ }
+ return manifest;
+};
+
+const run = async () => {
+ const [directoryValue] = process.argv.slice(2);
+ if (!directoryValue) {
+ throw new Error("Usage: node scripts/verify-windows-update-release.mjs ");
+ }
+ const manifest = await verifyWindowsUpdateRelease(resolve(directoryValue));
+ process.stdout.write(`Verified ${manifest.file.name} with ${manifest.keyId}\n`);
+};
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ await run();
+}
diff --git a/scripts/windows-update-metadata.test.mjs b/scripts/windows-update-metadata.test.mjs
new file mode 100644
index 00000000..9349eb8d
--- /dev/null
+++ b/scripts/windows-update-metadata.test.mjs
@@ -0,0 +1,123 @@
+import { generateKeyPairSync } from "node:crypto";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, test } from "bun:test";
+import { createWindowsUpdateMetadata } from "./create-windows-update-metadata.mjs";
+import {
+ assertWindowsUpdateSigningKey,
+ signWindowsUpdateManifest,
+} from "./sign-windows-update-manifest.mjs";
+import { verifyWindowsUpdateRelease } from "./verify-windows-update-release.mjs";
+
+describe("Windows update release metadata", () => {
+ test("creates installer metadata consumed by electron-updater and the trust gate", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-metadata-"));
+ try {
+ await writeFile(join(directory, "EdgeEver-1.49.0-windows-x64.exe"), "payload");
+ const manifest = await createWindowsUpdateMetadata({ directory, version: "1.49.0" });
+ expect(manifest.file).toMatchObject({
+ name: "EdgeEver-1.49.0-windows-x64.exe",
+ size: 7,
+ sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
+ });
+ expect(await readFile(join(directory, "latest.yml"), "utf8"))
+ .toContain(`sha512: ${manifest.file.sha512}`);
+ expect(await readFile(join(directory, "SHA256SUMS-windows.txt"), "utf8"))
+ .toBe(`${manifest.file.sha256} ${manifest.file.name}\n`);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("signs only with the private key matching the expected public key", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-signing-"));
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+ const { publicKey: otherPublicKey } = generateKeyPairSync("ed25519");
+ const manifestPath = join(directory, "latest-windows.json");
+ const signaturePath = `${manifestPath}.sig`;
+ const privateKeyPath = join(directory, "private.pem");
+ try {
+ await writeFile(manifestPath, `${JSON.stringify({
+ schemaVersion: 1,
+ keyId: "edgeever-windows-update-2026-01",
+ version: "1.49.0",
+ platform: "win32",
+ arch: "x64",
+ releaseDate: "2026-08-30T00:00:00.000Z",
+ file: {
+ name: "EdgeEver-1.49.0-windows-x64.exe",
+ size: 7,
+ sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==",
+ sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
+ },
+ })}\n`);
+ await writeFile(privateKeyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
+ expect(assertWindowsUpdateSigningKey({
+ privateKeyPath,
+ expectedPublicKey: publicKey,
+ }).asymmetricKeyType).toBe("ed25519");
+ expect(signWindowsUpdateManifest({
+ manifestPath,
+ signaturePath,
+ privateKeyPath,
+ expectedPublicKey: publicKey,
+ }).signature).toHaveLength(88);
+ expect(() => signWindowsUpdateManifest({
+ manifestPath,
+ signaturePath,
+ privateKeyPath,
+ expectedPublicKey: otherPublicKey,
+ })).toThrow("does not match");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("audits the complete production release asset set", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-release-audit-"));
+ const manifest = {
+ schemaVersion: 1,
+ keyId: "edgeever-windows-update-2026-01",
+ version: "1.49.0",
+ platform: "win32",
+ arch: "x64",
+ releaseDate: "2026-08-30T00:00:00.000Z",
+ file: {
+ name: "EdgeEver-1.49.0-windows-x64.exe",
+ size: 7,
+ sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==",
+ sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
+ },
+ };
+ try {
+ await writeFile(join(directory, manifest.file.name), "payload");
+ await writeFile(join(directory, "latest-windows.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+ await writeFile(join(directory, "latest-windows.json.sig"), `${JSON.stringify({
+ schemaVersion: 1,
+ keyId: manifest.keyId,
+ signature: "Itc3UD0G6Hn6BWdk9qlJ4SoqAUDn8efi1VRVUWDtA3Ip5+gwpAj7HxElxM6D/GXcAlHFxnVv19soEWxlfZLCDA==",
+ })}\n`);
+ await writeFile(join(directory, "latest.yml"), [
+ `version: ${manifest.version}`,
+ "files:",
+ ` - url: ${manifest.file.name}`,
+ ` sha512: ${manifest.file.sha512}`,
+ ` size: ${manifest.file.size}`,
+ `path: ${manifest.file.name}`,
+ `sha512: ${manifest.file.sha512}`,
+ `releaseDate: '${manifest.releaseDate}'`,
+ "",
+ ].join("\n"));
+ await writeFile(
+ join(directory, "SHA256SUMS-windows.txt"),
+ `${manifest.file.sha256} ${manifest.file.name}\n`,
+ );
+ await expect(verifyWindowsUpdateRelease(directory)).resolves.toEqual(manifest);
+ await writeFile(join(directory, manifest.file.name), "tampered");
+ await expect(verifyWindowsUpdateRelease(directory)).rejects.toThrow("size does not match");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});