diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b38106..550e41f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,8 +63,8 @@ jobs: run: | bash tools/package-release.sh target/cef mkdir -p release-assets - archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' -print -quit) - cp "$archive" release-assets/ + archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit) + cp release/*.tar.gz release/framely-cef.json release-assets/ cp tools/bootstrap.py release-assets/bootstrap.py cp install.sh release-assets/install.sh "$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG" @@ -93,8 +93,8 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: | cd release-assets - sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json > SHA256SUMS - printf 'Framely %s\n\nInstall on Frame over SSH using the repository install.sh.\n\nSHA256SUMS verifies downloaded archives; each Framely archive also includes an internal SHA256SUMS.\n' "$RELEASE_TAG" > release-notes.txt + sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS + printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt prerelease=false latest=true if [[ "$RELEASE_TAG" == *-* ]]; then @@ -102,5 +102,5 @@ jobs: latest=false fi gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease" - gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json + gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease" diff --git a/Cargo.lock b/Cargo.lock index 1abb5a2..14670ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "framely" -version = "0.4.2-preview.8" +version = "0.4.2-preview.9" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 37e225a..db36e27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framely" -version = "0.4.2-preview.8" +version = "0.4.2-preview.9" edition = "2021" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index 68b610c..5850ff8 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ For a Preview release, specify an actual published tag: curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3 ``` -Runtime packages include CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager. +First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager. The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent. diff --git a/README.zh-CN.md b/README.zh-CN.md index b4682f8..fe7525f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3 ``` -运行包包含 CEF,设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。 +首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。 网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。 diff --git a/docs/releases.md b/docs/releases.md index 704047d..2464232 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven Device and installer share the repository with independent versions/tags/workflows: -- Device: root Cargo version, `v` (currently `v0.4.2-preview.8`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF. -- Installer: `installer/Cargo.toml`, `installer-v` (currently `installer-v0.4.1-preview.6`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit. +- Device: root Cargo version, `v` (currently `v0.4.2-preview.9`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF. +- Installer: `installer/Cargo.toml`, `installer-v` (currently `installer-v0.4.1-preview.7`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit. Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version. @@ -47,18 +47,18 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura ```bash # After committing and pushing the intended source: -git tag v0.4.2-preview.8 -git push origin v0.4.2-preview.8 +git tag v0.4.2-preview.9 +git push origin v0.4.2-preview.9 # Independent installer release: -git tag installer-v0.4.1-preview.6 -git push origin installer-v0.4.1-preview.6 +git tag installer-v0.4.1-preview.7 +git push origin installer-v0.4.1-preview.7 ``` Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags. Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only. -Device attachments include the runtime archive, `framely-release.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts. +Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts. External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS. diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 5bc9c89..b454f40 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -6,7 +6,7 @@ ## Before installation -The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The runtime includes CEF, so users do not need Node.js or Rust on the device. +The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The complete offline installation package includes CEF, so users do not need Node.js or Rust on the device. Connect your computer and Frame to the same network. Enable developer mode and SSH on Frame, and prepare the `steamos` account password. Verify the SSH fingerprint. Installation uses sudo to create an account, systemd services and release directories. It does not automatically disable SteamOS read-only protection; it stops if required directories cannot be written. @@ -40,14 +40,26 @@ Replace the example with an existing device release tag. An explicit tag selects ## Option 3: local archive -Download `framely--linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame: +Download `framely--offline-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame: ```bash -python3 bootstrap.py install --archive "./framely--linux-arm64.tar.gz" --checksums ./SHA256SUMS +python3 bootstrap.py install --archive "./framely--offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS ``` Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`. +## Release packages and CEF + +Each device release provides three archives: + +- `framely--offline-linux-arm64.tar.gz`: complete offline package including CEF; always used for first installation. +- `framely--linux-arm64.tar.gz`: core package without CEF; selected automatically for updates by the desktop installer, SSH engine and in-app updater. +- `framely-cef--linux-arm64.tar.gz`: separate CEF runtime, available as an online download. `framely-cef.json` provides its URL, size and SHA256. + +CEF is stored under `/home/.framely/cef/`. Updates reuse the exact verified runtime; matching CEF files from older full installations migrate automatically. If the required runtime is missing or a release changes CEF, the core installation downloads the separate matching CEF archive over HTTPS and verifies its size, SHA256 and internal checksums. A complete offline package supplies the runtime without downloading it. + +Each release refers to its own CEF version, so rollback retains the previous runtime. Repair verifies it; uninstall removes the CEF cache with the programs. Local first installation must use the offline package. Local updates can use the core package when its required CEF is already installed; otherwise runtime recovery needs network access. + ## Verify installation ```bash diff --git a/docs/zh-CN/releases.md b/docs/zh-CN/releases.md index 71c1a83..fed1a0c 100644 --- a/docs/zh-CN/releases.md +++ b/docs/zh-CN/releases.md @@ -38,12 +38,18 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r 该机制不能恢复已被系统清除的 `/home` 数据,也不保证操作系统更新后无需人工操作。回滚到不支持修复模式的旧发行后,需要使用支持该模式的新发行包恢复安装。正式发布前应进行一次真实 SteamOS 更新,验证设置、插件数据、账号身份、服务恢复和 SteamVR 宿主兼容性。 +## 本体包与独立 CEF + +每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。 + +CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。 + ## GitHub Actions 发布 Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流: -- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.8`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。 -- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.6`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。 +- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.9`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。 +- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.7`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。 两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。 @@ -53,19 +59,19 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标 ```bash # 提交并推送代码后,选择需要发布的产品 -git tag v0.4.2-preview.8 -git push origin v0.4.2-preview.8 +git tag v0.4.2-preview.9 +git push origin v0.4.2-preview.9 # 独立发布安装器,不触发 Framely 构建 -git tag installer-v0.4.1-preview.6 -git push origin installer-v0.4.1-preview.6 +git tag installer-v0.4.1-preview.7 +git push origin installer-v0.4.1-preview.7 ``` 带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。 只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。 -Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为设备运行包;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。 +Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。 外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。 diff --git a/docs/zh-CN/user-guide/installation.md b/docs/zh-CN/user-guide/installation.md index 59d0691..9c114a1 100644 --- a/docs/zh-CN/user-guide/installation.md +++ b/docs/zh-CN/user-guide/installation.md @@ -6,7 +6,7 @@ ## 安装前 -目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。运行包自带 CEF,设备使用时无需安装 Node.js 或 Rust。 +目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。完整离线安装包自带 CEF,设备使用时无需安装 Node.js 或 Rust。 电脑与 Frame 连接同一网络,在 Frame 开启开发者模式并启用 SSH,准备 Steam 用户 `steamos` 的登录密码。安装会使用 sudo 创建账号、systemd 服务和发行目录;请核对 SSH 指纹。安装器不会自动解除 SteamOS 只读保护;实际所需目录不可写时会停止。 @@ -40,14 +40,26 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i ## 方式三:本地发行包 -下载设备运行包 `framely-<版本及构建号>-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录: +下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录: ```bash -python3 bootstrap.py install --archive "./framely-<版本及构建号>-linux-arm64.tar.gz" --checksums ./SHA256SUMS +python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS ``` 尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。 +## 发行包与 CEF + +每个本体 Release 提供三种压缩包: + +- `framely-<版本及构建号>-offline-linux-arm64.tar.gz`:包含 CEF 的完整离线包,首次安装固定使用此包。 +- `framely-<版本及构建号>-linux-arm64.tar.gz`:不含 CEF 的本体包;桌面安装器、SSH 引擎及本体更新器在更新时自动选择此包。 +- `framely-cef-<运行库标识>-linux-arm64.tar.gz`:单独的 CEF 运行库,提供在线下载;`framely-cef.json` 包含下载 URL、大小和 SHA256。 + +CEF 保存在 `/home/.framely/cef/<运行库标识>`。更新复用经过校验的对应运行库;旧完整安装中相同的 CEF 文件会自动迁移。所需运行库缺失或发行更换 CEF 时,本体安装流程通过 HTTPS 下载单独的匹配 CEF 包,并校验大小、SHA256 和包内哈希。完整离线包直接提供运行库,无需下载 CEF。 + +各发行引用自己的 CEF 版本,回滚时仍保留之前的运行库;修复会校验运行库,卸载则与程序一起移除 CEF 缓存。本地首次安装必须选择完整离线包;本地更新可使用不含 CEF 的包,前提是对应运行库已安装,否则恢复运行库需要联网。 + ## 验证安装 ```bash diff --git a/installer/Cargo.lock b/installer/Cargo.lock index 4cf9679..2f861f5 100644 --- a/installer/Cargo.lock +++ b/installer/Cargo.lock @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "framely-installer" -version = "0.4.1-preview.6" +version = "0.4.1-preview.7" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 50f95b3..0a91bf0 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framely-installer" -version = "0.4.1-preview.6" +version = "0.4.1-preview.7" edition = "2024" license = "AGPL-3.0-only" diff --git a/installer/README.md b/installer/README.md index d11eb48..098363a 100644 --- a/installer/README.md +++ b/installer/README.md @@ -32,6 +32,8 @@ After an operation, maintenance shows a separate success/failure card and a reco Scanning, downloads, and SSH work run on background threads, with progress and logs in the UI. The network panel button assumes port 15915; use the actual address if the device port has changed. +Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef). + ## Build Linux requires C/C++ build tools, CMake, pkg-config, and development libraries for Fontconfig/Freetype, X11/XCB, XKBCommon, Wayland, and Vulkan. macOS requires Xcode command-line tools. Windows requires MSVC, CMake, and Perl for built-in OpenSSL. GPUI Kit is pinned to 0.7.0; matching dependencies are locked in `Cargo.lock`. @@ -58,7 +60,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support. -Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v` tag (currently `installer-v0.4.1-preview.6`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs. +Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v` tag (currently `installer-v0.4.1-preview.7`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs. Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing. diff --git a/installer/README.zh-CN.md b/installer/README.zh-CN.md index 81f31e7..af1bc2c 100644 --- a/installer/README.zh-CN.md +++ b/installer/README.zh-CN.md @@ -32,6 +32,8 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修 扫描、下载和 SSH 工作在后台线程运行,界面展示进度和日志。默认网络管理面板按钮使用端口 15915;设备修改端口后应使用实际地址。 +在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。 + ## 构建 Linux 需要 C/C++ 构建工具、CMake、pkg-config、Fontconfig/Freetype、X11/XCB、XKBCommon、Wayland 和 Vulkan 开发库。macOS 需要 Xcode 命令行工具;Windows 需要 MSVC、CMake 和 Perl(用于内置 OpenSSL)。GPUI Kit 固定为 0.7.0,配套依赖由 `Cargo.lock` 锁定。 @@ -58,7 +60,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra 预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。 -安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.6`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。 +安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.7`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。 Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。 diff --git a/installer/src/main.rs b/installer/src/main.rs index 10fed63..b024452 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -463,7 +463,7 @@ impl Installer { } else { self.selected .and_then(|i| self.releases.get(i)) - .is_some_and(|r| release::package(r).is_ok()) + .is_some_and(|r| release::package_for(r, &self.chosen_action).is_ok()) } } fn action_available(&self, action: &str) -> bool { @@ -480,7 +480,7 @@ impl Installer { } else { self.selected .and_then(|i| self.releases.get(i)) - .and_then(|r| release::package(r).ok()) + .and_then(|r| release::package_for(r, "update").ok()) .map(|(package, _)| package.name.as_str()) }; self.chosen_action == "update" @@ -592,7 +592,7 @@ impl Installer { } else { let selected = selected.ok_or_else(|| anyhow::anyhow!("请选择 Release 版本"))?; - let (package, checksum) = release::package(&selected)?; + let (package, checksum) = release::package_for(&selected, &action)?; let archive = folder.path().join(&package.name); let sums = folder.path().join("SHA256SUMS"); let total = checksum diff --git a/installer/src/maintenance.rs b/installer/src/maintenance.rs index 9fcf027..fa26ecd 100644 --- a/installer/src/maintenance.rs +++ b/installer/src/maintenance.rs @@ -27,9 +27,10 @@ impl Installation { } } pub fn same_package(&self, name: &str) -> bool { - self.current_version - .as_ref() - .is_some_and(|version| name == format!("framely-{version}-linux-arm64.tar.gz")) + self.current_version.as_ref().is_some_and(|version| { + name == format!("framely-{version}-linux-arm64.tar.gz") + || name == format!("framely-{version}-offline-linux-arm64.tar.gz") + }) } } pub fn requires_package(action: &str) -> bool { diff --git a/installer/src/progress.rs b/installer/src/progress.rs index 666e558..c8db601 100644 --- a/installer/src/progress.rs +++ b/installer/src/progress.rs @@ -102,6 +102,7 @@ impl DeviceProgress { "stage" => (2, "准备设备安装目录"), "extract" => (3, "解压并校验发行文件"), "configure" => (4, "配置账号、安装文件与系统服务"), + "cef-download" => (4, "设备下载 CEF 运行库"), "activate" => (5, "启动服务并检查安装状态"), _ => continue, }; @@ -111,8 +112,17 @@ impl DeviceProgress { { continue; } - let mut value = Progress::new(Stage::Install, detail); - value.step = Some((index, 5)); + let mut value = Progress::new( + if step.step == "cef-download" { + Stage::Download + } else { + Stage::Install + }, + detail, + ); + if step.step != "cef-download" { + value.step = Some((index, 5)); + } value.completed = step.completed.unwrap_or(0); value.total = step.total; report(value); @@ -188,5 +198,12 @@ mod tests { assert_eq!(reports[0].step, Some((3, 5))); assert_eq!(reports[1].step, Some((4, 5))); assert_eq!(reports[1].fraction(), None); + decoder.push( + "FRAMELY_PROGRESS {\"step\":\"cef-download\",\"completed\":50,\"total\":100}\n", + &mut |p| reports.push(p), + ); + assert_eq!(reports[2].stage, Stage::Download); + assert_eq!(reports[2].fraction(), Some(0.5)); + assert_eq!(reports[2].step, None); } } diff --git a/installer/src/release.rs b/installer/src/release.rs index 7fbf3f2..26b9939 100644 --- a/installer/src/release.rs +++ b/installer/src/release.rs @@ -68,7 +68,11 @@ pub fn list(repo: &str) -> Result> { ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大"); let items: Vec = serde_json::from_slice(&bytes)?; let finished = items.len() < 100; - all.extend(items.into_iter().filter(|r| !r.draft && package(r).is_ok())); + all.extend( + items + .into_iter() + .filter(|r| !r.draft && (package(r).is_ok() || package_for(r, "update").is_ok())), + ); if finished { return Ok(all); } @@ -76,10 +80,26 @@ pub fn list(repo: &str) -> Result> { Ok(all) } pub fn package(release: &Release) -> Result<(&Asset, &Asset)> { + package_for(release, "install") +} +pub fn package_for<'a>(release: &'a Release, action: &str) -> Result<(&'a Asset, &'a Asset)> { + let offline = action == "install" + && release + .assets + .iter() + .any(|a| safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz")); + ensure!( + action != "install" + || offline + || !release.assets.iter().any(|a| a.name == "framely-cef.json"), + "首次安装需要包含 CEF 的完整离线包" + ); let packages: Vec<_> = release .assets .iter() - .filter(|a| safe_archive_name(&a.name)) + .filter(|a| { + safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz") == offline + }) .collect(); ensure!(packages.len() == 1, "该版本缺少唯一的 Framely ARM64 发行包"); let checksum = release @@ -234,4 +254,46 @@ mod tests { release.assets[0].name = "framely-0.4.2-build-linux-arm64.tar.gz".into(); assert!(package(&release).is_ok()); } + + #[test] + fn first_install_uses_offline_and_updates_use_core_with_three_archives() { + let mut release = Release { + tag_name: "v0.4.2".into(), + name: None, + published_at: None, + body: None, + prerelease: false, + draft: false, + assets: [ + "framely-0.4.2-build-linux-arm64.tar.gz", + "framely-0.4.2-build-offline-linux-arm64.tar.gz", + "framely-cef-154-build-linux-arm64.tar.gz", + "framely-cef.json", + "SHA256SUMS", + ] + .into_iter() + .map(|name| Asset { + name: name.into(), + browser_download_url: format!("https://example.org/{name}"), + size: 1, + }) + .collect(), + }; + assert!( + package_for(&release, "install") + .unwrap() + .0 + .name + .ends_with("-offline-linux-arm64.tar.gz") + ); + assert_eq!( + package_for(&release, "update").unwrap().0.name, + "framely-0.4.2-build-linux-arm64.tar.gz" + ); + release + .assets + .retain(|a| !a.name.ends_with("-offline-linux-arm64.tar.gz")); + assert!(package_for(&release, "install").is_err()); + assert!(package_for(&release, "update").is_ok()); + } } diff --git a/packaging/install.sh b/packaging/install.sh index 59881a2..ef3990c 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -15,7 +15,7 @@ steam_home=$(getent passwd "$steam_user" | cut -d: -f6) [[ -w /etc/systemd/system && -w /etc && -w /var/lib ]] || { echo 'System configuration is not writable. Framely did not change the read-only setting; installation stopped.' >&2; exit 1; } cd "$base" sha256sum --quiet -c SHA256SUMS -for binary in bin/framely lib/cef/framely-vr; do +for binary in bin/framely; do dependencies=$(ldd "$binary" 2>&1) [[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; } done @@ -74,6 +74,9 @@ mkdir -p "$root/logs" chmod 755 "$root" "$root/releases" "$root/plugins" "$root/data" chmod 700 "$root/logs" old=$(readlink "$root/current" || true) +if ! $repair && [[ -z $old && -f CEF_RUNTIME.json && ! -f lib/cef/libcef.so ]]; then + echo 'First installation requires the complete offline package with CEF.' >&2; exit 1 +fi if $repair; then [[ $old == "releases/$version" && -d $release && ! -L $release ]] || { echo 'Repair requires the currently installed release.' >&2; exit 1; } (cd "$release" && sha256sum --quiet -c SHA256SUMS) @@ -95,9 +98,23 @@ cleanup_install() { } trap cleanup_install EXIT if ! $repair; then - cp -a bin lib share tools VERSION SHA256SUMS ./*.sh "$stage/" + # Preserve every checksummed file, including documentation and runtime metadata. + cp -a "$base/." "$stage/" chown -R root:root "$stage" find "$stage" -type d -exec chmod 755 {} + +fi +if [[ -f CEF_RUNTIME.json ]]; then + # Cache exact runtimes outside releases so upgrades and rollback share files. + cef_runtime=$(python3 "$base/tools/cef-runtime.py" prepare "$base" "$store") + if $repair; then cef_release="$release"; else cef_release="$stage"; fi + python3 "$base/tools/cef-runtime.py" attach "$cef_release" "$cef_runtime" + (cd "$cef_release" && sha256sum --quiet -c SHA256SUMS) + dependencies=$(ldd "$cef_release/lib/cef/framely-vr" 2>&1) +else + dependencies=$(ldd "$base/lib/cef/framely-vr" 2>&1) +fi +[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; } +if ! $repair; then mv "$stage" "$release" release_created=true fi diff --git a/packaging/uninstall.sh b/packaging/uninstall.sh index 79035f1..06e33b6 100755 --- a/packaging/uninstall.sh +++ b/packaging/uninstall.sh @@ -29,7 +29,7 @@ rm -f /etc/systemd/system/framely.service /etc/systemd/system/framely-session.se systemctl daemon-reload rm -f "$root/current" "$root/previous-release" rm -f /home/.framely/state/current /home/.framely/state/previous-release /home/.framely/repair.sh -rm -rf /home/.framely/releases /run/framely +rm -rf /home/.framely/releases /home/.framely/cef /run/framely rm -f "$root/releases" /home/.framely/state/releases # External plugin effects cannot be reliably erased; retain data for recovery. echo 'All plugins and Framely were uninstalled. Saved settings and plugin data were retained.' diff --git a/src/update.rs b/src/update.rs index f30d831..fed851f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -69,6 +69,10 @@ pub fn create_descriptor(archive: &Path, url: &str, output: &Path, changelog: &s .strip_prefix("framely-") .and_then(|n| n.strip_suffix("-linux-arm64.tar.gz")) .context("必须使用 Framely 发行包命名")?; + ensure!( + !version.ends_with("-offline"), + "更新清单必须使用不含 CEF 的本体包" + ); let release = Release { schema_version: 1, version: version.into(), @@ -608,6 +612,21 @@ mod tests { } } #[test] + fn split_release_always_resolves_the_core_archive() { + let mut item = fixture("0.4.2-preview.1", false); + item.0["assets"].as_array_mut().unwrap().extend([ + json!({"name":"framely-0.4.2-preview.1-012345abcdef-offline-linux-arm64.tar.gz","browser_download_url":"https://example.org/offline","size":900}), + json!({"name":"framely-cef-154-build-linux-arm64.tar.gz","browser_download_url":"https://example.org/cef","size":800}), + ]); + let resolved = resolve_fixture(UpdateChannel::Testing, vec![item]) + .unwrap() + .unwrap(); + assert!(resolved + .0 + .url + .ends_with("framely-0.4.2-preview.1-012345abcdef-linux-arm64.tar.gz")); + } + #[test] fn custom_descriptors_distinguish_channels_and_ignore_packaging_hashes() { for (version, channel) in [ ("0.4.2", UpdateChannel::Stable), diff --git a/tests/test_cef_runtime.py b/tests/test_cef_runtime.py new file mode 100644 index 0000000..f850bec --- /dev/null +++ b/tests/test_cef_runtime.py @@ -0,0 +1,126 @@ +import importlib.util +import io +import json +import pathlib +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = pathlib.Path(__file__).parents[1] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +packager = load('packager', ROOT / 'tools/package-runtime.py') +runtime = load('cef_runtime', ROOT / 'tools/cef-runtime.py') +bootstrap = load('bootstrap', ROOT / 'tools/bootstrap.py') + + +class CEFRuntimeTests(unittest.TestCase): + def fixture(self, folder, version='1.0.0-build'): + stage = folder / ('framely-' + version) + files = {'VERSION': version, 'bin/framely': 'core', 'lib/cef/framely-vr': 'host', + 'lib/cef/libcef.so': 'browser', 'lib/cef/icudtl.dat': 'icu', + 'lib/cef/locales/en-US.pak': 'locale', 'share/licenses/cef.txt': 'license', + 'share/licenses/cef-credits.html': 'credits', 'share/licenses/openvr.txt': 'openvr', + 'install.sh': 'install', 'uninstall.sh': 'uninstall'} + for name, data in files.items(): + p = stage / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(data) + (stage / 'tools').mkdir() + shutil.copyfile(ROOT / 'tools/bootstrap.py', stage / 'tools/bootstrap.py') + cef = folder / 'cef' + (cef / 'include').mkdir(parents=True) + (cef / 'include/cef_version.h').write_text('#define CEF_VERSION "154.0.32+g682c378+chromium-154.0.8037.58"\n') + packager.package(stage, cef, 'owner/repo', 'v1.0.0') + return stage + + def test_archives_checksums_and_action_selection(self): + with tempfile.TemporaryDirectory() as temp: + folder = pathlib.Path(temp) + stage = self.fixture(folder) + archives = list(folder.glob('*.tar.gz')) + self.assertEqual(len(archives), 3) + for archive in archives: + bootstrap.verify_archive(archive, folder / 'SHA256SUMS') + assets = {p.name: {} for p in archives} | {'SHA256SUMS': {}, 'framely-cef.json': {}} + self.assertEqual(bootstrap.select_package(assets, 'install'), stage.name + '-offline-linux-arm64.tar.gz') + self.assertEqual(bootstrap.select_package(assets, 'update'), stage.name + '-linux-arm64.tar.gz') + del assets[stage.name + '-offline-linux-arm64.tar.gz'] + with self.assertRaises(ValueError): + bootstrap.select_package(assets, 'install') + for archive in archives: + extracted = bootstrap.extract_archive(archive, folder / archive.name.replace('.tar.gz', ''), + runtime=archive.name.startswith('framely-cef-')) + subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=extracted, check=True) + if archive.name == stage.name + '-linux-arm64.tar.gz': + self.assertFalse((extracted / 'lib/cef/libcef.so').exists()) + self.assertFalse((extracted / 'share/licenses/cef-credits.html').exists()) + self.assertTrue((extracted / 'lib/cef/framely-vr').exists()) + + def test_offline_install_core_update_and_legacy_migration_without_download(self): + with tempfile.TemporaryDirectory() as temp: + folder = pathlib.Path(temp) + stage = self.fixture(folder) + store = folder / 'store' + store.mkdir() + cached = runtime.prepare(stage, store) + core = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'core') + self.assertEqual(runtime.prepare(core, store), cached) + runtime.attach(core, cached) + self.assertTrue((core / 'lib/cef/libcef.so').is_symlink()) + self.assertEqual((core / 'lib/cef/libcef.so').read_text(), 'browser') + self.assertEqual((core / 'lib/cef/framely-vr').read_text(), 'host') + subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=core, check=True) + # A pre-split installation with the same files seeds the new cache. + legacy_store = folder / 'legacy' + (legacy_store / 'state').mkdir(parents=True) + (legacy_store / 'state/current').symlink_to(stage) + unattached = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'legacy-core') + self.assertEqual(runtime.prepare(unattached, legacy_store).name, cached.name) + (cached / 'lib/cef/libcef.so').write_text('tampered') + with self.assertRaises(ValueError): + runtime.prepare(core, store) + + def test_download_matching_runtime_and_reject_tampering(self): + with tempfile.TemporaryDirectory() as temp: + folder = pathlib.Path(temp) + stage = self.fixture(folder) + core = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'core') + requirement = json.loads((core / 'CEF_RUNTIME.json').read_text()) + data = (folder / requirement['archive']).read_bytes() + # The downloaded helper imports the shipped bootstrap module. + for bad in (True, False): + store = folder / ('bad-store' if bad else 'download-store') + store.mkdir() + response = io.BytesIO(data[:-1] if bad else data) + with patch('urllib.request.OpenerDirector.open', return_value=response): + if bad: + with self.assertRaises(ValueError): + runtime.prepare(core, store) + self.assertFalse((store / 'cef' / requirement['id']).exists()) + else: + cached = runtime.prepare(core, store) + self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser') + + def test_runtime_extraction_rejects_links(self): + with tempfile.TemporaryDirectory() as temp: + folder = pathlib.Path(temp) + archive = folder / 'bad.tar.gz' + with tarfile.open(archive, 'w:gz') as tar: + entry = tarfile.TarInfo('framely-cef-1/lib/cef/libcef.so') + entry.type = tarfile.SYMTYPE + entry.linkname = '/etc/shadow' + tar.addfile(entry) + with self.assertRaises(ValueError): + bootstrap.extract_archive(archive, folder / 'out', runtime=True) + self.assertFalse((folder / 'out').exists()) diff --git a/tests/test_install_retry.py b/tests/test_install_retry.py index 4e0cf98..3f07287 100644 --- a/tests/test_install_retry.py +++ b/tests/test_install_retry.py @@ -1,5 +1,7 @@ """Exercise production install cleanup with all machine paths and tools mocked.""" import os +import hashlib +import json import pathlib import subprocess import tempfile @@ -13,7 +15,10 @@ class InstallRetry(unittest.TestCase): def test_fresh_install_and_system_update_repair(self): self.exercise_install(legacy=False) - def exercise_install(self, legacy): + def test_split_runtime_install_repair_update_rollback_and_uninstall(self): + self.exercise_install(legacy=False, split=True) + + def exercise_install(self, legacy, split=False): with tempfile.TemporaryDirectory() as tmp: base = pathlib.Path(tmp) state, store, etc = base / 'state', base / 'store', base / 'etc' @@ -30,6 +35,14 @@ class InstallRetry(unittest.TestCase): (package / 'VERSION').write_text('test-version\n') (package / 'SHA256SUMS').write_text('') (package / 'lib/cef/framely-vr').write_text('mock') + if split: + for name, content in [('lib/cef/libcef.so', 'browser'), ('share/licenses/cef.txt', 'license')]: + path = package / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + (package / 'CEF_RUNTIME.json').write_text(json.dumps({'schemaVersion': 1, 'id': 'test-cef'})) + (package / 'CEF_SHA256SUMS').write_text(''.join(hashlib.sha256((package / name).read_bytes()).hexdigest() + ' ' + name + '\n' for name in ['lib/cef/libcef.so', 'share/licenses/cef.txt'])) + (package / 'tools/cef-runtime.py').write_text((pathlib.Path(__file__).parents[1] / 'tools/cef-runtime.py').read_text()) (package / 'bin/framely').write_text('#!/bin/sh\nexit 0\n') (package / 'bin/framely').chmod(0o755) repair_source = (pathlib.Path(__file__).parents[1] / 'packaging/repair.sh').read_text() @@ -85,6 +98,9 @@ class InstallRetry(unittest.TestCase): second = subprocess.run(['bash', str(script), 'steam'], env=env, capture_output=True, text=True) self.assertEqual(second.returncode, 0, second.stdout + second.stderr) self.assertTrue((store / 'releases/test-version').is_dir()) + if split: + self.assertTrue((store / 'releases/test-version/lib/cef/libcef.so').is_symlink()) + self.assertEqual((store / 'cef/test-cef/lib/cef/libcef.so').read_text(), 'browser') if not legacy: (state / 'state.json').write_text('{"settings":"preserved"}') (state / 'update-source.json').write_text('{"url":"https://example.org/update.json"}') @@ -125,6 +141,25 @@ class InstallRetry(unittest.TestCase): (state / 'state.json').unlink() state.rmdir() state.symlink_to(store / 'state') + if split: + # Upgrade using only the core payload; old and new releases retain + # their own host executable while sharing one verified runtime. + (package / 'lib/cef/libcef.so').unlink() + (package / 'share/licenses/cef.txt').unlink() + (package / 'VERSION').write_text('next-version\n') + (package / 'lib/cef/framely-vr').write_text('new host') + upgraded = subprocess.run(['bash', str(script), 'steam'], env=env, capture_output=True, text=True) + self.assertEqual(upgraded.returncode, 0, upgraded.stdout + upgraded.stderr) + self.assertEqual((state / 'current/lib/cef/framely-vr').read_text(), 'new host') + self.assertEqual((state / 'previous-release').read_text(), 'releases/test-version\n') + self.assertEqual((state / 'current/lib/cef/libcef.so').read_text(), 'browser') + rollback_source = (pathlib.Path(__file__).parents[1] / 'packaging/rollback.sh').read_text().replace('if [[ $EUID != 0 ]]; then exec sudo -- bash "$0" "$@"; fi', '').replace('/var/lib/framely', str(state)) + rollback_script = base / 'rollback.sh' + rollback_script.write_text(rollback_source) + rolled = subprocess.run(['bash', str(rollback_script)], env=env, capture_output=True, text=True) + self.assertEqual(rolled.returncode, 0, rolled.stdout + rolled.stderr) + self.assertEqual((state / 'current/lib/cef/framely-vr').read_text(), 'mock') + self.assertEqual((state / 'current/lib/cef/libcef.so').read_text(), 'browser') uninstall_source = (pathlib.Path(__file__).parents[1] / 'packaging/uninstall.sh').read_text() uninstall_source = uninstall_source.replace('if [[ $EUID != 0 ]]; then exec sudo -- bash "$0" "$@"; fi', '') uninstall_source = uninstall_source.replace('/etc/systemd/system', str(units)).replace('/var/lib/framely', str(state)) @@ -142,6 +177,7 @@ class InstallRetry(unittest.TestCase): removed = subprocess.run(['bash', str(uninstall)], env=env, capture_output=True, text=True) self.assertEqual(removed.returncode, 0, removed.stdout + removed.stderr) self.assertFalse((store / 'releases').exists()) + self.assertFalse((store / 'cef').exists()) self.assertFalse((store / 'repair.sh').exists()) self.assertEqual((store / 'state/state.json').read_text(), '{"settings":"preserved"}') self.assertEqual((store / 'data/plugin/payload').read_text(), 'plugin data') diff --git a/tools/bootstrap.py b/tools/bootstrap.py index 0a66a4a..b535ccb 100644 --- a/tools/bootstrap.py +++ b/tools/bootstrap.py @@ -40,14 +40,22 @@ def fetch(url): urllib.request.Request(url, headers={'User-Agent': 'Framely-Installer', 'Accept': 'application/vnd.github+json'}), timeout=60) -def download(url, destination, limit): +def download(url, destination, limit, progress=None): with fetch(url) as response, open(destination, 'xb') as output: size = 0 + reported_at = time.monotonic() + if progress: + progress(0) while block := response.read(256 * 1024): size += len(block) if size > limit: raise ValueError('Download exceeds size limit') output.write(block) + if progress and time.monotonic() - reported_at >= 0.2: + progress(size) + reported_at = time.monotonic() + if progress: + progress(size) def verify_archive(archive, checksums): @@ -75,7 +83,7 @@ def verify_archive(archive, checksums): raise ValueError('Archive SHA256 mismatch; installation stopped') -def extract_archive(archive, destination, progress=None): +def extract_archive(archive, destination, progress=None, runtime=False): # Check all headers before writing, and never use tar's link extraction. with tarfile.open(archive, 'r:gz') as source: members = source.getmembers() @@ -115,9 +123,10 @@ def extract_archive(archive, destination, progress=None): path.chmod(0o755 if member.mode & 0o111 else 0o644) base = destination / prefixes.pop() version = (base / 'VERSION').read_text().strip() - if base.name != 'framely-' + version: + if base.name != ('framely-cef-' if runtime else 'framely-') + version: raise ValueError('Release VERSION does not match directory') - for required in ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely']: + required_files = ['SHA256SUMS', 'lib/cef/libcef.so'] if runtime else ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely'] + for required in required_files: if not (base / required).is_file(): raise ValueError('Missing release file: ' + required) if progress: @@ -125,6 +134,18 @@ def extract_archive(archive, destination, progress=None): return base +def select_package(assets, action): + packages = [name for name in assets if re.fullmatch(r'framely-[0-9][a-zA-Z0-9.+-]*-linux-arm64\.tar\.gz', name)] + offline = [name for name in packages if name.endswith('-offline-linux-arm64.tar.gz')] + core = [name for name in packages if name not in offline] + selected = offline if action == 'install' and offline else core + if action == 'install' and not offline and 'framely-cef.json' in assets: + raise ValueError('First installation requires the complete offline package') + if len(selected) != 1 or 'SHA256SUMS' not in assets: + raise ValueError('Release must contain one matching ARM64 package and SHA256SUMS') + return selected[0] + + def current_release(): state = pathlib.Path('/home/.framely/state') if not state.is_dir(): @@ -172,6 +193,8 @@ def root_operation(args): shutil.copyfile(args.checksums, staged_checksums) verify_archive(staged_archive, staged_checksums) base = extract_archive(staged_archive, pathlib.Path(work) / 'unpacked', report) + if args.action == 'install' and (base / 'CEF_RUNTIME.json').exists() and not (base / 'lib/cef/libcef.so').is_file(): + raise ValueError('First installation requires the complete offline package with CEF') report('extract') subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True) else: @@ -185,7 +208,8 @@ def root_operation(args): same = False if same: command.append('--repair') - subprocess.run(command + [args.user], check=True) + subprocess.run(command + [args.user], check=True, + env=dict(os.environ, FRAMELY_PROGRESS_JSON='1' if getattr(args, 'progress_json', False) else '0')) report('activate') # Preconfigure the official update source without requiring agreement acceptance. state = pathlib.Path('/home/.framely/state') @@ -256,13 +280,11 @@ def main(argv=None): raise ValueError('Release metadata too large') release = json.loads(metadata) assets = {asset['name']: asset for asset in release['assets']} - packages = [name for name in assets if re.fullmatch(r'framely-[0-9][a-zA-Z0-9.+-]*-linux-arm64\.tar\.gz', name)] - if len(packages) != 1 or 'SHA256SUMS' not in assets: - raise ValueError('Release must contain one ARM64 package and SHA256SUMS') - args.archive = pathlib.Path(folder) / packages[0] + package = select_package(assets, args.action) + args.archive = pathlib.Path(folder) / package args.checksums = pathlib.Path(folder) / 'SHA256SUMS' - print('Downloading ' + packages[0], flush=True) - download(assets[packages[0]]['browser_download_url'], args.archive, MAX_ARCHIVE) + print('Downloading ' + package, flush=True) + download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE) download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2) args.archive, args.checksums = args.archive.resolve(), args.checksums.resolve() if args.progress_json: diff --git a/tools/cef-runtime.py b/tools/cef-runtime.py new file mode 100644 index 0000000..45ee18c --- /dev/null +++ b/tools/cef-runtime.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Verify, cache and attach the exact CEF runtime required by a release.""" +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + + +def verify(root, checksums): + for line in checksums.splitlines(): + match = re.fullmatch(r'([a-f0-9]{64}) (.+)', line) + if not match: + raise ValueError('Invalid CEF checksums') + path = pathlib.PurePosixPath(match[2]) + if path.is_absolute() or '..' in path.parts or '\\' in match[2] or not ( + match[2].startswith('lib/cef/') or match[2] in ('share/licenses/cef.txt', 'share/licenses/cef-credits.html')): + raise ValueError('Invalid CEF payload path') + with (root / path).open('rb') as source: + if hashlib.file_digest(source, 'sha256').hexdigest() != match[1]: + raise ValueError('CEF checksum mismatch: ' + match[2]) + if not any(line.endswith(' lib/cef/libcef.so') for line in checksums.splitlines()): + raise ValueError('CEF library checksum is missing') + + +def prepare(base, store): + base, store = pathlib.Path(base), pathlib.Path(store) + requirement = json.loads((base / 'CEF_RUNTIME.json').read_text()) + runtime_id = requirement['id'] + if requirement['schemaVersion'] != 1 or not re.fullmatch(r'[a-zA-Z0-9.+-]+', runtime_id) or '..' in runtime_id: + raise ValueError('Invalid CEF runtime identity') + expected = (base / 'CEF_SHA256SUMS').read_text() + parent = store / 'cef' + if parent.is_symlink(): + raise ValueError('CEF storage must not be a symlink') + parent.mkdir(mode=0o755, exist_ok=True) + cached = parent / runtime_id + if cached.exists() or cached.is_symlink(): + if cached.is_symlink() or not cached.is_dir(): + raise ValueError('Invalid CEF cache directory') + verify(cached, expected) + return cached + with tempfile.TemporaryDirectory(prefix='.install-', dir=parent) as work: + work = pathlib.Path(work) + payload = work / 'payload' + # Older full installations can seed the cache without a network request. + for candidate in (base, store / 'state/current'): + try: + verify(candidate, expected) + except (OSError, ValueError): + continue + payload.mkdir() + for line in expected.splitlines(): + relative = line[66:] + target = payload / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(candidate / relative, target) + target.chmod(0o755 if relative.startswith('lib/cef/') and (candidate / relative).stat().st_mode & 0o111 else 0o644) + break + else: + spec = importlib.util.spec_from_file_location('bootstrap', base / 'tools/bootstrap.py') + engine = importlib.util.module_from_spec(spec) + spec.loader.exec_module(engine) + archive_name = f'framely-cef-{runtime_id}-linux-arm64.tar.gz' + if requirement['archive'] != archive_name: + raise ValueError('Invalid CEF archive name') + archive = work / archive_name + print('Downloading CEF ' + requirement['version'], file=sys.stderr, flush=True) + def progress(completed): + if os.environ.get('FRAMELY_PROGRESS_JSON') == '1': + print('FRAMELY_PROGRESS ' + json.dumps(dict(step='cef-download', completed=completed, total=requirement['size'])), file=sys.stderr, flush=True) + engine.download(requirement['url'], archive, engine.MAX_ARCHIVE, progress) + if archive.stat().st_size != requirement['size']: + raise ValueError('CEF archive length mismatch') + with archive.open('rb') as source: + if hashlib.file_digest(source, 'sha256').hexdigest() != requirement['sha256']: + raise ValueError('CEF archive SHA256 mismatch') + # The same link-free extraction policy protects standalone runtimes. + payload = engine.extract_archive(archive, work / 'unpacked', runtime=True) + if (payload / 'VERSION').read_text().strip() != runtime_id: + raise ValueError('CEF archive version mismatch') + subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=payload, check=True) + verify(payload, expected) + for directory, _, _ in os.walk(payload): + pathlib.Path(directory).chmod(0o755) + payload.rename(cached) + return cached + + +def attach(release, cached): + release, cached = pathlib.Path(release), pathlib.Path(cached) + for folder in ('lib/cef', 'share/licenses'): + target_dir = release / folder + target_dir.mkdir(parents=True, exist_ok=True) + for source in (cached / folder).iterdir(): + target = target_dir / source.name + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.is_dir(): + shutil.rmtree(target) + target.symlink_to(source) + + +if __name__ == '__main__': + try: + if sys.argv[1] == 'prepare': + print(prepare(*sys.argv[2:])) + elif sys.argv[1] == 'attach': + attach(*sys.argv[2:]) + else: + raise ValueError('Invalid CEF operation') + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print('Framely CEF: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/tools/package-release.sh b/tools/package-release.sh index 45213fe..9990fb4 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -43,13 +43,10 @@ cp -a assets/branding "$stage/tools/source/assets/" cp -a examples/showcase "$stage/tools/source/examples/" cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/" cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/" -cp tools/extract-release.py tools/bootstrap.py "$stage/tools/" +cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py "$stage/tools/" find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} + cp -a docs "$stage/share/" printf '%s\n' "$build_id" > "$stage/VERSION" chmod 755 "$stage"/*.sh "$stage/bin/"* "$stage/lib/cef/framely-vr" -(cd "$stage" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS) -tar -C "$base/release" -czf "$base/release/framely-$build_id-linux-arm64.tar.gz" "framely-$build_id" -(cd "$base/release" && sha256sum "framely-$build_id-linux-arm64.tar.gz" > "framely-$build_id-linux-arm64.tar.gz.sha256") -(cd "$base/release" && cp "framely-$build_id-linux-arm64.tar.gz.sha256" SHA256SUMS) +python3 tools/package-runtime.py "$stage" "$cef" "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" "${RELEASE_TAG:-v$version}" echo "Package: release/framely-$build_id-linux-arm64.tar.gz" diff --git a/tools/package-runtime.py b/tools/package-runtime.py new file mode 100644 index 0000000..7cbad69 --- /dev/null +++ b/tools/package-runtime.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Produce core, offline and immutable CEF archives from a built release.""" +import hashlib +import json +import pathlib +import re +import shutil +import sys +import tarfile +import tempfile + + +def digest(path): + with path.open('rb') as source: + return hashlib.file_digest(source, 'sha256').hexdigest() + + +def checksums(root): + return ''.join(f'{digest(p)} {p.relative_to(root).as_posix()}\n' + for p in sorted(root.rglob('*')) if p.is_file() and p.name != 'SHA256SUMS') + + +def archive(root, output, prefix=None): + with tarfile.open(output, 'w:gz') as tar: + tar.add(root, arcname=prefix or root.name) + output.with_name(output.name + '.sha256').write_text(f'{digest(output)} {output.name}\n') + + +def package(stage, cef, repo, tag): + stage, cef = pathlib.Path(stage), pathlib.Path(cef) + output = stage.parent + version = re.search(r'^#define CEF_VERSION "([^"]+)"', + (cef / 'include/cef_version.h').read_text(), re.M)[1] + with tempfile.TemporaryDirectory(prefix='.cef-package-', dir=output) as work: + runtime = pathlib.Path(work) / 'runtime' + (runtime / 'lib/cef').mkdir(parents=True) + (runtime / 'share/licenses').mkdir(parents=True) + for source in (stage / 'lib/cef').iterdir(): + if source.name != 'framely-vr': + target = runtime / 'lib/cef' / source.name + if source.is_dir(): + shutil.copytree(source, target) + else: + shutil.copy2(source, target) + for source in (stage / 'share/licenses').glob('cef*'): + shutil.copy2(source, runtime / 'share/licenses' / source.name) + payload_sums = checksums(runtime) + runtime_id = version.split('+chromium')[0].replace('+', '-') + '-' + hashlib.sha256(payload_sums.encode()).hexdigest()[:12] + (runtime / 'VERSION').write_text(runtime_id + '\n') + (runtime / 'SHA256SUMS').write_text(checksums(runtime)) + cef_archive = output / f'framely-cef-{runtime_id}-linux-arm64.tar.gz' + archive(runtime, cef_archive, 'framely-cef-' + runtime_id) + requirement = dict(schemaVersion=1, id=runtime_id, version=version, + archive=cef_archive.name, size=cef_archive.stat().st_size, + sha256=digest(cef_archive), + url=f'https://github.com/{repo}/releases/download/{tag}/{cef_archive.name}') + (stage / 'CEF_RUNTIME.json').write_text(json.dumps(requirement, indent=2) + '\n') + (stage / 'CEF_SHA256SUMS').write_text(payload_sums) + (output / 'framely-cef.json').write_text(json.dumps(requirement, indent=2) + '\n') + (stage / 'SHA256SUMS').write_text(checksums(stage)) + archive(stage, output / f'{stage.name}-offline-linux-arm64.tar.gz') + core = pathlib.Path(work) / 'core' + shutil.copytree(stage, core, ignore=lambda directory, names: + [name for name in names if pathlib.Path(directory) == stage / 'lib/cef' and name != 'framely-vr' + or pathlib.Path(directory) == stage / 'share/licenses' and name.startswith('cef')]) + (core / 'SHA256SUMS').write_text(checksums(core)) + archive(core, output / f'{stage.name}-linux-arm64.tar.gz', stage.name) + outputs = [output / f'{stage.name}-linux-arm64.tar.gz', output / f'{stage.name}-offline-linux-arm64.tar.gz', cef_archive, output / 'framely-cef.json'] + (output / 'SHA256SUMS').write_text(''.join(f'{digest(path)} {path.name}\n' for path in outputs)) + print(f'Core: {stage.name}-linux-arm64.tar.gz') + print(f'Offline: {stage.name}-offline-linux-arm64.tar.gz') + print(f'CEF: {cef_archive.name}') + + +if __name__ == '__main__': + package(*sys.argv[1:])