diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 3c5b73f..3df6178 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -1,15 +1,23 @@ name: Unsigned desktop packages on: workflow_dispatch: + inputs: + verify_package_run: + description: Optional existing package run for a Windows verification-only retry + required: false + default: '' + type: string permissions: contents: read jobs: package: + if: inputs.verify_package_run == '' strategy: fail-fast: false matrix: os: [macos-15, windows-2025] runs-on: ${{ matrix.os }} + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -20,6 +28,10 @@ jobs: node-version: 24.19.0 cache: pnpm - uses: dtolnay/rust-toolchain@1.99.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri + cache-on-failure: true - run: pnpm install --frozen-lockfile - run: pnpm typecheck - name: macOS full tests including HTTP transport @@ -35,20 +47,49 @@ jobs: - run: node scripts/build-desktop.mjs --prepare-only - run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml - run: node scripts/build-desktop.mjs + - run: pnpm package:desktop - if: runner.os == 'macOS' - run: node scripts/verify-installed.mjs --app "apps/desktop/src-tauri/target/release/bundle/macos/Git View.app" --output installed-verification.json + run: node scripts/verify-macos-package.mjs - if: runner.os == 'Windows' shell: pwsh - run: | - New-Item -ItemType Directory -Force dist/desktop-portable - Copy-Item apps/desktop/src-tauri/target/release/git-view-desktop.exe dist/desktop-portable/ - Copy-Item apps/desktop/src-tauri/resources/* dist/desktop-portable/ -Recurse - node scripts/verify-installed.mjs --app dist/desktop-portable --output installed-verification.json + run: ./scripts/verify-windows-package.ps1 - uses: actions/upload-artifact@v4 + if: always() with: name: unsigned-desktop-${{ matrix.os }} - path: | - apps/desktop/src-tauri/target/release/bundle/ - dist/desktop-portable/ - installed-verification.json + path: dist/installers/ + if-no-files-found: error + verify_windows: + if: inputs.verify_package_run != '' + runs-on: windows-2025 + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.7.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.19.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Download the existing installer without rebuilding + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_RUN: ${{ inputs.verify_package_run }} + run: | + if ($env:PACKAGE_RUN -notmatch '^\d+$') { throw 'Package run must be a numeric GitHub Actions run ID' } + gh run download $env:PACKAGE_RUN --name unsigned-desktop-windows-2025 --dir dist/installers + if ($LASTEXITCODE -ne 0) { throw 'Could not download the installer' } + - shell: pwsh + run: ./scripts/verify-windows-package.ps1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-installer-verification + path: dist/installers/ if-no-files-found: error diff --git a/README.md b/README.md index 7bfcec5..77682ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Git View 是一个本地 Git 图形工具,用于查看文件差异、浏览提交历史,以及比较分支和提交。支持整文件暂存、提交和本地分支操作,写入前需要预览确认。 -支持中文和英文界面,通过本机 Git 读取仓库,无需连接远程服务。目前为桌面预览版,主要支持 macOS Apple silicon。 +支持中文和英文界面,通过本机 Git 读取仓库,无需连接远程服务。目前为桌面预览版,提供 macOS Apple silicon 和 Windows x64 安装包;Windows 暂时只开放查看。 ![提交历史与文件差异,浏览器中的演示仓库](docs/verification/screenshots/history-simplification-browser.png) @@ -22,7 +22,14 @@ Git View 是一个本地 Git 图形工具,用于查看文件差异、浏览提 ## 安装 -当前需要从源码构建。以下步骤适用于 macOS Apple silicon。 +在 [GitHub Releases](https://github.com/XSY-28/git-view/releases/latest) 下载当前版本的安装包和 `.sha256` 校验文件,无需登录 GitHub。两种包都自带 Node 运行时,使用时只需系统 Git,无需另装 Node、pnpm 或 Rust。 + +- **macOS Apple silicon**:下载 `macos-arm64.dmg`,打开后先退出 Git View,再双击 **Install Git View.command**。安装位置固定为 `~/Applications/Git View.app`,旧版本保留为 ZIP;完成后弹出磁盘映像。不要再复制一份到 `/Applications`。 +- **Windows x64**:下载 `windows-x64-setup.exe`,运行安装向导,再从开始菜单打开。需要 Git for Windows 与 WebView2;缺少 WebView2 时安装程序会下载运行时。Windows 仅支持查看,不开放暂存、提交和分支写入。 + +当前包尚未代码签名或 Apple 公证,系统可能提示无法验证发布者。确认下载来源后,使用系统提供的“仍要运行”或“隐私与安全性 → 仍要打开”入口,不要关闭系统安全机制。完整步骤与校验、卸载说明见[安装包说明](docs/INSTALL.md)。 + +### 从源码构建(macOS) 构建需要 Git、Node.js **24.19.0**、pnpm **11.7.0**、Rust 和 Xcode Command Line Tools。CI 使用 Rust 1.99.0。 @@ -66,7 +73,9 @@ node scripts/create-demo.mjs ## 更新 -更新本地源码后,先退出 Git View,再在项目根目录运行: +安装包用户退出 Git View 后,下载新版本并重新安装即可。macOS 会将旧版本备份为 ZIP。 + +从源码更新的 macOS 用户退出 Git View 后,在项目根目录运行: ```sh pnpm update:desktop @@ -77,7 +86,7 @@ open "$HOME/Applications/Git View.app" ## 支持范围 -macOS Apple silicon 已有桌面实机验证。Windows 暂时仅开放查看,源码与桌面宿主由 CI 检查,安装和原生界面尚未实机验证。 +macOS Apple silicon 已有桌面实机验证。Windows 暂时仅开放查看,已通过 Windows CI 中的实际安装、原生窗口差异与历史走查、正常退出和卸载验证;Windows 10/11 实体电脑尚未复测。详见[安装包验收记录](docs/verification/2026-10-07-desktop-installers.md)。 - 暂不提供 push、merge、rebase 或历史重写。远程跟踪引用来自本地仓库,不会自动获取远端更新。 - 暂存按整个文件操作,不支持选择差异行或片段。写入限常规仓库和 index;冲突、进行中的 Git 操作、submodule、特殊 index 或外部内容过滤器会被拒绝。暂不支持 `text`、`eol`、`ident`、`working-tree-encoding` 和 autocrlf 转换。 diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs index 4d9979e..72fcdaf 100644 --- a/apps/desktop/src-tauri/src/sidecar.rs +++ b/apps/desktop/src-tauri/src/sidecar.rs @@ -66,6 +66,12 @@ impl Sidecar { use std::os::unix::process::CommandExt; command.process_group(0); } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // The GUI host has no console; keep its piped background runtime quiet. + command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); + } let mut child = command .spawn() .map_err(|_| "Bundled query process could not start")?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 17cf17c..3e3fbac 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -15,6 +15,6 @@ "icon": ["icons/icon.png", "icons/icon.icns", "icons/icon.ico"], "targets": ["app"], "resources": { "resources/runtime/": "runtime/", "resources/app/": "app/" }, - "macOS": { "minimumSystemVersion": "12.0" } + "macOS": { "minimumSystemVersion": "13.5" } } } diff --git a/apps/desktop/src-tauri/tauri.windows.conf.json b/apps/desktop/src-tauri/tauri.windows.conf.json index 6e63c8a..ac2af6d 100644 --- a/apps/desktop/src-tauri/tauri.windows.conf.json +++ b/apps/desktop/src-tauri/tauri.windows.conf.json @@ -1,3 +1,12 @@ { - "bundle": { "targets": ["nsis"] } + "bundle": { + "targets": ["nsis"], + "windows": { + "nsis": { + "installMode": "currentUser", + "languages": ["English", "SimpChinese"], + "displayLanguageSelector": true + } + } + } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..1a32567 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,40 @@ +# 安装 Git View 预览包 + +在 [GitHub Releases](https://github.com/XSY-28/git-view/releases/latest) 下载对应平台的安装包与同版本 `.sha256` 文件,无需登录 GitHub。开发构建也可从 [打包工作流](https://github.com/XSY-28/git-view/actions/workflows/package.yml) 的成功运行中下载 Artifacts ZIP(需要登录 GitHub)。当前目标平台为 macOS 13.5 及以上的 Apple silicon(arm64)和 Windows x64,尚不提供 Intel Mac、Windows ARM 或 Linux 安装包。macOS 下限由包内 [Node 24 的平台要求](https://github.com/nodejs/node/blob/v24.19.0/BUILDING.md#platform-list)决定。 + +## macOS Apple silicon + +1. 确认系统 Git 可用,在终端执行 `git --version`。如果系统提示安装 Command Line Tools,按提示完成;使用安装包不需要 Node、pnpm、Rust 或完整 Xcode。 +2. 下载 `Git-View_0.2.0_macos-arm64.dmg`。可在下载目录执行 `shasum -a 256 -c Git-View_0.2.0_macos-arm64.dmg.sha256` 核对文件。 +3. 退出正在运行的 Git View,打开 DMG,双击 `Install Git View.command`。安装无需管理员权限,固定安装到 `~/Applications/Git View.app`,完成后自动打开应用。 +4. 安装完成后弹出 DMG。不要把另一份 Git View.app 放在 `/Applications`,也不要保留解压的备份应用;这样能避免多个同名搜索入口。 + +安装程序从磁盘映像中解压到临时目录,用包内 Node 启动项目现有安装脚本。它校验应用身份和资源哈希、备份旧安装、检查运行状态、安装到固定路径、回收临时应用、清理注册并刷新搜索服务。磁盘映像中只保留压缩的应用,不会出现可被 Spotlight 注册的第二份 `.app`。 + +已有版本备份到 `~/Library/Application Support/Git View/backups`,格式为 ZIP。安装中发生错误时保留现有恢复机制;错误会显示在终端中。注册与搜索进程检查不能证明 Spotlight 面板结果正确,需要重新搜索确认。安装程序不会自动停止你正在使用的应用。 + +当前包未做 Apple Developer ID 签名或公证。系统拦截时,先确认下载来源,再用“系统设置 → 隐私与安全性 → 仍要打开”。安装命令和应用可能分别触发检查;不要通过关闭 Gatekeeper 绕过。 + +卸载时先退出应用,再删除 `~/Applications/Git View.app`。若也想删除设置和备份,另行删除 `~/.local/share/git-view` 和上述备份目录;不要删除你自己的 Git 仓库。 + +## Windows x64 + +1. 安装 [Git for Windows](https://gitforwindows.org/),确保新开的终端中 `git --version` 能成功。 +2. 下载 `Git-View_0.2.0_windows-x64-setup.exe`。用 PowerShell 执行 `Get-FileHash .\Git-View_0.2.0_windows-x64-setup.exe -Algorithm SHA256`,与同版本 `.sha256` 文件中的哈希比较。 +3. 运行安装向导。应用安装到当前用户目录,不要求管理员权限;缺少 Microsoft WebView2 时安装程序会下载运行时,因此首次安装可能需要联网。 +4. 从开始菜单打开 Git View,点击顶部打开仓库按钮选择本地仓库,或展开“手动输入路径”填入仓库的完整路径。 + +Windows 当前只支持查看,包括当前改动、历史、版本比较和历史调查。暂存、取消暂存、提交、创建和切换分支均由后台明确拒绝。浏览器 HTTP 备用入口暂不支持 Windows,请使用桌面应用。 + +当前 EXE 未做代码签名,Windows 可能显示未知发布者或 SmartScreen 提示。仅在核对 GitHub 下载来源和哈希后使用系统提供的“更多信息 → 仍要运行”入口,不要关闭 SmartScreen。若设备的组织策略禁止未知发布者,需要遵循该设备的策略。 + +卸载使用“设置 → 应用 → 已安装的应用 → Git View → 卸载”。应用设置默认在用户主目录的 `.local\share\git-view`,卸载不会自动删除你的仓库。 + +## 构建与验收边界 + +包构建工作流分别在 macOS ARM 与 Windows x64 runner 上执行类型检查、适用的真实 Git 测试和 Rust 宿主测试,再构建安装包。 + +- macOS:验证 DMG 完整性和挂载、执行包内安装程序 dry-run,并对从 DMG 解压出的内容验证内置 Node、中文/空格路径、仓库只读指纹和 stdio 退出。原生 GUI 在本机固定安装路径验收。 +- Windows:运行真正的 NSIS 静默安装到包含中文和空格的目录,对安装结果做包内运行检查;启动安装后的 release 应用,用 WebView2 调试连接驱动真实窗口和 Tauri IPC,检查打开中文路径仓库、暂存/未暂存差异、历史差异、正常关闭与 Node 子进程回收,最后卸载。 + +这些检查的通过与失败以该版本对应的 GitHub Actions 和随包验证报告为准。Windows runner 是 CI 系统,不能替代所有 Windows 10/11 实体电脑上的验收;SmartScreen、Gatekeeper、人工安装向导和原生系统目录选择对话框也需要目标设备复测。当前安装包没有自动更新机制。 diff --git a/docs/verification/2026-10-07-desktop-installers.md b/docs/verification/2026-10-07-desktop-installers.md new file mode 100644 index 0000000..5bbb9a4 --- /dev/null +++ b/docs/verification/2026-10-07-desktop-installers.md @@ -0,0 +1,39 @@ +# 桌面安装包验收(2026-10-07) + +本轮为桌面预览版 0.2.0 提供 macOS Apple silicon DMG 和 Windows x64 NSIS EXE。构建源码为 `727be42df4ed0fcecb69d38348d61dd1831e02ef`,安装步骤见[安装说明](../INSTALL.md)。安装包没有 Developer ID、公证或 Windows 代码签名。 + +## 已验证 + +| 项目 | 结果与证据 | +| --- | --- | +| 本机源码检查 | `pnpm check` 通过:232 个测试、32 个文件,以及类型检查和构建。 | +| 本机宿主检查 | `cargo test --locked` 的 4 个实际 sidecar 生命周期测试通过,包括正常退出、协议错误、崩溃重启和 Git 子进程回收。 | +| macOS 安装与恢复脚本 | `pnpm test:install-desktop` 的 27 个测试通过。 | +| 本地 macOS DMG | 完整性、挂载、包内安装程序真实 dry-run 及解压内容验证通过;实际安装到 `~/Applications/Git View.app`。 | +| 本机 macOS 原生窗口 | 暂存差异显示 `-version one / +version two staged`,工作区差异显示 `-version two staged / +version three working`,首次提交显示 `+version one`;正常退出后主进程与 Node 子进程均结束。 | +| macOS 清理 | 用既有安装脚本移除本轮原始构建副本;旧版本保存为 ZIP,注册唯一检查及搜索服务刷新通过。最近打开的仓库恢复为项目目录,测试临时仓库已移除。 | +| 云端 macOS 包 | [打包运行](https://github.com/XSY-28/git-view/actions/runs/37610447140)的 macOS job 成功。CI 包验证报告与本机同源码构建的 DMG 原生窗口走查是分别记录的证据。 | +| Windows 安装与窗口 | 真实 NSIS 安装到中文/空格目录、包内运行、打开中文仓库、暂存/工作区差异、历史、仓库只读指纹、后台 Node 无控制台、正常关闭与子进程回收、卸载全部通过,见同一打包运行的 Windows job 与随包两份验证报告。 | + +本地 DMG 的 SHA256 为 `42708befe15e289747273e4bb92c694c6e33c0b6062e2eba4bc8398a00b6b49c`。安装后的原生可执行文件和资源哈希,与从该 DMG 解压验证的内容一致。本地验收报告保存在忽略的 `dist/installers/`,CI 的平台包与报告保存在对应工作流 Artifacts。 + +## Windows 问题与回归检查 + +最初的安装包可完成构建、NSIS 安装和包内 CLI / stdio 验证,但原生窗口测试未能接入 WebView2。进程诊断和桌面截图确认窗口、WebView2 与 Node 已运行;WebView2 命令行缺少测试请求的调试参数。 + +微软文档明确指出,[管理员宿主会忽略环境变量中的 WebView2 调试参数](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/webview-features-flags)。验证脚本在一次性 CI 机器中仅为 `git-view-desktop.exe` 设置对应 HKLM 参数,保留并在 `finally` 中恢复旧值。应用的正常启动没有调试端口配置。 + +截图同时暴露了后台 Node 额外分配控制台窗口的问题。[旧包回归运行](https://github.com/XSY-28/git-view/actions/runs/37610032540)在实际进程控制台检查中失败。修复在 Windows 宿主中以 `CREATE_NO_WINDOW` 启动后台 Node,并为 Node 发起的 Git 查询设置 `windowsHide`;保留原有管道通信和 Windows Job 退出清理机制。新包已通过同一进程检查及完整窗口走查。 + +Windows 安装包的 SHA256 为 `ea2cfd34635e5fc47a447bc62fa439822104cf31b6bc8b9da98b436ec0e8cb37`,下载到本机后已与随包校验文件核对。CLI 验证报告中的 `gui: unverified` 只表示 CLI 脚本不执行 GUI 检查;原生窗口的独立报告列出上述窗口检查全部通过。 + +![Windows 安装后的工作区差异](screenshots/desktop-installers-windows-changes.png) + +![Windows 安装后的历史提交差异](screenshots/desktop-installers-windows-history.png) + +## 验证边界 + +- Windows 仍只支持查看,暂存、取消暂存、提交与分支写入尚未开放。 +- Windows CI 环境中的实际窗口验证不等于所有 Windows 10/11 实体电脑验收;人工安装向导、原生目录选择器与 SmartScreen 仍需目标设备复测。 +- macOS 首次互联网下载后的 Gatekeeper 流程、不同 macOS 版本及 Spotlight 搜索面板尚未复测。注册唯一、搜索服务刷新和新 PID 不能替代搜索面板证据。 +- 暂无 Intel Mac、Windows ARM 和 Linux 安装包,也没有自动更新机制。 diff --git a/docs/verification/screenshots/desktop-installers-windows-changes.png b/docs/verification/screenshots/desktop-installers-windows-changes.png new file mode 100644 index 0000000..731156f Binary files /dev/null and b/docs/verification/screenshots/desktop-installers-windows-changes.png differ diff --git a/docs/verification/screenshots/desktop-installers-windows-history.png b/docs/verification/screenshots/desktop-installers-windows-history.png new file mode 100644 index 0000000..86fe05a Binary files /dev/null and b/docs/verification/screenshots/desktop-installers-windows-history.png differ diff --git a/package.json b/package.json index 3a9621f..4fe1b45 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "inspect": "node dist/cli.mjs inspect --repo \"$PWD\" --json", "stop": "node dist/cli.mjs shutdown --json", "build:desktop": "node scripts/build-desktop.mjs", + "package:desktop": "node scripts/package-desktop.mjs", "update:desktop": "node scripts/install-desktop.mjs --check-ready && pnpm build && pnpm build:desktop && pnpm install:desktop", "install:desktop": "node scripts/install-desktop.mjs", "repair:desktop-search": "node scripts/install-desktop.mjs --repair-search", diff --git a/packages/git-cli/src/runner.ts b/packages/git-cli/src/runner.ts index 0edd90c..e8d6f2a 100644 --- a/packages/git-cli/src/runner.ts +++ b/packages/git-cli/src/runner.ts @@ -45,7 +45,7 @@ export function runGit(cwd: string, args: string[], options: RunOptions = {}): P '-c', 'i18n.logOutputEncoding=utf-8', '-c', 'protocol.allow=never', ]; return new Promise((resolve, reject) => { - const child = spawn('git', [...globalArgs, ...args], { cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn('git', [...globalArgs, ...args], { cwd, env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let bytes = 0; diff --git a/scripts/install-desktop.mjs b/scripts/install-desktop.mjs index 442f75e..ec8a988 100644 --- a/scripts/install-desktop.mjs +++ b/scripts/install-desktop.mjs @@ -72,7 +72,12 @@ async function validateBundle(path, run, expectedVersion) { async function requireStopped(run, paths) { const output = (await run('/bin/ps', ['-axo', 'pid=,comm='])).stdout; for (const line of output.split('\n')) { - const command = line.trim().replace(/^\d+\s+/, ''); + const entry = line.trim().match(/^(\d+)\s+(.+)$/); + if (!entry) continue; + const command = entry[2]; + // A distribution installer runs this script with the incoming bundle's + // own Node. Exempt only this exact process, never another app or sidecar. + if (Number(entry[1]) === process.pid && command === process.execPath) continue; if (command.endsWith('/Contents/MacOS/git-view-desktop') || paths.some(path => command.startsWith(`${path}/Contents/`))) { throw new Error('Git View is running. Quit Git View, then rerun the desktop update/install command. No process was stopped.'); } diff --git a/scripts/macos-install.command b/scripts/macos-install.command new file mode 100644 index 0000000..21080fa --- /dev/null +++ b/scripts/macos-install.command @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail +package_dir="$(cd "$(dirname "$0")" && pwd)" +work_dir="$(/usr/bin/mktemp -d /tmp/git-view-package.XXXXXX)" +# Node resolves the script URL to a physical path; keep argv on the same path. +work_dir="$(cd "$work_dir" && pwd -P)" +trap '/bin/rm -rf "$work_dir"' EXIT +bundle_dir="$work_dir/apps/desktop/src-tauri/target/release/bundle/macos" +/bin/mkdir -p "$bundle_dir" "$work_dir/scripts" +/usr/bin/ditto -x -k "$package_dir/Git-View.app.zip" "$bundle_dir" +/bin/cp "$package_dir/.installer/install-desktop.mjs" "$work_dir/scripts/" +/bin/cp "$package_dir/.installer/verify-installed.mjs" "$work_dir/scripts/" +/bin/cp "$package_dir/.installer/tauri.conf.json" "$work_dir/apps/desktop/src-tauri/" +runtime="$bundle_dir/Git View.app/Contents/Resources/runtime/node" +if [[ "${1:-}" == "--verify-only" ]]; then + "$runtime" "$work_dir/scripts/verify-installed.mjs" --app "$bundle_dir/Git View.app" +else + "$runtime" "$work_dir/scripts/install-desktop.mjs" "$@" + if [[ "${1:-}" != "--dry-run" && "${1:-}" != "--check-ready" ]]; then + /usr/bin/open "$HOME/Applications/Git View.app" + printf '\nInstalled to ~/Applications/Git View.app. You can close this window and eject the disk image.\n' + fi +fi diff --git a/scripts/package-desktop.mjs b/scripts/package-desktop.mjs new file mode 100644 index 0000000..6df44b5 --- /dev/null +++ b/scripts/package-desktop.mjs @@ -0,0 +1,41 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const host = join(root, 'apps/desktop/src-tauri'); +const config = JSON.parse(await readFile(join(host, 'tauri.conf.json'), 'utf8')); +const output = join(root, 'dist/installers'); +await mkdir(output, { recursive: true }); +let artifact; +if (process.platform === 'darwin') { + const stage = await mkdtemp(join(tmpdir(), 'git-view-dmg-')); + try { + const source = join(host, 'target/release/bundle/macos/Git View.app'); + await exec('/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', source, join(stage, 'Git-View.app.zip')]); + await mkdir(join(stage, '.installer')); + for (const name of ['install-desktop.mjs', 'verify-installed.mjs']) await copyFile(join(root, 'scripts', name), join(stage, '.installer', name)); + await copyFile(join(host, 'tauri.conf.json'), join(stage, '.installer/tauri.conf.json')); + await copyFile(join(root, 'LICENSE'), join(stage, 'LICENSE.txt')); + await copyFile(join(root, 'scripts/macos-install.command'), join(stage, 'Install Git View.command')); + await chmod(join(stage, 'Install Git View.command'), 0o755); + await writeFile(join(stage, 'READ ME.txt'), `Git View ${config.version} — macOS ${process.arch}\n\nQuit Git View, then double-click Install Git View.command.\nThe installer uses the bundled Node runtime; no Node or Rust installation is needed.\nIt installs only to ~/Applications/Git View.app and keeps previous versions as ZIP backups.\nSystem Git is required. This preview has no Developer ID signature or Apple notarization.\nIf macOS blocks it, inspect the source and use System Settings > Privacy & Security > Open Anyway.\nDo not disable Gatekeeper. Close the installer disk image after installation.\n\n退出 Git View 后,双击 Install Git View.command。安装位置固定为 ~/Applications/Git View.app。\n旧版本备份为 ZIP。需要系统 Git,无需另装 Node 或 Rust。当前预览包尚未签名或公证。\n`); + artifact = `Git-View_${config.version}_macos-${process.arch}.dmg`; + await exec('/usr/bin/hdiutil', ['create', '-volname', 'Git View Installer', '-srcfolder', stage, '-ov', '-format', 'UDZO', join(output, artifact)], { timeout: 180_000 }); + await exec('/usr/bin/hdiutil', ['verify', join(output, artifact)], { timeout: 120_000 }); + } finally { await rm(stage, { recursive: true, force: true }); } +} else if (process.platform === 'win32') { + const folder = join(host, 'target/release/bundle/nsis'); + const candidates = (await readdir(folder)).filter(name => name.endsWith('-setup.exe')); + if (candidates.length !== 1) throw new Error(`Expected one NSIS installer, found ${candidates.length}.`); + artifact = `Git-View_${config.version}_windows-${process.arch}-setup.exe`; + await copyFile(join(folder, candidates[0]), join(output, artifact)); +} else { throw new Error('Installer packaging supports macOS and Windows only.'); } +const hash = createHash('sha256').update(await readFile(join(output, artifact))).digest('hex'); +await writeFile(join(output, `${artifact}.sha256`), `${hash} ${artifact}\n`); +console.log(JSON.stringify({ artifact: join(output, artifact), sha256: hash })); diff --git a/scripts/verify-macos-package.mjs b/scripts/verify-macos-package.mjs new file mode 100644 index 0000000..3a6cd61 --- /dev/null +++ b/scripts/verify-macos-package.mjs @@ -0,0 +1,32 @@ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import assert from 'node:assert/strict'; + +const exec = promisify(execFile); +if (process.platform !== 'darwin') throw new Error('macOS verification must run on macOS.'); +const folder = resolve('dist/installers'); +const images = (await readdir(folder)).filter(name => name.endsWith('.dmg')); +if (images.length !== 1) throw new Error('Expected exactly one DMG.'); +const temporary = await mkdtemp(join(tmpdir(), 'git-view-mounted-')); +const mount = join(temporary, 'image'); +await mkdir(mount); +let mounted = false; +try { + await exec('/usr/bin/hdiutil', ['attach', '-nobrowse', '-readonly', '-mountpoint', mount, join(folder, images[0])]); + mounted = true; + const command = join(mount, 'Install Git View.command'); + const plan = JSON.parse((await exec('/bin/bash', [command, '--dry-run'], { timeout: 90_000 })).stdout); + assert.equal(plan.dryRun, true); + assert.equal(plan.searchRefresh.status, 'planned'); + const report = JSON.parse((await exec('/bin/bash', [command, '--verify-only'], { timeout: 120_000 })).stdout); + report.checks.dmgMountedAndInstallerDryRun = true; + report.checks.extractedPayloadVerified = true; + await writeFile(join(folder, 'macos-package-verification.json'), `${JSON.stringify(report, null, 2)}\n`); + console.log('DMG mount, installer dry-run and extracted payload checks passed.'); +} finally { + if (mounted) await exec('/usr/bin/hdiutil', ['detach', mount]); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/verify-windows-package.ps1 b/scripts/verify-windows-package.ps1 new file mode 100644 index 0000000..a637219 --- /dev/null +++ b/scripts/verify-windows-package.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference = 'Stop' +$install = Join-Path $env:RUNNER_TEMP 'Git View 中文 安装' +if (Test-Path $install) { throw "Verification directory already exists: $install" } +$packages = @(Get-ChildItem 'dist/installers/*-setup.exe') +if ($packages.Count -ne 1) { throw 'Expected exactly one NSIS installer' } +try { + $installer = Start-Process -FilePath $packages[0].FullName -ArgumentList "/S /D=$install" -PassThru -Wait + if ($installer.ExitCode -ne 0) { throw "NSIS installer failed: $($installer.ExitCode)" } + $app = Join-Path $install 'git-view-desktop.exe' + if (-not (Test-Path $app)) { throw 'NSIS did not install the desktop executable' } + node scripts/verify-installed.mjs --app $install --output dist/installers/windows-package-verification.json + if ($LASTEXITCODE -ne 0) { throw 'Installed payload verification failed' } + node scripts/verify-windows-ui.mjs $app + if ($LASTEXITCODE -ne 0) { throw 'Installed native window verification failed' } +} finally { + $uninstaller = Join-Path $install 'uninstall.exe' + if (Test-Path $uninstaller) { + $process = Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "NSIS uninstall failed: $($process.ExitCode)" } + # NSIS uninstall may hand off to a temporary process before returning. + for ($i = 0; $i -lt 30 -and (Test-Path (Join-Path $install 'git-view-desktop.exe')); $i++) { Start-Sleep -Milliseconds 500 } + if (Test-Path (Join-Path $install 'git-view-desktop.exe')) { throw 'Uninstall left the desktop executable installed' } + } + if (Test-Path $install) { Remove-Item $install -Recurse -Force } +} +$reportPath = 'dist/installers/windows-package-verification.json' +$report = Get-Content $reportPath -Raw | ConvertFrom-Json +$report.checks | Add-Member -NotePropertyName nsisInstalledAndUninstalled -NotePropertyValue $true +$report | ConvertTo-Json -Depth 20 | Set-Content $reportPath -Encoding utf8NoBOM diff --git a/scripts/verify-windows-ui.mjs b/scripts/verify-windows-ui.mjs new file mode 100644 index 0000000..12d0ba2 --- /dev/null +++ b/scripts/verify-windows-ui.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { setTimeout as wait } from 'node:timers/promises'; +import { chromium, expect } from '@playwright/test'; + +// Drive the actual installed WebView2 window; no HTTP fallback or mocked IPC. +if (process.platform !== 'win32') throw new Error('Native UI verification must run on Windows.'); +const executable = resolve(process.argv[2] || ''); +const output = resolve('dist/installers'); +const temporary = await mkdtemp(join(tmpdir(), 'git-view-native-ui-')); +const fixture = join(temporary, '中文 仓库'); +const exec = promisify(execFile); +const env = { ...process.env, GIT_VIEW_HOME: join(temporary, 'state'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL' }; +for (const name of Object.keys(env)) if (name.startsWith('GIT_') && !['GIT_VIEW_HOME', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL'].includes(name)) delete env[name]; +let child; let browser; let exited; +let diagnostic = ''; let probeFailure; let policyOverride; +const report = { platform: process.platform, arch: process.arch, executable, checks: {}, environment: 'GitHub Windows runner; installed release WebView2 window', physicalWindows10Or11: 'unverified' }; +try { + await mkdir(output, { recursive: true }); + await mkdir(fixture); + const git = (...args) => exec('git', ['-C', fixture, ...args], { env }); + await git('init', '-b', 'main'); + await writeFile(join(fixture, '中文 文件.txt'), 'version one\n'); + await git('add', '--', '中文 文件.txt'); + await git('-c', 'user.name=Installer Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'native fixture commit'); + await writeFile(join(fixture, '中文 文件.txt'), 'version two staged\n'); + await git('add', '--', '中文 文件.txt'); + await writeFile(join(fixture, '中文 文件.txt'), 'version three working\n'); + await writeFile(join(fixture, '未跟踪.txt'), 'untracked content\n'); + const fingerprint = async () => { + const hash = createHash('sha256'); + const walk = async path => { + for (const item of (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const file = join(path, item.name); hash.update(file.slice(fixture.length)); + if (item.isDirectory()) await walk(file); else hash.update(await readFile(file)); + } + }; + await walk(fixture); return hash.digest('hex'); + }; + const before = await fingerprint(); + const server = createServer(); + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)); + const port = server.address().port; + await new Promise(resolveClose => server.close(resolveClose)); + // WebView2 150+ ignores environment debug flags in elevated hosts. + // Scope its documented HKLM override to this executable on the disposable runner, + // and restore any previous value even when verification fails. + const policyKey = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge\\WebView2\\AdditionalBrowserArguments'; + const policyName = basename(executable).replaceAll("'", "''"); + const elevated = (await exec('powershell.exe', ['-NoProfile', '-Command', "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"])).stdout.trim() === 'True'; + report.elevatedHost = elevated; + if (elevated) { + policyOverride = { key: policyKey, name: policyName, previous: JSON.parse((await exec('powershell.exe', ['-NoProfile', '-Command', `$ErrorActionPreference='Stop'; $k='${policyKey}'; $n='${policyName}'; $v=Get-ItemPropertyValue -LiteralPath $k -Name $n -ErrorAction SilentlyContinue; ConvertTo-Json -Compress -InputObject $v`])).stdout.trim() || 'null') }; + await exec('powershell.exe', ['-NoProfile', '-Command', `$ErrorActionPreference='Stop'; New-Item -Path '${policyKey}' -Force | Out-Null; New-ItemProperty -LiteralPath '${policyKey}' -Name '${policyName}' -PropertyType String -Value '--remote-debugging-port=${port} --remote-debugging-address=127.0.0.1' -Force | Out-Null`]); + } + // This debugging endpoint exists only in the disposable CI process. + child = spawn(executable, [], { env: { ...env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${port} --remote-debugging-address=127.0.0.1`, WEBVIEW2_USER_DATA_FOLDER: join(temporary, 'webview') }, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', chunk => { diagnostic += chunk; }); + child.stderr.on('data', chunk => { diagnostic += chunk; }); + exited = new Promise((resolveExit, reject) => { child.once('exit', (code, signal) => resolveExit({ code, signal })); child.once('error', reject); }); + await expect.poll(async () => { + if (child.exitCode !== null) throw new Error(`Native app exited before WebView initialization: ${diagnostic}`); + return fetch(`http://127.0.0.1:${port}/json/version`).then(result => { probeFailure = `HTTP ${result.status}`; return result.ok; }, error => { probeFailure = String(error.cause || error); return false; }); + }, { timeout: 60_000, message: 'Installed WebView2 must expose its real window' }).toBe(true); + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + await expect.poll(() => browser.contexts().flatMap(context => context.pages()).length, { timeout: 30_000 }).toBeGreaterThan(0); + const page = browser.contexts().flatMap(context => context.pages())[0]; + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await expect(page.locator('.repository-switcher-trigger')).toBeEnabled({ timeout: 30_000 }); + assert.equal(await page.evaluate(() => typeof window.__TAURI__?.core?.invoke), 'function'); + report.checks.realTauriIpcAndWebView2 = true; + const nodes = JSON.parse((await exec('powershell.exe', ['-NoProfile', '-Command', `ConvertTo-Json -Compress -InputObject @(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${child.pid}" | Where-Object Name -eq 'node.exe' | Select-Object -ExpandProperty ProcessId)`])).stdout); + assert.ok(nodes.length > 0, 'The real packaged Node sidecar must be running.'); + const consoleHandles = JSON.parse((await exec('powershell.exe', ['-NoProfile', '-Command', ` + Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern bool FreeConsole(); [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid); [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'; + $handles = @(${nodes.join(',')} | ForEach-Object { + [ConsoleProbe]::FreeConsole() | Out-Null; + if ([ConsoleProbe]::AttachConsole($_)) { [ConsoleProbe]::GetConsoleWindow().ToInt64(); [ConsoleProbe]::FreeConsole() | Out-Null } else { 0 } + }); + ConvertTo-Json -Compress -InputObject $handles + `])).stdout); + assert.ok(consoleHandles.every(handle => handle === 0), 'The background Node sidecar must not allocate a console window.'); + report.checks.noSidecarConsoleWindow = true; + const switcher = page.locator('.repository-switcher-trigger'); + if (await switcher.getAttribute('aria-expanded') !== 'true') await switcher.click(); + await page.locator('.repository-switcher-manual summary').click(); + await page.locator('#repo-path').fill(fixture); + await page.locator('.repository-switcher-manual button').click(); + await expect(page.locator('.group-staged .file-row').filter({ hasText: '中文 文件.txt' })).toBeVisible({ timeout: 30_000 }); + report.checks.manualOpenUnicodeRepository = true; + await page.locator('.group-staged .file-row').filter({ hasText: '中文 文件.txt' }).click(); + await expect(page.getByTestId('diff-scroll')).toContainText('+version two staged'); + await expect(page.getByTestId('diff-scroll')).toContainText('-version one'); + report.checks.stagedDiff = true; + await page.locator('.group-unstaged .file-row').filter({ hasText: '中文 文件.txt' }).click(); + await expect(page.getByTestId('diff-scroll')).toContainText('+version three working'); + await expect(page.getByTestId('diff-scroll')).toContainText('-version two staged'); + report.checks.unstagedDiff = true; + await page.screenshot({ path: join(output, 'windows-native-changes.png') }); + await page.getByRole('navigation', { name: /^(Main view|主视图)$/ }).getByRole('button', { name: /^(History|历史)$/ }).click(); + await page.getByTestId('commit-row').filter({ hasText: 'native fixture commit' }).click(); + await expect(page.getByTestId('diff-scroll')).toContainText('+version one'); + report.checks.historyAndCommitDiff = true; + await page.screenshot({ path: join(output, 'windows-native-history.png') }); + assert.deepEqual(errors, []); + report.checks.noRendererErrors = true; + assert.equal(await fingerprint(), before); + report.checks.repositoryFingerprintUnchanged = true; + await exec('powershell.exe', ['-NoProfile', '-Command', `$p = Get-Process -Id ${child.pid}; if (-not $p.CloseMainWindow()) { throw 'Native window did not accept close' }`]); + const result = await Promise.race([exited, wait(15_000).then(() => { throw new Error('Native host did not exit after window close.'); })]); + assert.equal(result.code, 0); + await expect.poll(async () => (await exec('powershell.exe', ['-NoProfile', '-Command', `@(Get-Process -Id ${nodes.join(',')} -ErrorAction SilentlyContinue).Count`])).stdout.trim(), { timeout: 15_000 }).toBe('0'); + report.checks.normalWindowCloseAndSidecarCleanup = true; + await writeFile(join(output, 'windows-native-verification.json'), `${JSON.stringify(report, null, 2)}\n`); + console.log('Installed Windows window, repository opening, staged/unstaged diff, history and normal exit passed.'); +} catch (error) { + const processes = child ? await exec('powershell.exe', ['-NoProfile', '-Command', `ConvertTo-Json -Depth 4 -InputObject @{ app = @(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue | Select-Object Id,MainWindowTitle,Responding,SessionId,@{Name='WindowHandle';Expression={ [string]$_.MainWindowHandle }}); webviews = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'msedgewebview2.exe' -or $_.ProcessId -eq ${child.pid} -or $_.ParentProcessId -eq ${child.pid} } | Select-Object Name,ProcessId,ParentProcessId,CommandLine) }`]).then(result => result.stdout, failure => String(failure)) : ''; + await writeFile(join(output, 'windows-native-diagnostics.json'), `${JSON.stringify({ error: String(error), diagnostic, probeFailure, processes }, null, 2)}\n`); + const screenshot = join(output, 'windows-native-desktop-failure.png').replaceAll("'", "''"); + await exec('powershell.exe', ['-NoProfile', '-Command', `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $r=[System.Windows.Forms.SystemInformation]::VirtualScreen; $b=New-Object System.Drawing.Bitmap($r.Width,$r.Height); $g=[System.Drawing.Graphics]::FromImage($b); try { $g.CopyFromScreen($r.Left,$r.Top,0,0,$b.Size); $b.Save('${screenshot}',[System.Drawing.Imaging.ImageFormat]::Png) } finally { $g.Dispose(); $b.Dispose() }`]).catch(failure => console.log(`Desktop screenshot unavailable: ${failure.message}`)); + if (browser) for (const page of browser.contexts().flatMap(context => context.pages())) await page.screenshot({ path: join(output, 'windows-native-failure.png') }).catch(() => {}); + throw error; +} finally { + await browser?.close().catch(() => {}); + if (child && child.exitCode === null) { await exec('taskkill.exe', ['/PID', String(child.pid), '/T', '/F']).catch(() => {}); await exited.catch(() => {}); } + if (policyOverride) { + const { key, name, previous } = policyOverride; + const restore = previous === null + ? `Remove-ItemProperty -LiteralPath '${key}' -Name '${name}' -ErrorAction SilentlyContinue` + : `New-ItemProperty -LiteralPath '${key}' -Name '${name}' -PropertyType String -Value '${String(previous).replaceAll("'", "''")}' -Force | Out-Null`; + await exec('powershell.exe', ['-NoProfile', '-Command', restore]); + } + await rm(temporary, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); +}