mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 02:01:27 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5089e9bea |
No files matched your search
Generated
+1
-1
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.7"
|
||||
version = "0.4.2-preview.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.7"
|
||||
version = "0.4.2-preview.8"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -94,3 +94,5 @@ Optional `quickPage` points to a payload bundle. `windows` defaults to an empty
|
||||
Dependencies use ranges or `{version, source}`, with source pointing to an HTTPS catalog. Conflicts use ranges. Missing optional dependencies do not prevent installation; exclusive resources rely on author declarations. See [relationships](../plugin-relationships.md).
|
||||
|
||||
Manifest JSON is limited to 256 KiB and payload paths to 512 bytes. Payload paths allow ASCII letters/digits and `/._-+` only. Absolute paths, empty segments, `.`/`..`, symlinks and undeclared archive files are forbidden. Successful validation is not a safety review; users still assess runtime privileges.
|
||||
|
||||
`backend.uiVisibilityEvents` (boolean, default false) subscribes to manager-only `framely.ui.visibility` RPCs. Reply promptly. Delivery does not depend on mounted pages and requires a host supporting visibility events.
|
||||
@@ -111,3 +111,9 @@ Import `registerLifecycle`, `LifecycleContext`, `LifecyclePhase`, `LifecycleCall
|
||||
## Preview and privilege boundaries
|
||||
|
||||
Development preview simulates windows, notifications and some bridge operations. Test backend calls, real dependencies, VR inputs, haptics and runtime identity on the device. Pages cannot directly call management APIs or reserved `framely.lifecycle.*` methods. Runtime identity is configured in Manifest, not granted by the SDK.
|
||||
|
||||
## Headset visibility and capture pause
|
||||
|
||||
`framely.ui.getVisibility()` returns `{ known, captureObscured, pageVisible, sequence, sessionId }`. `framely.ui.onVisibilityChanged(callback, onError?)` subscribes and immediately queries the current snapshot; it returns an unsubscribe function. `useVisibility()` provides the same fields and `error`. Capture visibility combines the actual visibility of the quick panel, manager and every plugin window, excluding notifications and dock icons. Remote browser pages have `pageVisible: false` and cannot report headset visibility.
|
||||
|
||||
Declare `backend.uiVisibilityEvents: true` to receive the reserved JSON-line RPC `framely.ui.visibility` at startup and on changes, independently of mounted pages. Reply with the request id. The backend snapshot includes `known`, `captureObscured`, `sequence` and `views`; `pageVisible` is frontend-only. A native heartbeat missing for more than two seconds makes `known` false. Capture backends should pause on `!known || captureObscured`. Existing backends do not receive this RPC without opting in. Node/TS can use `registerVisibility` from `@framely/sdk/visibility`; Python supports `serve(dispatch, visibility=callback)`.
|
||||
+6
-6
@@ -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<version>` (currently `v0.4.2-preview.7`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.5`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.8`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.6`), `.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,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.4.2-preview.7
|
||||
git push origin v0.4.2-preview.7
|
||||
git tag v0.4.2-preview.8
|
||||
git push origin v0.4.2-preview.8
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.5
|
||||
git push origin installer-v0.4.1-preview.5
|
||||
git tag installer-v0.4.1-preview.6
|
||||
git push origin installer-v0.4.1-preview.6
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -94,3 +94,5 @@
|
||||
依赖值可为版本范围字符串,或 `{version, source}`;source 是 HTTPS `catalog.json` 地址。冲突值是版本范围。可选依赖缺失不阻止安装,独占资源只按作者声明校验。[依赖与冲突](../plugin-relationships.md)
|
||||
|
||||
清单 JSON 最大 256 KiB,载荷路径最多 512 字节。载荷路径仅允许 ASCII 字母、数字及 `/._-+`,禁止绝对路径、空段、`.`、`..`、软链接和未声明的附加包文件。清单通过校验后,运行权限仍需用户判断;文件哈希不代表插件安全审核。
|
||||
|
||||
`backend.uiVisibilityEvents`(boolean,默认 false):订阅宿主专用 `framely.ui.visibility` RPC。后端必须及时回复;通知无需页面挂载。该字段需要支持可见性事件的新版宿主。
|
||||
@@ -111,3 +111,16 @@ serve(dispatch)
|
||||
## 预览与权限边界
|
||||
|
||||
`npm run dev` 只模拟窗口、通知及部分桥接调用;后端、真实依赖状态、VR 输入、振动和身份行为需要实机测试。插件页面不能直接调用管理面板 API,也不能调用 `framely.lifecycle.*` 保留方法。SDK 本身不声明权限,运行用户由 Manifest 决定。
|
||||
|
||||
## 头显可见性与自动暂停
|
||||
|
||||
`framely.ui.getVisibility()` 返回 `{ known, captureObscured, pageVisible, sequence, sessionId }`。`framely.ui.onVisibilityChanged(callback, onError?)` 订阅后立即查询当前状态,返回取消订阅函数;`useVisibility()` 提供同样状态及 `error`。`captureObscured` 为原生快捷面板、Framely 管理器和全部插件窗口的真实可见性并集,排除通知和 dock 图标。`pageVisible` 表示当前插件所在原生视图可见;远程浏览器始终为 false。浏览器不能上报或修改头显状态。
|
||||
|
||||
录像后端应在 manifest 中声明 `backend.uiVisibilityEvents: true`,处理宿主专用 JSON-line RPC `framely.ui.visibility`,正常回复请求 id。参数包含 `known`、`captureObscured`、`sequence` 和 `views`,不包含前端专属的 `pageVisible`。启动后首次通知包含当前状态,之后在状态变化时通知。原生宿主超过两秒没有心跳时,`known` 变为 false;录像应在 `!known || captureObscured` 时暂停。页面关闭不影响后端通知。旧插件默认不订阅。Node/TS 可从 `@framely/sdk/visibility` 使用 `registerVisibility`,Python `serve(dispatch, visibility=callback)` 支持相同通知。
|
||||
|
||||
```ts
|
||||
const off = framely.ui.onVisibilityChanged(state => {
|
||||
console.log(state.known, state.captureObscured, state.pageVisible);
|
||||
});
|
||||
// 页面卸载时调用 off()
|
||||
```
|
||||
@@ -42,8 +42,8 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.7`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.5`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- 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。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
|
||||
@@ -53,12 +53,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.7
|
||||
git push origin v0.4.2-preview.7
|
||||
git tag v0.4.2-preview.8
|
||||
git push origin v0.4.2-preview.8
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.5
|
||||
git push origin installer-v0.4.1-preview.5
|
||||
git tag installer-v0.4.1-preview.6
|
||||
git push origin installer-v0.4.1-preview.6
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
Generated
+1
-1
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.5"
|
||||
version = "0.4.1-preview.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.5"
|
||||
version = "0.4.1-preview.6"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+4
-2
@@ -18,7 +18,9 @@ Only one installer instance can run per user; additional instances exit immediat
|
||||
2. Discovery prioritizes `192.168.*` subnets, with at most 1024 addresses per scan. Name discovery and IP probing run together. Only devices whose hostname is `frame` are listed, with SSH availability indicated. Other devices and addresses without a confirmed name are omitted. Manual IP and SSH port entry is always available.
|
||||
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. Changed saved fingerprints cause the connection to be rejected. After verifying a device reinstall, you can remove its entry from `known-hosts.json` in the configuration directory.
|
||||
4. Select a release or a local `framely-*-linux-arm64.tar.gz` together with its external `SHA256SUMS` or matching `.sha256` file. Local packages do not require GitHub access. Prereleases are hidden by default.
|
||||
5. Choose an operation on Install & Maintenance and confirm. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
|
||||
5. Choose an operation on Install & Maintenance and confirm the device, version and action in the modal dialog. Cancel or press Esc to return without executing. Errors and operation results also appear in dialogs. SSH fingerprint verification stays on the connection page. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
|
||||
|
||||
During execution, the main page shows download, transfer and device-operation progress. Downloads and SFTP transfers report actual bytes and percentages; local packages skip downloading. The device reports verification, staging, extraction, service configuration and startup checks. Extraction reports actual bytes; steps without a measurable total show an indeterminate indicator and the current step. Logs remain available on the same page. Completion is reported only after the operation and post-operation checks succeed.
|
||||
|
||||
After local verification, built-in SSH/SFTP uploads the package. The device verifies it again and extracts it safely. Passwords are used only in memory and are not written to configuration, command arguments, or environment variables. Operations provide the sudo password through SSH standard input. SSH and sudo use the same entered password, matching the default Frame account configuration.
|
||||
|
||||
@@ -56,7 +58,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<VERSION>` tag (currently `installer-v0.4.1-preview.5`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` 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<VERSION>` 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<VERSION>` 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.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
2. 默认优先扫描 `192.168.*` 网段(每次最多 1024 个地址)。名称发现和 IP 探测同时开始;只列出主机名为 `frame` 的设备,并标注 SSH 是否可用。其他设备和无法确认名称的地址不会显示。手动 IP 和 SSH 端口始终可用。
|
||||
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。已保存的指纹发生变化时拒绝连接;核实设备重装后可手动移除配置目录中的对应 `known-hosts.json` 条目。
|
||||
4. 从 Releases 选择版本,或选择本地 `framely-*-linux-arm64.tar.gz` 和压缩包外部的 `SHA256SUMS`(也可选择对应 `.sha256` 文件)。本地包不需要访问 GitHub。默认隐藏测试版。
|
||||
5. 在“安装与维护”选择操作并确认。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
|
||||
5. 在“安装与维护”选择操作,在弹窗中核对设备、版本和操作后确认;取消或按 Esc 返回,不会执行。错误和操作结果也使用弹窗。SSH 指纹确认仍保留在连接页面。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
|
||||
|
||||
执行时主页面显示下载、传输和设备操作进度。在线下载与 SFTP 传输显示实际字节数和百分比;本地安装包跳过下载。设备端依次回报校验、准备目录、解压、配置服务和启动检查等步骤。解压显示实际字节进度,无法计算总量的步骤显示运行指示与当前步骤,日志保留在同一页面。只有操作及后续检查成功后才显示完成。
|
||||
|
||||
本机校验完成后使用内置 SSH/SFTP 上传,设备端再次校验并安全解压。密码只在内存中使用,不写入配置文件,不作为命令参数或环境变量传输;操作通过 SSH 标准输入向 sudo 提交密码。SSH 登录和 sudo 密码使用同一输入,适用于 Frame 默认账号配置。
|
||||
|
||||
@@ -56,7 +58,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.5`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.6`)触发 `.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 代码签名,发布前应分别验证系统的首次启动体验。
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::prelude::FluentBuilder;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(super) enum Prompt {
|
||||
Operation(String),
|
||||
Notice(String, String),
|
||||
Outcome(String, Result<(), String>),
|
||||
}
|
||||
|
||||
impl Installer {
|
||||
pub(super) fn queue_prompt(&mut self, prompt: Prompt) {
|
||||
if !self.prompts.contains(&prompt) {
|
||||
self.prompts.push_back(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn notice(&mut self, title: impl Into<String>, message: impl Into<String>) {
|
||||
self.queue_prompt(Prompt::Notice(title.into(), message.into()));
|
||||
}
|
||||
|
||||
pub(super) fn present_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let Some(prompt) = self.prompts.pop_front() else {
|
||||
return;
|
||||
};
|
||||
if let Prompt::Operation(action) = &prompt {
|
||||
if self.confirmation.as_ref() != Some(action) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let device = self.connected_host.clone();
|
||||
let version = if self.local {
|
||||
self.archive.as_ref().map(|p| p.display().to_string())
|
||||
} else {
|
||||
self.selected
|
||||
.and_then(|i| self.releases.get(i))
|
||||
.map(|r| r.tag_name.clone())
|
||||
};
|
||||
let view = cx.entity().downgrade();
|
||||
window.open_alert_dialog(cx, move |dialog, _, _| {
|
||||
let close_view = view.clone();
|
||||
let action_view = view.clone();
|
||||
let (title, message, ok, cancel) = match &prompt {
|
||||
Prompt::Operation(action) => (
|
||||
format!("确认{}?", ui::action_label(action)),
|
||||
if action == "uninstall" {
|
||||
"将停用并卸载所有插件,然后移除 Framely。插件卸载失败时保留本体;已保存的数据保留。".into()
|
||||
} else {
|
||||
"设备上的 Framely 服务可能会暂时中断,插件和已保存的数据会保留。".into()
|
||||
},
|
||||
format!("确认{}", ui::action_label(action)),
|
||||
true,
|
||||
),
|
||||
Prompt::Notice(title, message) => (title.clone(), message.clone(), "知道了".into(), false),
|
||||
Prompt::Outcome(action, result) => (
|
||||
format!("{}{}", ui::action_label(action), if result.is_ok() { "完成" } else { "失败" }),
|
||||
match result {
|
||||
Ok(()) if action == "uninstall" => "Framely 已卸载。已保存的数据保留;重新连接后可再次安装。".into(),
|
||||
Ok(()) => "操作已完成。请重新连接设备,读取最新版本和可用操作。".into(),
|
||||
Err(error) => format!("{error}\n\n可关闭弹窗查看日志,重新连接设备后再试。"),
|
||||
},
|
||||
"返回设备连接".into(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
let close_prompt = prompt.clone();
|
||||
let action_prompt = prompt.clone();
|
||||
dialog
|
||||
.width(px(540.))
|
||||
.close_button(true)
|
||||
.title(title)
|
||||
.child(
|
||||
div().id("installer-prompt-content").flex().flex_col().gap_3()
|
||||
.max_h(px(360.)).overflow_y_scroll().text_sm()
|
||||
.when(matches!(prompt, Prompt::Operation(_)), |body| {
|
||||
body.child(format!("设备:{device}"))
|
||||
.when(matches!(&prompt, Prompt::Operation(action) if maintenance::requires_package(action)), |body| {
|
||||
body.child(format!("版本 / 安装包:{}", version.as_deref().unwrap_or("未选择")))
|
||||
})
|
||||
})
|
||||
.child(message),
|
||||
)
|
||||
.button_props(DialogButtonProps::default().ok_text(ok).cancel_text("取消").show_cancel(cancel))
|
||||
.on_ok(move |_, _, cx| {
|
||||
let _ = action_view.update(cx, |view, cx| match &action_prompt {
|
||||
Prompt::Operation(action) if view.confirmation.as_ref() == Some(action) => view.operate(cx),
|
||||
Prompt::Outcome(_, _) => view.navigate(0, cx),
|
||||
_ => {}
|
||||
});
|
||||
true
|
||||
})
|
||||
.on_close(move |_, _, cx| {
|
||||
let _ = close_view.update(cx, |view, cx| {
|
||||
if let Prompt::Operation(action) = &close_prompt {
|
||||
if view.confirmation.as_ref() == Some(action) {
|
||||
view.confirmation = None;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod discovery;
|
||||
pub mod instance;
|
||||
pub mod maintenance;
|
||||
pub mod progress;
|
||||
pub mod release;
|
||||
pub mod remote;
|
||||
pub const DEFAULT_REPO: &str = "SteamFramelyHomebrew/framely";
|
||||
|
||||
+214
-9
@@ -3,6 +3,7 @@ use framely_installer::{
|
||||
DEFAULT_REPO,
|
||||
discovery::{self, Device},
|
||||
maintenance::{self, Installation},
|
||||
progress::{Progress, Stage},
|
||||
release::{self, Release},
|
||||
remote::{self, Connection, Credentials, Probe},
|
||||
};
|
||||
@@ -16,6 +17,7 @@ use gpui_kit::{
|
||||
*,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
@@ -40,6 +42,7 @@ enum Event {
|
||||
Connected(Result<Connection, String>),
|
||||
File(FileKind, Option<PathBuf>),
|
||||
Log(String),
|
||||
Progress(Progress),
|
||||
Done(Result<(), String>),
|
||||
}
|
||||
struct Installer {
|
||||
@@ -69,7 +72,9 @@ struct Installer {
|
||||
scan_id: u64,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
confirmation: Option<String>,
|
||||
prompts: VecDeque<dialogs::Prompt>,
|
||||
operation_result: Option<(String, Result<(), String>)>,
|
||||
progress: Option<Progress>,
|
||||
logs: Vec<String>,
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
@@ -134,7 +139,9 @@ impl Installer {
|
||||
scan_id: 0,
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
confirmation: None,
|
||||
prompts: VecDeque::new(),
|
||||
operation_result: None,
|
||||
progress: None,
|
||||
logs: Vec::new(),
|
||||
status: "开启 Frame 开发者模式并设置密码,将电脑和 Frame 连接到同一网络。".into(),
|
||||
error: None,
|
||||
@@ -187,7 +194,7 @@ impl Installer {
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
if weak
|
||||
.update(cx, |view: &mut Installer, cx| view.poll(cx))
|
||||
.update_in(cx, |view: &mut Installer, window, cx| view.poll(window, cx))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
@@ -197,7 +204,7 @@ impl Installer {
|
||||
.detach();
|
||||
view
|
||||
}
|
||||
fn poll(&mut self, cx: &mut Context<Self>) {
|
||||
fn poll(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut changed = false;
|
||||
while let Ok(event) = self.receiver.try_recv() {
|
||||
changed = true;
|
||||
@@ -225,6 +232,7 @@ impl Installer {
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.clone();
|
||||
self.notice("扫描失败", error.clone());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +247,10 @@ impl Installer {
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.clone();
|
||||
self.notice(
|
||||
"无法加载在线版本",
|
||||
format!("可刷新重试,或选择本地安装包。\n\n{error}"),
|
||||
);
|
||||
self.release_error =
|
||||
Some(format!("无法加载在线版本,可重试或选择本地安装包。{error}"));
|
||||
}
|
||||
@@ -253,6 +265,7 @@ impl Installer {
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.clone();
|
||||
self.notice("SSH 连接检查失败", error.clone());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +294,7 @@ impl Installer {
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.clone();
|
||||
self.notice("设备登录失败", error.clone());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
@@ -302,6 +316,10 @@ impl Installer {
|
||||
self.logs.remove(0);
|
||||
}
|
||||
}
|
||||
Event::Progress(progress) => {
|
||||
self.status = progress.detail.clone();
|
||||
self.progress = Some(progress);
|
||||
}
|
||||
Event::Done(result) => {
|
||||
self.busy = false;
|
||||
self.error = None;
|
||||
@@ -310,6 +328,10 @@ impl Installer {
|
||||
} else {
|
||||
"操作失败,请查看操作结果与日志。".into()
|
||||
};
|
||||
self.queue_prompt(dialogs::Prompt::Outcome(
|
||||
self.chosen_action.clone(),
|
||||
result.clone(),
|
||||
));
|
||||
self.operation_result = Some((self.chosen_action.clone(), result));
|
||||
// Avoid presenting a stale version or reusing an interrupted SSH channel.
|
||||
self.connection = None;
|
||||
@@ -323,6 +345,7 @@ impl Installer {
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
self.present_prompt(window, cx);
|
||||
}
|
||||
fn scan(&mut self, cx: &mut Context<Self>) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
@@ -356,6 +379,7 @@ impl Installer {
|
||||
let Ok(port) = port else {
|
||||
self.status = "SSH 端口无效".into();
|
||||
self.error = Some(self.status.clone());
|
||||
self.notice("无法检查连接", self.status.clone());
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
@@ -365,6 +389,7 @@ impl Installer {
|
||||
self.device_state = None;
|
||||
self.confirmation = None;
|
||||
self.operation_result = None;
|
||||
self.progress = None;
|
||||
self.status = "正在检查 SSH 连接…".into();
|
||||
self.probe = None;
|
||||
self.connected_host = format!("{host}:{port}");
|
||||
@@ -386,11 +411,13 @@ impl Installer {
|
||||
self.probe = None;
|
||||
self.status = "地址已变化,请重新检查 SSH。".into();
|
||||
self.error = Some(self.status.clone());
|
||||
self.notice("设备地址已变化", self.status.clone());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.password.read(cx).value().is_empty() {
|
||||
self.error = Some("请输入设备登录密码".into());
|
||||
self.notice("无法登录设备", "请输入设备登录密码。");
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -465,6 +492,7 @@ impl Installer {
|
||||
}
|
||||
fn request_operation(&mut self, cx: &mut Context<Self>) {
|
||||
if self.busy
|
||||
|| self.confirmation.is_some()
|
||||
|| self.connection.is_none()
|
||||
|| !self.action_available(&self.chosen_action)
|
||||
|| self.same_update()
|
||||
@@ -476,6 +504,7 @@ impl Installer {
|
||||
return;
|
||||
}
|
||||
self.confirmation = Some(self.chosen_action.clone());
|
||||
self.queue_prompt(dialogs::Prompt::Operation(self.chosen_action.clone()));
|
||||
cx.notify();
|
||||
}
|
||||
fn navigate(&mut self, page: usize, cx: &mut Context<Self>) {
|
||||
@@ -485,6 +514,7 @@ impl Installer {
|
||||
if page == 2 && self.connection.is_none() && !self.preview {
|
||||
self.page = 0;
|
||||
self.error = Some("请先连接 Frame,再进行安装或维护".into());
|
||||
self.notice("请先连接设备", "请先连接 Frame,再进行安装或维护。");
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -521,11 +551,13 @@ impl Installer {
|
||||
|| (maintenance::requires_package(&action) && !self.selection_ready())
|
||||
{
|
||||
self.error = Some("操作条件已变化,请重新选择操作或版本".into());
|
||||
self.notice("无法执行操作", "操作条件已变化,请重新选择操作或版本。");
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let Some(connection) = self.connection.clone() else {
|
||||
self.status = "请先连接设备".into();
|
||||
self.notice("请先连接设备", "连接后重新选择操作。");
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
@@ -539,6 +571,7 @@ impl Installer {
|
||||
self.error = None;
|
||||
self.logs.clear();
|
||||
self.operation_result = None;
|
||||
self.progress = Some(Progress::new(Stage::Prepare, "正在准备操作…"));
|
||||
self.status = format!("正在执行 {action}…");
|
||||
let sender = self.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -547,6 +580,9 @@ impl Installer {
|
||||
let mut log = |line| {
|
||||
let _ = sender.send(Event::Log(line));
|
||||
};
|
||||
let mut progress = |value| {
|
||||
let _ = sender.send(Event::Progress(value));
|
||||
};
|
||||
let (archive, sums) = if matches!(action.as_str(), "install" | "update") {
|
||||
if local {
|
||||
(
|
||||
@@ -559,14 +595,27 @@ impl Installer {
|
||||
let (package, checksum) = release::package(&selected)?;
|
||||
let archive = folder.path().join(&package.name);
|
||||
let sums = folder.path().join("SHA256SUMS");
|
||||
release::download(checksum, &sums, &mut log)?;
|
||||
release::download(package, &archive, &mut log)?;
|
||||
let total = checksum
|
||||
.size
|
||||
.checked_add(package.size)
|
||||
.ok_or_else(|| anyhow::anyhow!("下载长度无效"))?;
|
||||
log(format!("下载 {}", package.name));
|
||||
for (asset, path, offset) in
|
||||
[(checksum, &sums, 0), (package, &archive, checksum.size)]
|
||||
{
|
||||
release::download(asset, path, &mut |mut value| {
|
||||
value.completed += offset;
|
||||
value.total = Some(total);
|
||||
progress(value);
|
||||
})?;
|
||||
}
|
||||
(Some(archive), Some(sums))
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let (Some(archive), Some(sums)) = (&archive, &sums) {
|
||||
progress(Progress::new(Stage::Verify, "校验本地安装包 SHA256"));
|
||||
release::verify(archive, sums)?;
|
||||
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
|
||||
}
|
||||
@@ -580,6 +629,7 @@ impl Installer {
|
||||
repo: &repo,
|
||||
},
|
||||
&mut log,
|
||||
&mut progress,
|
||||
)
|
||||
})()
|
||||
.map_err(|e| format!("{e:#}"));
|
||||
@@ -593,6 +643,7 @@ impl Drop for Installer {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
mod dialogs;
|
||||
mod ui;
|
||||
|
||||
fn transparent_window_root(window: &mut Window, cx: &mut App) {
|
||||
@@ -639,6 +690,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
gpui_kit::platform::current_headless_renderer,
|
||||
);
|
||||
context.update(initialize_theme);
|
||||
context.update(|cx| cx.set_reduce_motion(true));
|
||||
let (handle, view) = context.update(|cx| {
|
||||
gpui_kit::open_window(
|
||||
WindowOptions {
|
||||
@@ -683,7 +735,14 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
(2, "operation-failed"),
|
||||
] {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
window.close_all_dialogs(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.prompts.clear();
|
||||
view.confirmation = None;
|
||||
view.error = None;
|
||||
view.release_error = None;
|
||||
view.operation_result = None;
|
||||
view.progress = None;
|
||||
view.page = page;
|
||||
view.local = name == "local";
|
||||
if name == "empty" {
|
||||
@@ -697,7 +756,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
view.sender
|
||||
.send(Event::ConnectionStage("正在验证用户名和密码…".into()))
|
||||
.unwrap();
|
||||
view.poll(cx);
|
||||
view.poll(window, cx);
|
||||
assert!(view.busy && view.connection_stage.is_some());
|
||||
} else if name == "connection-error" {
|
||||
view.sender
|
||||
@@ -705,7 +764,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
"SSH 登录失败:请检查用户名和密码,并确认已开启开发者模式".into(),
|
||||
)))
|
||||
.unwrap();
|
||||
view.poll(cx);
|
||||
view.poll(window, cx);
|
||||
assert!(!view.busy && view.connection_stage.is_none() && view.error.is_some());
|
||||
} else if name == "connected" {
|
||||
view.connected_host = "192.168.1.42:22".into();
|
||||
@@ -722,7 +781,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
},
|
||||
})))
|
||||
.unwrap();
|
||||
view.poll(cx);
|
||||
view.poll(window, cx);
|
||||
assert!(
|
||||
view.connection.is_some()
|
||||
&& !view.busy
|
||||
@@ -736,7 +795,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
view.sender
|
||||
.send(Event::Releases(Err("GitHub API: status code 404".into())))
|
||||
.unwrap();
|
||||
view.poll(cx);
|
||||
view.poll(window, cx);
|
||||
assert!(!view.busy && view.error.is_none() && view.release_error.is_some());
|
||||
}
|
||||
if name == "installed-no-package" {
|
||||
@@ -798,7 +857,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
Err("网络连接中断,请重新连接设备后重试。".into())
|
||||
}))
|
||||
.unwrap();
|
||||
view.poll(cx);
|
||||
view.poll(window, cx);
|
||||
assert!(
|
||||
view.operation_result.is_some()
|
||||
&& view.connection.is_none()
|
||||
@@ -816,6 +875,152 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
.capture_screenshot(handle)?
|
||||
.save(dir.join(format!("installer-{name}.png")))?;
|
||||
}
|
||||
// Exercise actual modal hit testing, cancellation and stale confirmation.
|
||||
for action in ["install", "update", "repair", "rollback", "uninstall"] {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
window.close_all_dialogs(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.page = 2;
|
||||
view.busy = false;
|
||||
view.operation_result = None;
|
||||
view.progress = None;
|
||||
view.prompts.clear();
|
||||
view.confirmation = None;
|
||||
view.error = None;
|
||||
view.release_error = None;
|
||||
view.status = "已连接 192.168.1.42:frame · 0.4.0".into();
|
||||
view.local = false;
|
||||
view.selected = Some(0);
|
||||
view.releases[0].tag_name = "v0.4.1".into();
|
||||
view.device_state = Some(Installation {
|
||||
present: action != "install",
|
||||
current_version: (action != "install").then(|| "0.4.0".into()),
|
||||
previous_version: Some("0.3.9".into()),
|
||||
});
|
||||
view.connection = Some(Arc::new(Mutex::new(Connection {
|
||||
session: ssh2::Session::new().unwrap(),
|
||||
host: "192.168.1.42".into(),
|
||||
user: "steamos".into(),
|
||||
version: "frame · 0.4.0".into(),
|
||||
installation: view.device_state.clone().unwrap(),
|
||||
})));
|
||||
view.chosen_action = action.into();
|
||||
view.request_operation(cx);
|
||||
view.request_operation(cx);
|
||||
assert_eq!(
|
||||
view.prompts.len(),
|
||||
1,
|
||||
"double activation must not queue two confirmations"
|
||||
);
|
||||
view.poll(window, cx);
|
||||
assert!(view.confirmation.is_some());
|
||||
});
|
||||
window.render_frame(cx);
|
||||
assert!(window.has_active_dialog(cx));
|
||||
assert!(window.find("dialog").visible());
|
||||
window.click("step-0", cx);
|
||||
assert_eq!(
|
||||
view.read(cx).page,
|
||||
2,
|
||||
"modal must block background navigation"
|
||||
);
|
||||
})?;
|
||||
context.run_until_parked();
|
||||
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
|
||||
context
|
||||
.capture_screenshot(handle)?
|
||||
.save(dir.join(format!("installer-confirm-{action}.png")))?;
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
window.within("dialog").click("cancel", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(!window.has_active_dialog(cx));
|
||||
assert!(view.read(cx).confirmation.is_none() && !view.read(cx).busy);
|
||||
view.update(cx, |view, cx| {
|
||||
view.request_operation(cx);
|
||||
view.poll(window, cx);
|
||||
});
|
||||
window.render_frame(cx);
|
||||
window.press("escape", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(!window.has_active_dialog(cx));
|
||||
assert!(view.read(cx).confirmation.is_none() && !view.read(cx).busy);
|
||||
view.update(cx, |view, cx| {
|
||||
view.request_operation(cx);
|
||||
view.poll(window, cx);
|
||||
});
|
||||
window.render_frame(cx);
|
||||
window.within("dialog").click("close", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(!window.has_active_dialog(cx));
|
||||
assert!(view.read(cx).confirmation.is_none() && !view.read(cx).busy);
|
||||
view.update(cx, |view, cx| {
|
||||
view.request_operation(cx);
|
||||
view.poll(window, cx);
|
||||
});
|
||||
window.render_frame(cx);
|
||||
view.update(cx, |view, _| {
|
||||
view.device_state = None;
|
||||
});
|
||||
window.within("dialog").click("ok", cx);
|
||||
assert!(
|
||||
!view.read(cx).busy,
|
||||
"stale confirmation must not start SSH operations"
|
||||
);
|
||||
view.update(cx, |view, cx| view.poll(window, cx));
|
||||
window.render_frame(cx);
|
||||
assert!(
|
||||
window.has_active_dialog(cx),
|
||||
"stale confirmation must show an error dialog"
|
||||
);
|
||||
window.within("dialog").click("ok", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(!window.has_active_dialog(cx));
|
||||
})?;
|
||||
}
|
||||
for (stage, name) in [
|
||||
(Stage::Download, "download"),
|
||||
(Stage::Verify, "verify"),
|
||||
(Stage::Transfer, "transfer"),
|
||||
(Stage::Install, "install"),
|
||||
] {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.chosen_action = "install".into();
|
||||
view.error = None;
|
||||
view.release_error = None;
|
||||
view.busy = true;
|
||||
view.local = false;
|
||||
view.logs = vec!["电脑端 SHA256 校验通过。".into()];
|
||||
let mut progress = Progress::new(
|
||||
stage,
|
||||
match stage {
|
||||
Stage::Download => "framely-0.4.1-linux-arm64.tar.gz",
|
||||
Stage::Verify => "校验本地安装包 SHA256",
|
||||
Stage::Transfer => "framely-0.4.1-linux-arm64.tar.gz",
|
||||
_ => "解压并校验发行文件",
|
||||
},
|
||||
);
|
||||
if stage != Stage::Verify {
|
||||
progress.completed = 25 * 1048576;
|
||||
progress.total = Some(100 * 1048576);
|
||||
}
|
||||
if stage == Stage::Install {
|
||||
progress.step = Some((3, 5));
|
||||
}
|
||||
view.sender.send(Event::Progress(progress)).unwrap();
|
||||
view.poll(window, cx);
|
||||
assert_eq!(view.progress.as_ref().unwrap().stage, stage);
|
||||
});
|
||||
window.render_frame(cx);
|
||||
assert!(window.find("operation-progress").visible());
|
||||
})?;
|
||||
context.run_until_parked();
|
||||
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
|
||||
context
|
||||
.capture_screenshot(handle)?
|
||||
.save(dir.join(format!("installer-progress-{name}.png")))?;
|
||||
}
|
||||
eprintln!("Installer modal and progress checks passed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
Prepare,
|
||||
Download,
|
||||
Verify,
|
||||
Transfer,
|
||||
Install,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Progress {
|
||||
pub stage: Stage,
|
||||
pub detail: String,
|
||||
pub completed: u64,
|
||||
pub total: Option<u64>,
|
||||
pub step: Option<(u8, u8)>,
|
||||
}
|
||||
impl Progress {
|
||||
pub fn new(stage: Stage, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
stage,
|
||||
detail: detail.into(),
|
||||
completed: 0,
|
||||
total: None,
|
||||
step: None,
|
||||
}
|
||||
}
|
||||
pub fn fraction(&self) -> Option<f32> {
|
||||
self.total
|
||||
.filter(|total| *total > 0)
|
||||
.map(|total| (self.completed.min(total) as f64 / total as f64) as f32)
|
||||
}
|
||||
}
|
||||
|
||||
// Report small files too, throttle large streams, and only report completion
|
||||
// after checking the exact byte count. The same path handles HTTPS and SFTP.
|
||||
pub fn copy(
|
||||
reader: &mut impl Read,
|
||||
writer: &mut impl Write,
|
||||
total: u64,
|
||||
stage: Stage,
|
||||
detail: &str,
|
||||
report: &mut dyn FnMut(Progress),
|
||||
) -> Result<()> {
|
||||
let mut value = Progress::new(stage, detail);
|
||||
value.total = Some(total);
|
||||
report(value.clone());
|
||||
let mut block = [0; 256 * 1024];
|
||||
let mut last = Instant::now();
|
||||
loop {
|
||||
let n = reader.read(&mut block)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
ensure!(value.completed + n as u64 <= total, "传输超过声明长度");
|
||||
writer.write_all(&block[..n])?;
|
||||
value.completed += n as u64;
|
||||
if last.elapsed() >= Duration::from_millis(200) {
|
||||
report(value.clone());
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
ensure!(value.completed == total, "传输未完成");
|
||||
writer.flush()?;
|
||||
report(value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceStep {
|
||||
step: String,
|
||||
completed: Option<u64>,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeviceProgress {
|
||||
pending: String,
|
||||
}
|
||||
impl DeviceProgress {
|
||||
pub fn push(&mut self, chunk: &str, report: &mut dyn FnMut(Progress)) -> Vec<String> {
|
||||
let mut logs = Vec::new();
|
||||
self.pending.push_str(chunk);
|
||||
while let Some(end) = self.pending.find('\n') {
|
||||
let line: String = self.pending.drain(..=end).collect();
|
||||
let Some(json) = line.trim().strip_prefix("FRAMELY_PROGRESS ") else {
|
||||
logs.push(line);
|
||||
continue;
|
||||
};
|
||||
let Ok(step) = serde_json::from_str::<DeviceStep>(json) else {
|
||||
continue;
|
||||
};
|
||||
let (index, detail) = match step.step.as_str() {
|
||||
"verify" => (1, "设备端校验安装包"),
|
||||
"stage" => (2, "准备设备安装目录"),
|
||||
"extract" => (3, "解压并校验发行文件"),
|
||||
"configure" => (4, "配置账号、安装文件与系统服务"),
|
||||
"activate" => (5, "启动服务并检查安装状态"),
|
||||
_ => continue,
|
||||
};
|
||||
if step
|
||||
.total
|
||||
.is_some_and(|total| total == 0 || step.completed.unwrap_or(0) > total)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut value = Progress::new(Stage::Install, detail);
|
||||
value.step = Some((index, 5));
|
||||
value.completed = step.completed.unwrap_or(0);
|
||||
value.total = step.total;
|
||||
report(value);
|
||||
}
|
||||
if self.pending.len() > 8192 {
|
||||
logs.push(std::mem::take(&mut self.pending));
|
||||
}
|
||||
logs
|
||||
}
|
||||
pub fn finish(&mut self) -> String {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
if pending.trim().starts_with("FRAMELY_PROGRESS ") {
|
||||
String::new()
|
||||
} else {
|
||||
pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn byte_progress_reports_small_files_and_rejects_incomplete_or_oversized_streams() {
|
||||
let mut reports = Vec::new();
|
||||
let mut output = Vec::new();
|
||||
copy(
|
||||
&mut &b"payload"[..],
|
||||
&mut output,
|
||||
7,
|
||||
Stage::Transfer,
|
||||
"package",
|
||||
&mut |p| reports.push(p),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(output, b"payload");
|
||||
assert_eq!(reports.first().unwrap().completed, 0);
|
||||
assert_eq!(reports.last().unwrap().fraction(), Some(1.0));
|
||||
for declared in [6, 8] {
|
||||
let mut reports = Vec::new();
|
||||
assert!(
|
||||
copy(
|
||||
&mut &b"payload"[..],
|
||||
&mut Vec::new(),
|
||||
declared,
|
||||
Stage::Download,
|
||||
"package",
|
||||
&mut |p| reports.push(p)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(reports.iter().all(|p| p.fraction() != Some(1.0)));
|
||||
}
|
||||
assert_eq!(Progress::new(Stage::Install, "working").fraction(), None);
|
||||
}
|
||||
#[test]
|
||||
fn device_progress_handles_split_lines_and_rejects_invalid_events() {
|
||||
let mut decoder = DeviceProgress::default();
|
||||
let mut reports = Vec::new();
|
||||
let mut report = |p| reports.push(p);
|
||||
assert_eq!(
|
||||
decoder.push("service output\nFRAMELY_PRO", &mut report),
|
||||
["service output\n"]
|
||||
);
|
||||
decoder.push("GRESS {\"step\":\"extract\",\"completed\":2,", &mut report);
|
||||
decoder.push(
|
||||
"\"total\":8}\nFRAMELY_PROGRESS {\"step\":\"configure\"}\n",
|
||||
&mut report,
|
||||
);
|
||||
decoder.push("FRAMELY_PROGRESS {\"step\":\"unknown\"}\nFRAMELY_PROGRESS {\"step\":\"extract\",\"completed\":9,\"total\":8}\n", &mut report);
|
||||
assert_eq!(reports.len(), 2);
|
||||
assert_eq!(reports[0].fraction(), Some(0.25));
|
||||
assert_eq!(reports[0].step, Some((3, 5)));
|
||||
assert_eq!(reports[1].step, Some((4, 5)));
|
||||
assert_eq!(reports[1].fraction(), None);
|
||||
}
|
||||
}
|
||||
+18
-28
@@ -1,12 +1,7 @@
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fs::File, io::Read, path::Path, time::Duration};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Asset {
|
||||
@@ -155,34 +150,29 @@ pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn download(asset: &Asset, dest: &Path, log: &mut dyn FnMut(String)) -> Result<()> {
|
||||
pub fn download(
|
||||
asset: &Asset,
|
||||
dest: &Path,
|
||||
progress: &mut dyn FnMut(crate::progress::Progress),
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
asset.size > 0 && asset.size <= 2 * 1024 * 1024 * 1024,
|
||||
"下载长度无效"
|
||||
);
|
||||
let mut starting =
|
||||
crate::progress::Progress::new(crate::progress::Stage::Download, &asset.name);
|
||||
starting.total = Some(asset.size);
|
||||
progress(starting);
|
||||
let mut input = get(&asset.browser_download_url)?.into_reader();
|
||||
let mut output = File::create(dest)?;
|
||||
let (mut total, mut reported) = (0u64, 0u64);
|
||||
let mut block = [0; 256 * 1024];
|
||||
loop {
|
||||
let n = input.read(&mut block)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
total += n as u64;
|
||||
ensure!(total <= asset.size, "下载超过声明长度");
|
||||
output.write_all(&block[..n])?;
|
||||
if total - reported >= 4 * 1024 * 1024 {
|
||||
log(format!(
|
||||
"下载 {}:{} / {} MiB",
|
||||
asset.name,
|
||||
total / 1024 / 1024,
|
||||
asset.size / 1024 / 1024
|
||||
));
|
||||
reported = total;
|
||||
}
|
||||
}
|
||||
ensure!(total == asset.size, "下载未完成");
|
||||
crate::progress::copy(
|
||||
&mut input,
|
||||
&mut output,
|
||||
asset.size,
|
||||
crate::progress::Stage::Download,
|
||||
&asset.name,
|
||||
progress,
|
||||
)?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+90
-26
@@ -208,6 +208,15 @@ fn exec(
|
||||
command: &str,
|
||||
password: Option<&str>,
|
||||
log: &mut dyn FnMut(String),
|
||||
) -> Result<String> {
|
||||
exec_with_progress(session, command, password, log, None)
|
||||
}
|
||||
fn exec_with_progress(
|
||||
session: &Session,
|
||||
command: &str,
|
||||
password: Option<&str>,
|
||||
log: &mut dyn FnMut(String),
|
||||
mut progress: Option<&mut dyn FnMut(crate::progress::Progress)>,
|
||||
) -> Result<String> {
|
||||
let mut channel = session.channel_session()?;
|
||||
channel.handle_extended_data(ExtendedData::Merge)?;
|
||||
@@ -220,6 +229,7 @@ fn exec(
|
||||
channel.send_eof()?;
|
||||
let mut result = String::new();
|
||||
let mut chunk = [0; 4096];
|
||||
let mut decoder = crate::progress::DeviceProgress::default();
|
||||
loop {
|
||||
let n = channel.read(&mut chunk)?;
|
||||
if n == 0 {
|
||||
@@ -230,11 +240,25 @@ fn exec(
|
||||
let line = password
|
||||
.filter(|s| !s.is_empty())
|
||||
.map_or(line.clone(), |p| line.replace(p, "[密码已隐藏]"));
|
||||
log(line.clone());
|
||||
let line = if let Some(report) = progress.as_deref_mut() {
|
||||
decoder.push(&line, report).join("")
|
||||
} else {
|
||||
line
|
||||
};
|
||||
if !line.is_empty() {
|
||||
log(line.clone());
|
||||
}
|
||||
if result.len() < 128 * 1024 {
|
||||
result.push_str(&line);
|
||||
}
|
||||
}
|
||||
if progress.is_some() {
|
||||
let tail = decoder.finish();
|
||||
if !tail.is_empty() {
|
||||
log(tail.clone());
|
||||
result.push_str(&tail);
|
||||
}
|
||||
}
|
||||
channel.wait_close()?;
|
||||
ensure!(
|
||||
channel.exit_status()? == 0,
|
||||
@@ -243,31 +267,31 @@ fn exec(
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
fn upload(session: &Session, path: &Path, remote: &str, log: &mut dyn FnMut(String)) -> Result<()> {
|
||||
fn upload(
|
||||
session: &Session,
|
||||
path: &Path,
|
||||
remote: &str,
|
||||
offset: u64,
|
||||
total: u64,
|
||||
progress: &mut dyn FnMut(crate::progress::Progress),
|
||||
) -> Result<()> {
|
||||
let size = path.metadata()?.len();
|
||||
let sftp = session.sftp()?;
|
||||
let mut output = sftp.create(Path::new(remote))?;
|
||||
let mut input = fs::File::open(path)?;
|
||||
let mut block = [0; 256 * 1024];
|
||||
let (mut written, mut reported) = (0u64, 0u64);
|
||||
loop {
|
||||
let n = input.read(&mut block)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
output.write_all(&block[..n])?;
|
||||
written += n as u64;
|
||||
if written - reported >= 4 * 1024 * 1024 {
|
||||
log(format!(
|
||||
"上传:{} / {} MiB",
|
||||
written / 1024 / 1024,
|
||||
size / 1024 / 1024
|
||||
));
|
||||
reported = written;
|
||||
}
|
||||
}
|
||||
crate::progress::copy(
|
||||
&mut input,
|
||||
&mut output,
|
||||
size,
|
||||
crate::progress::Stage::Transfer,
|
||||
&path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
&mut |mut value| {
|
||||
value.completed += offset;
|
||||
value.total = Some(total);
|
||||
progress(value);
|
||||
},
|
||||
)?;
|
||||
output.close()?;
|
||||
ensure!(written == size, "上传未完成");
|
||||
Ok(())
|
||||
}
|
||||
pub struct Operation<'a> {
|
||||
@@ -281,6 +305,7 @@ pub fn operate(
|
||||
password: Zeroizing<String>,
|
||||
operation: Operation<'_>,
|
||||
log: &mut dyn FnMut(String),
|
||||
progress: &mut dyn FnMut(crate::progress::Progress),
|
||||
) -> Result<()> {
|
||||
let Operation {
|
||||
action,
|
||||
@@ -322,13 +347,28 @@ pub fn operate(
|
||||
"临时目录无效"
|
||||
);
|
||||
let result = (|| -> Result<()> {
|
||||
let total = super::BOOTSTRAP.len() as u64
|
||||
+ archive
|
||||
.map(|p| p.metadata().map(|m| m.len()))
|
||||
.transpose()?
|
||||
.unwrap_or(0)
|
||||
+ sums
|
||||
.map(|p| p.metadata().map(|m| m.len()))
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
let mut transfer =
|
||||
crate::progress::Progress::new(crate::progress::Stage::Transfer, "传输安装引擎");
|
||||
transfer.total = Some(total);
|
||||
progress(transfer.clone());
|
||||
let sftp = session.sftp()?;
|
||||
let script = format!("{dir}/bootstrap.py");
|
||||
let mut file = sftp.create(Path::new(&script))?;
|
||||
file.write_all(super::BOOTSTRAP)?;
|
||||
file.close()?;
|
||||
transfer.completed = super::BOOTSTRAP.len() as u64;
|
||||
progress(transfer);
|
||||
let mut args = format!(
|
||||
"sudo -S -p '' -k -- python3 {} {} --yes --repo {} --user {}",
|
||||
"sudo -S -p '' -k -- python3 -u {} {} --yes --progress-json --repo {} --user {}",
|
||||
quote(&script),
|
||||
quote(action),
|
||||
quote(repo),
|
||||
@@ -341,9 +381,23 @@ pub fn operate(
|
||||
.and_then(|s| s.to_str())
|
||||
.context("压缩包名无效")?;
|
||||
let target = format!("{dir}/{name}");
|
||||
upload(session, archive, &target, log)?;
|
||||
upload(
|
||||
session,
|
||||
archive,
|
||||
&target,
|
||||
super::BOOTSTRAP.len() as u64,
|
||||
total,
|
||||
progress,
|
||||
)?;
|
||||
let checksums = format!("{dir}/SHA256SUMS");
|
||||
upload(session, sums, &checksums, log)?;
|
||||
upload(
|
||||
session,
|
||||
sums,
|
||||
&checksums,
|
||||
super::BOOTSTRAP.len() as u64 + archive.metadata()?.len(),
|
||||
total,
|
||||
progress,
|
||||
)?;
|
||||
args += &format!(
|
||||
" --archive {} --checksums {}",
|
||||
quote(&target),
|
||||
@@ -355,11 +409,21 @@ pub fn operate(
|
||||
"安装或更新需要已校验的发行包"
|
||||
);
|
||||
}
|
||||
exec(session, &args, Some(&password), log)?;
|
||||
let mut installing =
|
||||
crate::progress::Progress::new(crate::progress::Stage::Install, "设备端校验安装包");
|
||||
installing.step = Some((1, 5));
|
||||
progress(installing);
|
||||
exec_with_progress(session, &args, Some(&password), log, Some(progress))?;
|
||||
let mut activating = crate::progress::Progress::new(
|
||||
crate::progress::Stage::Install,
|
||||
"检查系统服务并清理临时文件",
|
||||
);
|
||||
activating.step = Some((5, 5));
|
||||
progress(activating);
|
||||
if action != "uninstall" {
|
||||
exec(
|
||||
session,
|
||||
"systemctl is-active framely.service; systemctl is-enabled framely.service framely-session.service",
|
||||
"systemctl is-active framely.service && systemctl is-enabled framely.service framely-session.service",
|
||||
None,
|
||||
log,
|
||||
)?;
|
||||
|
||||
+157
-95
@@ -36,21 +36,6 @@ fn badge(text: impl Into<SharedString>, active: bool) -> Div {
|
||||
.text_color(rgb(if active { BLUE } else { MUTED }))
|
||||
.child(text.into())
|
||||
}
|
||||
fn error_banner(error: String) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(px(RADIUS))
|
||||
.border_1()
|
||||
.border_color(rgb(0x8f5454))
|
||||
.bg(rgb(0x38272b))
|
||||
.text_sm()
|
||||
.text_color(rgb(0xf1b8b8))
|
||||
.child(Icon::new(IconName::CircleAlert).size_4().mt_1())
|
||||
.child(div().flex_1().min_w_0().child(error))
|
||||
}
|
||||
fn heading(title: &'static str, subtitle: &'static str) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
@@ -70,6 +55,122 @@ fn icon(name: IconName) -> Icon {
|
||||
}
|
||||
|
||||
impl Installer {
|
||||
fn operation_progress(&self) -> AnyElement {
|
||||
let progress = self.progress.as_ref().expect("operation progress");
|
||||
let success = self
|
||||
.operation_result
|
||||
.as_ref()
|
||||
.is_some_and(|(_, result)| result.is_ok());
|
||||
let failed = self
|
||||
.operation_result
|
||||
.as_ref()
|
||||
.is_some_and(|(_, result)| result.is_err());
|
||||
let current = match progress.stage {
|
||||
Stage::Prepare => 0,
|
||||
Stage::Download => 1,
|
||||
Stage::Verify => 2,
|
||||
Stage::Transfer => 3,
|
||||
Stage::Install => 4,
|
||||
};
|
||||
let mut steps = div().flex().gap_4();
|
||||
for (id, label, rank, skip) in [
|
||||
(
|
||||
"download-progress",
|
||||
"下载",
|
||||
1,
|
||||
self.local || !maintenance::requires_package(&self.chosen_action),
|
||||
),
|
||||
("transfer-progress", "传输", 3, false),
|
||||
(
|
||||
"install-progress",
|
||||
ui::action_label(&self.chosen_action),
|
||||
4,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let done = !skip && (success || current > rank);
|
||||
let active = !skip && current == rank && !success;
|
||||
let value = if done {
|
||||
100.
|
||||
} else if active {
|
||||
progress.fraction().unwrap_or(0.) * 100.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
let status = if skip {
|
||||
"无需下载".to_owned()
|
||||
} else if done {
|
||||
"已完成".to_owned()
|
||||
} else if active && failed {
|
||||
"失败".to_owned()
|
||||
} else if active {
|
||||
progress
|
||||
.fraction()
|
||||
.map(|v| {
|
||||
if progress.stage == Stage::Install {
|
||||
format!("当前步骤 {:.0}%", v * 100.)
|
||||
} else {
|
||||
format!("{:.0}%", v * 100.)
|
||||
}
|
||||
})
|
||||
.unwrap_or("进行中".into())
|
||||
} else {
|
||||
"等待中".to_owned()
|
||||
};
|
||||
steps = steps.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(label)
|
||||
.child(muted(status)),
|
||||
)
|
||||
.child(
|
||||
gpui_kit::component::progress::Progress::new(id)
|
||||
.accessibility_label(label)
|
||||
.color(rgb(BLUE))
|
||||
.value(value)
|
||||
.loading(active && !failed && progress.fraction().is_none()),
|
||||
),
|
||||
);
|
||||
}
|
||||
let card = panel().id("operation-progress");
|
||||
#[cfg(feature = "visual-test")]
|
||||
let card = card.test_support();
|
||||
card.gap_3()
|
||||
.child(div().font_weight(FontWeight::MEDIUM).child(if failed {
|
||||
"操作已停止"
|
||||
} else if success {
|
||||
"操作已完成"
|
||||
} else {
|
||||
"执行进度"
|
||||
}))
|
||||
.child(steps)
|
||||
.child(muted(if success {
|
||||
"所有步骤已完成".to_owned()
|
||||
} else {
|
||||
progress.detail.clone()
|
||||
}))
|
||||
.when(!success, |this| {
|
||||
this.when_some(progress.total.filter(|total| *total > 0), |this, total| {
|
||||
this.child(muted(format!(
|
||||
"{:.1} / {:.1} MiB",
|
||||
progress.completed as f64 / 1048576.,
|
||||
total as f64 / 1048576.
|
||||
)))
|
||||
})
|
||||
.when_some(progress.step, |this, (step, count)| {
|
||||
this.child(muted(format!("设备操作:步骤 {step} / {count}")))
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
fn device_page(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let locked = self.busy || self.connection.is_some();
|
||||
let mut devices = div()
|
||||
@@ -275,15 +376,7 @@ impl Installer {
|
||||
);
|
||||
if let Some(probe) = &self.probe {
|
||||
connection = connection
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(error_banner(error)).child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(BLUE))
|
||||
.child(probe.fingerprint.clone()),
|
||||
)
|
||||
})
|
||||
.when(self.error.is_none(), |this| {
|
||||
.when(true, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex()
|
||||
@@ -326,23 +419,19 @@ impl Installer {
|
||||
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
|
||||
);
|
||||
} else {
|
||||
connection = connection
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(error_banner(error))
|
||||
})
|
||||
.child(
|
||||
Button::new("probe")
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.busy {
|
||||
"正在连接…"
|
||||
} else {
|
||||
"检查 SSH 连接"
|
||||
})
|
||||
.loading(self.busy)
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.probe(cx))),
|
||||
);
|
||||
connection = connection.child(
|
||||
Button::new("probe")
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.busy {
|
||||
"正在连接…"
|
||||
} else {
|
||||
"检查 SSH 连接"
|
||||
})
|
||||
.loading(self.busy)
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.probe(cx))),
|
||||
);
|
||||
}
|
||||
if let Some(stage) = &self.connection_stage {
|
||||
connection = connection.child(
|
||||
@@ -480,9 +569,6 @@ impl Installer {
|
||||
} else {
|
||||
"暂无可安装的在线版本"
|
||||
})
|
||||
.when_some(self.release_error.clone(), |this, error| {
|
||||
this.child(error_banner(error).w_full())
|
||||
})
|
||||
.child(muted(if self.busy {
|
||||
"请稍候"
|
||||
} else {
|
||||
@@ -589,11 +675,6 @@ impl Installer {
|
||||
} else {
|
||||
source = source
|
||||
.child(list)
|
||||
.when(!self.releases.is_empty(), |this| {
|
||||
this.when_some(self.release_error.clone(), |this, error| {
|
||||
this.child(error_banner(error))
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
@@ -848,6 +929,21 @@ impl Installer {
|
||||
.child(operations)
|
||||
.child(summary),
|
||||
);
|
||||
if self.busy && self.progress.is_some() {
|
||||
body = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_5()
|
||||
.child(heading(
|
||||
"正在执行操作",
|
||||
"下载、传输和设备执行进度会在下方更新。",
|
||||
))
|
||||
.child(muted(format!(
|
||||
"设备:{} · 操作:{}",
|
||||
self.connected_host,
|
||||
action_label(&self.chosen_action)
|
||||
)));
|
||||
}
|
||||
if let Some((action, result)) = &self.operation_result {
|
||||
let success = result.is_ok();
|
||||
let description = match result {
|
||||
@@ -883,48 +979,18 @@ impl Installer {
|
||||
.child(
|
||||
Button::new("reconnect-result")
|
||||
.primary()
|
||||
.label("重新连接设备")
|
||||
.on_click(cx.listener(|view, _, _, cx| view.navigate(0, cx))),
|
||||
.label("查看操作结果")
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
if let Some((action, result)) = view.operation_result.clone() {
|
||||
view.queue_prompt(super::dialogs::Prompt::Outcome(action, result));
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
body = div().flex().flex_col().gap_5().child(feedback);
|
||||
}
|
||||
if let Some(action) = &self.confirmation {
|
||||
let message = if action == "uninstall" {
|
||||
"将停用并卸载所有插件,然后移除 Framely。插件卸载失败时保留本体;已保存的数据保留。"
|
||||
.into()
|
||||
} else {
|
||||
format!(
|
||||
"确认{}?设备上的 Framely 服务可能会暂时中断。",
|
||||
action_label(action)
|
||||
)
|
||||
};
|
||||
body = body.child(
|
||||
panel()
|
||||
.border_color(rgb(0x7599b5))
|
||||
.child(div().text_sm().child(message))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("confirm")
|
||||
.primary()
|
||||
.label("确认执行")
|
||||
.disabled(self.busy)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.operate(cx))),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.ghost()
|
||||
.label("取消")
|
||||
.disabled(self.busy)
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
view.confirmation = None;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
if self.progress.is_some() {
|
||||
body = body.child(self.operation_progress());
|
||||
}
|
||||
if !self.logs.is_empty() {
|
||||
body = body.child(
|
||||
@@ -957,7 +1023,7 @@ impl Installer {
|
||||
body.into_any_element()
|
||||
}
|
||||
}
|
||||
fn action_label(action: &str) -> &'static str {
|
||||
pub(super) fn action_label(action: &str) -> &'static str {
|
||||
match action {
|
||||
"update" => "更新",
|
||||
"repair" => "修复安装",
|
||||
@@ -1131,8 +1197,9 @@ impl Render for Installer {
|
||||
|| !self.action_available(&self.chosen_action)
|
||||
|| self.same_update(),
|
||||
)
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
.on_click(cx.listener(|view, _, window, cx| {
|
||||
view.request_operation(cx);
|
||||
view.present_prompt(window, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1188,11 +1255,6 @@ impl Render for Installer {
|
||||
.mx_auto()
|
||||
.px_8()
|
||||
.py_7()
|
||||
.when(self.page != 0, |this| {
|
||||
this.when_some(self.error.clone(), |this, error| {
|
||||
this.child(div().mb_5().child(error_banner(error)))
|
||||
})
|
||||
})
|
||||
.child(body),
|
||||
),
|
||||
)
|
||||
|
||||
+6
-2
@@ -14,6 +14,7 @@
|
||||
#include "keyboard_state.h"
|
||||
#include "keyboard_input.h"
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
#include <arpa/inet.h>
|
||||
@@ -47,6 +48,8 @@ static std::string keyboard_view;
|
||||
static KeyboardMenuGuard keyboard_menu;
|
||||
static uint64_t keyboard_token=0;static vr::VROverlayHandle_t keyboard_target=0;
|
||||
static std::mutex queue_mutex;
|
||||
static std::mutex visibility_mutex;
|
||||
static json visible_views=nullptr;
|
||||
static std::deque<json> queue;
|
||||
|
||||
template<class T> struct Handler {
|
||||
@@ -88,7 +91,7 @@ static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
return p;
|
||||
}
|
||||
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string req="GET /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nConnection: close\r\n\r\n";size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");return json::parse(response.substr(body+4));}
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;{std::lock_guard<std::mutex> lock(visibility_mutex);payload=visible_views.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");return json::parse(response.substr(body+4));}
|
||||
// Frame renders the native laser cursor in the default overlay sort group.
|
||||
// Raising a panel above that group can obscure the runtime cursor.
|
||||
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
|
||||
@@ -233,7 +236,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
}
|
||||
}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsActiveDashboardOverlay(v->overlay);if(active!=v->shown){v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&!keyboard_open)close_view(*t);}}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&!keyboard_open)close_view(*t);}}
|
||||
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
|
||||
if(v->dirty&&!v->closing){
|
||||
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
|
||||
@@ -271,6 +274,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
}
|
||||
}
|
||||
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
|
||||
if(keyboard_close_pending){
|
||||
// A non-minimal runtime keyboard may buffer text until Done. Drain overlay
|
||||
// character events first so the global Done event cannot duplicate input.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
// An active dashboard tab may remain selected after the dashboard is closed.
|
||||
inline bool capture_view_visible(bool closing,bool overlay_visible,bool dashboard,bool dashboard_visible,bool active) {
|
||||
return !closing && overlay_visible && (!dashboard || (dashboard_visible && active));
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./visibility": "./src/visibility.ts",
|
||||
"./lifecycle": "./src/lifecycle.ts",
|
||||
"./python": "./python/framely.py"
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import sys
|
||||
def emit(event, data):
|
||||
print(json.dumps({"event": event, "data": data}), flush=True)
|
||||
|
||||
def serve(dispatch, lifecycle=None):
|
||||
def serve(dispatch, lifecycle=None, visibility=None):
|
||||
"""dispatch(method, params); lifecycle maps onInstall/onStart/... to callbacks.
|
||||
|
||||
Independent hooks receive FRAMELY_LIFECYCLE_CONTEXT and exit once completed.
|
||||
Normal backend hooks are manager-only reserved protocol messages.
|
||||
visibility(snapshot) receives framely.ui.visibility when opted in via manifest.
|
||||
"""
|
||||
lifecycle = lifecycle or {}
|
||||
phase = os.environ.get("FRAMELY_LIFECYCLE")
|
||||
@@ -37,6 +38,8 @@ def serve(dispatch, lifecycle=None):
|
||||
if phase not in lifecycle:
|
||||
raise ValueError("Lifecycle callback not registered")
|
||||
result = lifecycle[phase](params)
|
||||
elif method == "framely.ui.visibility" and visibility is not None:
|
||||
result = visibility(params)
|
||||
else:
|
||||
result = dispatch(method, params)
|
||||
response = {"id": request["id"], "result": result}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './visibility';
|
||||
import {createVisibilityApi, type UiVisibility} from './visibility';
|
||||
export * from './lifecycle';
|
||||
import {installScrollbars} from './scrollbars';
|
||||
import React from 'react';
|
||||
@@ -7,6 +9,7 @@ export interface Notification {id:string;title:string;body:string;image?:string;
|
||||
type Bridge={request:(op:string,p?:unknown)=>Promise<any>;subscribe:(f:(e:unknown)=>void)=>()=>void};
|
||||
function bridge():Bridge{const b=(window as any).__framelyBridge;if(!b)throw new Error('This page requires the Framely plugin host');return b;}
|
||||
export const framely={
|
||||
ui:createVisibilityApi(bridge),
|
||||
language:{get:():Promise<{preference:string;language:string}>=>bridge().request('language.get')},
|
||||
call:<T=unknown>(method:string,params:unknown={}):Promise<T>=>bridge().request('call',{method,params}),
|
||||
windows:{open:(window:string)=>bridge().request('window.open',{window}),close:(window:string)=>bridge().request('window.close',{window})},
|
||||
@@ -56,3 +59,5 @@ export function useBackend<T=unknown>(method:string,params:unknown={}){const[dat
|
||||
|
||||
export interface DependencyStatus {id:string;required:boolean;constraint:string|{version:string;source:string};version:string|null;enabled:boolean;matches:boolean;available:boolean;state?:{phase:string}|null}
|
||||
export function useDependencies(){const [items,setItems]=React.useState<DependencyStatus[]>([]),[error,setError]=React.useState<string|null>(null);React.useEffect(()=>{let live=true;const load=()=>{framely.dependencies().then(v=>{if(live){setItems(v);setError(null);}}).catch(e=>{if(live)setError(String(e));});};load();const off=framely.onEvent(e=>{if((e as any)?.type==='dependencies.changed')load();});return()=>{live=false;off();};},[]);return {items,error};}
|
||||
|
||||
export function useVisibility(){const[state,setState]=React.useState<UiVisibility>({known:false,captureObscured:false,pageVisible:false,sequence:0});const[error,setError]=React.useState<unknown>(null);React.useEffect(()=>framely.ui.onVisibilityChanged(value=>{setState(value);setError(null);},setError),[]);return{...state,error};}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Visibility in the headset. Unknown state must be treated as obscured by capture plugins. */
|
||||
export interface CaptureVisibility { known:boolean; captureObscured:boolean; sequence:number; sessionId?:string; views?:Record<string,boolean> }
|
||||
export interface UiVisibility extends CaptureVisibility { pageVisible:boolean }
|
||||
export interface VisibilityBridge { request:(op:string,p?:unknown)=>Promise<any>; subscribe:(f:(e:unknown)=>void)=>()=>void }
|
||||
export function createVisibilityApi(getBridge:()=>VisibilityBridge) {
|
||||
const getVisibility=():Promise<UiVisibility>=>getBridge().request('ui.visibility.get');
|
||||
return {getVisibility,onVisibilityChanged(callback:(state:UiVisibility)=>void,onError?:(error:unknown)=>void):()=>void {
|
||||
let live=true,sequence=-1,session:string|undefined;
|
||||
const retired=new Set<string>();
|
||||
const accept=(state:UiVisibility)=>{
|
||||
if(!live||!state||!Number.isSafeInteger(state.sequence))return;
|
||||
if(state.sessionId!==session){if(state.sessionId&&retired.has(state.sessionId))return;if(session)retired.add(session);session=state.sessionId;sequence=-1;}
|
||||
if(state.sequence>=sequence){sequence=state.sequence;callback(state);}
|
||||
};
|
||||
const off=getBridge().subscribe(event=>{const e=event as {type?:string;data?:UiVisibility};if(e?.type==='ui.visibility.changed'&&e.data)accept(e.data);});
|
||||
getVisibility().then(accept).catch(error=>{if(live)onError?.(error);});
|
||||
return()=>{live=false;off();};
|
||||
}};
|
||||
}
|
||||
/** Handle the manager-only JSON-line method in an existing backend dispatcher. */
|
||||
export function registerVisibility(callback:(state:CaptureVisibility)=>unknown|Promise<unknown>) {
|
||||
return async(method:string,state:CaptureVisibility)=>{if(method!=='framely.ui.visibility')throw Error('Not a visibility method');return await callback(state);};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod subscriptions;
|
||||
mod tests;
|
||||
mod update;
|
||||
mod uploads;
|
||||
mod visibility;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
@@ -33,8 +33,13 @@ pub struct Backend {
|
||||
skip_serializing_if = "is_default_memory_limit_mib"
|
||||
)]
|
||||
pub memory_limit_mib: u32,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub ui_visibility_events: bool,
|
||||
}
|
||||
pub const DEFAULT_MEMORY_LIMIT_MIB: u32 = 512;
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
fn default_memory_limit_mib() -> u32 {
|
||||
DEFAULT_MEMORY_LIMIT_MIB
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ pub fn hook(
|
||||
restart: RestartPolicy::Never,
|
||||
restart_limit: 3,
|
||||
memory_limit_mib: m.memory_limit_mib(),
|
||||
ui_visibility_events: false,
|
||||
});
|
||||
let unit = format!("framely-hook-{}-{:032x}", m.id, rand::random::<u128>());
|
||||
let cmd = command(&launch, payload, manager, logs, &unit)?;
|
||||
|
||||
+86
-1
@@ -38,6 +38,8 @@ pub struct Service {
|
||||
runtime: BTreeMap<String, Value>,
|
||||
waiting: BTreeSet<String>,
|
||||
batching: bool,
|
||||
visibility: crate::visibility::Tracker,
|
||||
visibility_pending: BTreeSet<String>,
|
||||
}
|
||||
impl Service {
|
||||
pub fn prepare_uninstall(&mut self) -> Result<Value> {
|
||||
@@ -158,6 +160,8 @@ impl Service {
|
||||
runtime: BTreeMap::new(),
|
||||
waiting: resume,
|
||||
batching: false,
|
||||
visibility: Default::default(),
|
||||
visibility_pending: BTreeSet::new(),
|
||||
};
|
||||
if initialized_defaults {
|
||||
service.save()?;
|
||||
@@ -330,6 +334,16 @@ impl Service {
|
||||
Duration::from_secs(l.timeout_seconds),
|
||||
)?;
|
||||
}
|
||||
if self.visibility.expire(Instant::now()) {
|
||||
self.publish_visibility();
|
||||
}
|
||||
if m.backend.as_ref().is_some_and(|b| b.ui_visibility_events) {
|
||||
running.call_timeout(
|
||||
"framely.ui.visibility",
|
||||
serde_json::to_value(&self.visibility.state)?,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
}
|
||||
ensure!(!running.exited(), "Backend exited during startup");
|
||||
Ok(running)
|
||||
})();
|
||||
@@ -516,7 +530,63 @@ impl Service {
|
||||
}
|
||||
let _ = self.save();
|
||||
}
|
||||
fn publish_visibility(&mut self) {
|
||||
let state = serde_json::to_value(&self.visibility.state).unwrap();
|
||||
process::event(
|
||||
&self.events,
|
||||
json!({"kind":"ui.visibility.changed","data":state}),
|
||||
);
|
||||
self.visibility_pending.extend(
|
||||
self.running
|
||||
.keys()
|
||||
.filter(|id| {
|
||||
self.db.plugins.get(*id).is_some_and(|p| {
|
||||
p.manifest
|
||||
.backend
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.ui_visibility_events)
|
||||
})
|
||||
})
|
||||
.cloned(),
|
||||
);
|
||||
self.deliver_visibility();
|
||||
}
|
||||
fn deliver_visibility(&mut self) {
|
||||
let state = serde_json::to_value(&self.visibility.state).unwrap();
|
||||
for id in self.visibility_pending.clone() {
|
||||
let Some(running) = self.running.get_mut(&id) else {
|
||||
self.visibility_pending.remove(&id);
|
||||
continue;
|
||||
};
|
||||
match running.call_timeout(
|
||||
"framely.ui.visibility",
|
||||
state.clone(),
|
||||
Duration::from_secs(1),
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.visibility_pending.remove(&id);
|
||||
if let Some(p) = self.db.plugins.get_mut(&id) {
|
||||
if p.error
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.starts_with("Visibility delivery failed:"))
|
||||
{
|
||||
p.error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(p) = self.db.plugins.get_mut(&id) {
|
||||
p.error = Some(format!("Visibility delivery failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn maintenance(&mut self) {
|
||||
if self.visibility.expire(Instant::now()) {
|
||||
self.publish_visibility();
|
||||
}
|
||||
self.deliver_visibility();
|
||||
if !self.agreement_accepted() {
|
||||
return;
|
||||
}
|
||||
@@ -619,6 +689,8 @@ impl Service {
|
||||
method,
|
||||
"status"
|
||||
| "events"
|
||||
| "host.ui.visibility"
|
||||
| "ui.visibility.get"
|
||||
| "language.list"
|
||||
| "language.save"
|
||||
| "agreement.status"
|
||||
@@ -640,6 +712,19 @@ impl Service {
|
||||
}
|
||||
let id = p["plugin"].as_str().unwrap_or("").to_owned();
|
||||
match method {
|
||||
"host.ui.visibility" => {
|
||||
let views = serde_json::from_value(p)?;
|
||||
if self.visibility.report(views, Instant::now())? {
|
||||
self.publish_visibility();
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"ui.visibility.get" => {
|
||||
if self.visibility.expire(Instant::now()) {
|
||||
self.publish_visibility();
|
||||
}
|
||||
serde_json::to_value(&self.visibility.state).map_err(Into::into)
|
||||
}
|
||||
"system.uninstall.prepare" => {
|
||||
ensure!(
|
||||
p["approve"].as_bool() == Some(true),
|
||||
@@ -867,7 +952,7 @@ impl Service {
|
||||
self.start(&id)?;
|
||||
let name = p["method"].as_str().context("Missing backend method")?;
|
||||
ensure!(
|
||||
!name.starts_with("framely.lifecycle."),
|
||||
!name.starts_with("framely.lifecycle.") && !name.starts_with("framely.ui."),
|
||||
"Lifecycle methods are reserved for the manager"
|
||||
);
|
||||
let r = self
|
||||
|
||||
+66
-2
@@ -336,7 +336,16 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/host/poll" {
|
||||
ensure!(self.native(&r), "Native authentication failed");
|
||||
ensure!(!remote && self.native(&r), "Native authentication failed");
|
||||
if r.method() == &Method::Post {
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(32769).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 32768, "Visibility report too large");
|
||||
let views: Value = serde_json::from_slice(&bytes)?;
|
||||
if views.is_object() {
|
||||
self.core("host.ui.visibility", views)?;
|
||||
}
|
||||
}
|
||||
let mut queue = self.commands.lock().unwrap();
|
||||
let mut commands = Vec::new();
|
||||
let mut size = 0;
|
||||
@@ -514,7 +523,10 @@ impl Agent {
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().read_to_end(&mut bytes)?;
|
||||
let request: Value = serde_json::from_slice(&bytes)?;
|
||||
let mut request: Value = serde_json::from_slice(&bytes)?;
|
||||
if remote && request["method"] == "ui.visibility.get" {
|
||||
request["params"] = json!({"view":""});
|
||||
}
|
||||
let result = if remote && request["method"] == "agreement.decline" {
|
||||
Ok(json!({"closed":false}))
|
||||
} else {
|
||||
@@ -552,6 +564,7 @@ impl Agent {
|
||||
| "agreement.decline"
|
||||
| "agreement.revoke"
|
||||
| "ui.events"
|
||||
| "ui.visibility.get"
|
||||
| "host.manager.open"
|
||||
| "host.manager.close"
|
||||
| "host.menu.close"
|
||||
@@ -659,6 +672,15 @@ impl Agent {
|
||||
"install.start" => self.jobs.install(self.socket.clone(), p),
|
||||
"job.status" => self.jobs.status(p["job"].as_str().context("Missing job")?),
|
||||
"job.cancel" => self.jobs.cancel(p["job"].as_str().context("Missing job")?),
|
||||
"ui.visibility.get" => {
|
||||
let mut state = self.core("ui.visibility.get", json!({}))?;
|
||||
state["pageVisible"] = json!(
|
||||
state["known"] == true
|
||||
&& state["views"][p["view"].as_str().unwrap_or("")] == true
|
||||
);
|
||||
state.as_object_mut().unwrap().remove("views");
|
||||
Ok(state)
|
||||
}
|
||||
"ui.events" => {
|
||||
let result = self
|
||||
.events
|
||||
@@ -1814,6 +1836,48 @@ mod tests {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn native_visibility_reports_require_native_auth_and_are_not_public_api() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap();
|
||||
let socket_worker = std::thread::spawn(move || {
|
||||
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
|
||||
for expected in ["host.ui.visibility", "agreement.status"] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
assert_eq!(request["method"], expected);
|
||||
ipc::write(
|
||||
&mut stream,
|
||||
&json!({"result":core.handle(request["method"].as_str().unwrap(),request["params"].clone()).unwrap()}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
core.handle("ui.visibility.get", json!({})).unwrap()["captureObscured"],
|
||||
true
|
||||
);
|
||||
});
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let worker = http_server(server, agent.clone(), stop.clone());
|
||||
let url = format!("{}/host/poll", agent.origin);
|
||||
assert!(ureq::post(&url).send_json(json!({"menu":true})).is_err());
|
||||
assert!(ureq::post(&url)
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.send_json(json!({"menu":true}))
|
||||
.is_err());
|
||||
ureq::post(&url)
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
.send_json(json!({"menu":true}))
|
||||
.unwrap();
|
||||
assert!(agent
|
||||
.api(json!({"method":"host.ui.visibility","params":{"menu":false}}))
|
||||
.is_err());
|
||||
socket_worker.join().unwrap();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn version_history_checks_identity_duplicates_and_schema() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let source = Source {
|
||||
|
||||
+106
@@ -1639,3 +1639,109 @@ fn disk_packages_stream_validation_and_review_hash() {
|
||||
drop(staged);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_manifest_and_reserved_methods() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bytes = fixture(dir.path(), "test.visibility", "1", 1, Some("steamos"));
|
||||
let mut manifest = package::verify(&bytes).unwrap().manifest;
|
||||
assert!(!manifest.backend.as_ref().unwrap().ui_visibility_events);
|
||||
manifest.backend.as_mut().unwrap().ui_visibility_events = true;
|
||||
let encoded = serde_json::to_value(&manifest).unwrap();
|
||||
assert_eq!(encoded["backend"]["uiVisibilityEvents"], true);
|
||||
assert!(serde_json::from_value::<Manifest>({
|
||||
let mut value = encoded;
|
||||
value["backend"]["uiVisibilityEvents"] = json!(1);
|
||||
value
|
||||
})
|
||||
.is_err());
|
||||
let mut core = accepted_service(dir.path(), 1000).unwrap();
|
||||
assert_eq!(
|
||||
core.handle("ui.visibility.get", json!({})).unwrap()["known"],
|
||||
false
|
||||
);
|
||||
core.handle(
|
||||
"host.ui.visibility",
|
||||
json!({"menu":false,"framely.manager":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
core.handle("ui.visibility.get", json!({})).unwrap()["captureObscured"],
|
||||
true
|
||||
);
|
||||
assert!(core
|
||||
.handle("host.ui.visibility", json!({"browser":true}))
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
core.handle("ui.visibility.get", json!({})).unwrap()["sequence"],
|
||||
1
|
||||
);
|
||||
let events = core.handle("events", json!({})).unwrap();
|
||||
assert!(events
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["kind"] == "ui.visibility.changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_backend_opt_in_restart_and_expiry() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(temp.path());
|
||||
let mut core =
|
||||
accepted_service(&temp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
for (id, opt_in) in [("test.visible", true), ("test.legacy", false)] {
|
||||
let payload = temp.path().join(id);
|
||||
fs::create_dir_all(&payload).unwrap();
|
||||
let backend = r#"#!/usr/bin/python3
|
||||
import sys,json
|
||||
states=[]
|
||||
fail_next=False
|
||||
for line in sys.stdin:
|
||||
r=json.loads(line)
|
||||
if r['method']=='fail-next':fail_next=True
|
||||
if r['method']=='framely.ui.visibility':
|
||||
if fail_next:
|
||||
fail_next=False;print(json.dumps({'id':r['id'],'error':'temporary visibility failure'}),flush=True);continue
|
||||
states.append(r['params'])
|
||||
print(json.dumps({'id':r['id'],'result':states}),flush=True)
|
||||
"#;
|
||||
fs::write(payload.join("backend.py"), backend).unwrap();
|
||||
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":id,"name":id,"author":"test","version":"1","backend":{"entry":"backend.py","runAs":"steamos","uiVisibilityEvents":opt_in},"files":{}});
|
||||
let path = temp.path().join(format!("{id}.json"));
|
||||
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
let package = temp.path().join(format!("{id}.framely"));
|
||||
crate::package::pack(&path, &payload, &package).unwrap();
|
||||
install(&mut core, &fs::read(package).unwrap(), json!({})).unwrap();
|
||||
core.handle("plugin.open", json!({"plugin":id})).unwrap();
|
||||
}
|
||||
let read = |core: &mut Service, id: &str| {
|
||||
core.handle("plugin.call", json!({"plugin":id,"method":"states"}))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(read(&mut core, "test.visible")[0]["known"], false);
|
||||
assert_eq!(read(&mut core, "test.legacy"), json!([]));
|
||||
core.handle("host.ui.visibility", json!({"menu":true}))
|
||||
.unwrap();
|
||||
assert_eq!(read(&mut core, "test.visible")[1]["captureObscured"], true);
|
||||
core.handle("plugin.restart", json!({"plugin":"test.visible"}))
|
||||
.unwrap();
|
||||
assert_eq!(read(&mut core, "test.visible")[0]["captureObscured"], true);
|
||||
assert!(core.handle("plugin.call",json!({"plugin":"test.visible","method":"framely.ui.visibility","params":{"known":true,"captureObscured":false}})).is_err());
|
||||
core.handle(
|
||||
"plugin.call",
|
||||
json!({"plugin":"test.visible","method":"fail-next"}),
|
||||
)
|
||||
.unwrap();
|
||||
core.handle("host.ui.visibility", json!({"menu":false}))
|
||||
.unwrap();
|
||||
assert_eq!(read(&mut core, "test.visible").as_array().unwrap().len(), 1);
|
||||
core.maintenance();
|
||||
assert_eq!(read(&mut core, "test.visible")[1]["captureObscured"], false);
|
||||
assert!(core.db.plugins["test.visible"].error.is_none());
|
||||
std::thread::sleep(std::time::Duration::from_millis(2100));
|
||||
core.maintenance();
|
||||
assert_eq!(read(&mut core, "test.visible")[2]["known"], false);
|
||||
assert_eq!(read(&mut core, "test.legacy"), json!([]));
|
||||
core.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Visibility {
|
||||
pub known: bool,
|
||||
pub capture_obscured: bool,
|
||||
pub sequence: u64,
|
||||
pub session_id: String,
|
||||
pub views: BTreeMap<String, bool>,
|
||||
}
|
||||
pub struct Tracker {
|
||||
pub state: Visibility,
|
||||
seen: Option<Instant>,
|
||||
}
|
||||
impl Default for Tracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: Visibility {
|
||||
session_id: format!("{:032x}", rand::random::<u128>()),
|
||||
..Default::default()
|
||||
},
|
||||
seen: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Tracker {
|
||||
pub fn report(&mut self, views: BTreeMap<String, bool>, now: Instant) -> Result<bool> {
|
||||
ensure!(
|
||||
views.len() <= 64
|
||||
&& views.keys().all(|k| k.len() <= 256
|
||||
&& (k == "menu" || k == "framely.manager" || k.starts_with("framely.window."))),
|
||||
"Invalid native visibility views"
|
||||
);
|
||||
self.seen = Some(now);
|
||||
let changed = !self.state.known || self.state.views != views;
|
||||
if changed {
|
||||
self.state = Visibility {
|
||||
known: true,
|
||||
capture_obscured: views.values().any(|v| *v),
|
||||
sequence: self.state.sequence + 1,
|
||||
session_id: self.state.session_id.clone(),
|
||||
views,
|
||||
};
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
pub fn expire(&mut self, now: Instant) -> bool {
|
||||
if self.state.known
|
||||
&& self
|
||||
.seen
|
||||
.is_none_or(|t| now.duration_since(t) > Duration::from_secs(2))
|
||||
{
|
||||
self.state = Visibility {
|
||||
sequence: self.state.sequence + 1,
|
||||
session_id: self.state.session_id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
self.seen = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn aggregate_close_and_lease() {
|
||||
let now = Instant::now();
|
||||
let mut t = Tracker::default();
|
||||
assert!(!t.state.known);
|
||||
assert!(t
|
||||
.report(
|
||||
BTreeMap::from([("menu".into(), false), ("framely.window.x.y".into(), true)]),
|
||||
now
|
||||
)
|
||||
.unwrap());
|
||||
assert!(t.state.capture_obscured);
|
||||
assert!(!t
|
||||
.report(t.state.views.clone(), now + Duration::from_secs(1))
|
||||
.unwrap());
|
||||
assert!(!t.expire(now + Duration::from_secs(2)));
|
||||
assert!(t.expire(now + Duration::from_secs(4)));
|
||||
assert!(!t.state.known);
|
||||
assert!(!t.expire(now + Duration::from_secs(5)));
|
||||
assert!(t
|
||||
.report(BTreeMap::new(), now + Duration::from_secs(6))
|
||||
.unwrap());
|
||||
assert!(!t.state.capture_obscured);
|
||||
assert_eq!(t.state.sequence, 3);
|
||||
}
|
||||
#[test]
|
||||
fn reject_untrusted_view() {
|
||||
assert!(Tracker::default()
|
||||
.report(BTreeMap::from([("remote".into(), true)]), Instant::now())
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdtemp,rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
const dir=await mkdtemp(join(process.env.TMPDIR??tmpdir(),'framely-visibility-'));
|
||||
try {
|
||||
const file=join(dir,'visibility.mjs');
|
||||
await build({entryPoints:['sdk/src/visibility.ts'],bundle:true,platform:'node',format:'esm',outfile:file});
|
||||
const {createVisibilityApi,registerVisibility}=await import(pathToFileURL(file));
|
||||
let deliver,resolve,unsubscribed=false;
|
||||
const bridge={request:()=>new Promise(r=>resolve=r),subscribe:f=>{deliver=f;return()=>{unsubscribed=true;}}};
|
||||
const states=[],api=createVisibilityApi(()=>bridge);
|
||||
const off=api.onVisibilityChanged(s=>states.push(s));
|
||||
const newer={known:true,captureObscured:true,pageVisible:true,sequence:2};
|
||||
deliver({type:'ui.visibility.changed',data:newer});
|
||||
resolve({known:true,captureObscured:false,pageVisible:false,sequence:1});
|
||||
await new Promise(r=>setImmediate(r));
|
||||
assert.deepEqual(states,[newer],'stale initial query must not overwrite a newer event');
|
||||
off();deliver({type:'ui.visibility.changed',data:{...newer,sequence:3}});
|
||||
assert(unsubscribed);assert.equal(states.length,1,'unsubscribe suppresses later callbacks');
|
||||
let lateResolve;
|
||||
const lateStates=[];
|
||||
const late=createVisibilityApi(()=>({request:()=>new Promise(r=>lateResolve=r),subscribe:()=>()=>{}}));
|
||||
late.onVisibilityChanged(s=>lateStates.push(s))();lateResolve(newer);
|
||||
await new Promise(r=>setImmediate(r));assert.deepEqual(lateStates,[],'unmount suppresses pending initial query');
|
||||
const restarted=[],epochApi=createVisibilityApi(()=>bridge);
|
||||
const epochOff=epochApi.onVisibilityChanged(s=>restarted.push(s));
|
||||
deliver({type:'ui.visibility.changed',data:{...newer,sequence:100,sessionId:'old'}});
|
||||
deliver({type:'ui.visibility.changed',data:{...newer,sequence:1,sessionId:'new'}});
|
||||
deliver({type:'ui.visibility.changed',data:{...newer,sequence:101,sessionId:'old'}});
|
||||
assert.equal(restarted.length,2,'new host session resets ordering; retired sessions cannot overwrite it');epochOff();
|
||||
let error;
|
||||
const failed=createVisibilityApi(()=>({request:()=>Promise.reject(Error('disconnected')),subscribe:()=>()=>{}}));
|
||||
failed.onVisibilityChanged(()=>{},e=>error=e);
|
||||
await new Promise(r=>setImmediate(r));assert.equal(error.message,'disconnected');
|
||||
const dispatch=registerVisibility(state=>state.captureObscured);
|
||||
assert.equal(await dispatch('framely.ui.visibility',newer),true);
|
||||
await assert.rejects(dispatch('fake',newer));
|
||||
console.log('PASS SDK visibility initial query, event ordering, unsubscribe, disconnect and backend dispatcher');
|
||||
} finally {await rm(dir,{recursive:true,force:true});}
|
||||
@@ -1,6 +1,8 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import contextlib
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
@@ -62,6 +64,27 @@ class BootstrapTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
bootstrap.HTTPSRedirect().redirect_request(None, None, 302, '', {}, 'http://example.org/package')
|
||||
|
||||
def test_extraction_progress_reports_real_bytes_and_never_completes_invalid_release(self):
|
||||
entries = [('framely-1/' + name, b'1' if name == 'VERSION' else b'test') for name in ['VERSION', 'SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely']]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
reports = []
|
||||
bootstrap.extract_archive(self.archive(root, entries), root / 'out', lambda *value: reports.append(value))
|
||||
total = sum(len(contents) for _, contents in entries)
|
||||
self.assertEqual(reports[0], ('extract', 0, total))
|
||||
self.assertEqual(reports[-1], ('extract', total, total))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
reports = []
|
||||
with self.assertRaises(ValueError):
|
||||
bootstrap.extract_archive(self.archive(root, [('framely-1/VERSION', b'wrong')]), root / 'out', lambda *value: reports.append(value))
|
||||
self.assertFalse(any(done == total and total > 0 for _, done, total in reports))
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
bootstrap.emit_progress('configure')
|
||||
payload = json.loads(output.getvalue().removeprefix('FRAMELY_PROGRESS '))
|
||||
self.assertEqual(payload, {'step': 'configure'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,10 @@
|
||||
#include "ui_visibility.h"
|
||||
#include <cassert>
|
||||
int main(){
|
||||
assert(!capture_view_visible(false,true,true,false,true));
|
||||
assert(!capture_view_visible(false,false,true,true,true));
|
||||
assert(!capture_view_visible(false,true,true,true,false));
|
||||
assert(capture_view_visible(false,true,true,true,true));
|
||||
assert(capture_view_visible(false,true,false,false,false));
|
||||
assert(!capture_view_visible(true,true,false,false,false));
|
||||
}
|
||||
+33
-3
@@ -11,6 +11,7 @@ import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
@@ -18,6 +19,13 @@ DEFAULT_REPO = 'SteamFramelyHomebrew/framely'
|
||||
MAX_ARCHIVE = 2 * 1024**3
|
||||
|
||||
|
||||
def emit_progress(step, completed=None, total=None):
|
||||
payload = {'step': step}
|
||||
if total is not None and total > 0:
|
||||
payload.update(completed=completed, total=total)
|
||||
print('FRAMELY_PROGRESS ' + json.dumps(payload), flush=True)
|
||||
|
||||
|
||||
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlparse(newurl).scheme != 'https':
|
||||
@@ -67,7 +75,7 @@ def verify_archive(archive, checksums):
|
||||
raise ValueError('Archive SHA256 mismatch; installation stopped')
|
||||
|
||||
|
||||
def extract_archive(archive, destination):
|
||||
def extract_archive(archive, destination, progress=None):
|
||||
# Check all headers before writing, and never use tar's link extraction.
|
||||
with tarfile.open(archive, 'r:gz') as source:
|
||||
members = source.getmembers()
|
||||
@@ -88,6 +96,9 @@ def extract_archive(archive, destination):
|
||||
raise ValueError('Expected one release directory')
|
||||
destination = pathlib.Path(destination)
|
||||
destination.mkdir(mode=0o700)
|
||||
total, completed, reported_at = sum(m.size for m in members), 0, time.monotonic()
|
||||
if progress:
|
||||
progress('extract', completed, total)
|
||||
for member in members:
|
||||
path = destination.joinpath(*pathlib.PurePosixPath(member.name).parts)
|
||||
if member.isdir():
|
||||
@@ -95,7 +106,12 @@ def extract_archive(archive, destination):
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source.extractfile(member) as data, path.open('xb') as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
while block := data.read(256 * 1024):
|
||||
output.write(block)
|
||||
completed += len(block)
|
||||
if progress and time.monotonic() - reported_at >= 0.2:
|
||||
progress('extract', completed, total)
|
||||
reported_at = time.monotonic()
|
||||
path.chmod(0o755 if member.mode & 0o111 else 0o644)
|
||||
base = destination / prefixes.pop()
|
||||
version = (base / 'VERSION').read_text().strip()
|
||||
@@ -104,6 +120,8 @@ def extract_archive(archive, destination):
|
||||
for required in ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely']:
|
||||
if not (base / required).is_file():
|
||||
raise ValueError('Missing release file: ' + required)
|
||||
if progress:
|
||||
progress('extract', completed, total)
|
||||
return base
|
||||
|
||||
|
||||
@@ -131,6 +149,7 @@ def confirm(message):
|
||||
|
||||
|
||||
def root_operation(args):
|
||||
report = emit_progress if getattr(args, 'progress_json', False) else lambda *args: None
|
||||
import fcntl
|
||||
with open('/run/framely-installer.lock', 'a') as lock:
|
||||
try:
|
||||
@@ -143,17 +162,21 @@ def root_operation(args):
|
||||
if args.action in ('install', 'update'):
|
||||
if args.action == 'update':
|
||||
current_release()
|
||||
report('verify')
|
||||
verify_archive(args.archive, args.checksums)
|
||||
report('stage')
|
||||
work = tempfile.mkdtemp(prefix='.framely-install-', dir='/home')
|
||||
staged_archive = pathlib.Path(work) / args.archive.name
|
||||
staged_checksums = pathlib.Path(work) / 'SHA256SUMS'
|
||||
shutil.copyfile(args.archive, staged_archive)
|
||||
shutil.copyfile(args.checksums, staged_checksums)
|
||||
verify_archive(staged_archive, staged_checksums)
|
||||
base = extract_archive(staged_archive, pathlib.Path(work) / 'unpacked')
|
||||
base = extract_archive(staged_archive, pathlib.Path(work) / 'unpacked', report)
|
||||
report('extract')
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True)
|
||||
else:
|
||||
base = current_release()
|
||||
report('configure')
|
||||
if args.action in ('install', 'update'):
|
||||
command = ['bash', str(base / 'install.sh')]
|
||||
try:
|
||||
@@ -163,6 +186,7 @@ def root_operation(args):
|
||||
if same:
|
||||
command.append('--repair')
|
||||
subprocess.run(command + [args.user], check=True)
|
||||
report('activate')
|
||||
# Preconfigure the official update source without requiring agreement acceptance.
|
||||
state = pathlib.Path('/home/.framely/state')
|
||||
config = state / 'update-source.json'
|
||||
@@ -183,6 +207,7 @@ def root_operation(args):
|
||||
subprocess.run(['bash', '/home/.framely/repair.sh', args.user], check=True)
|
||||
elif args.action == 'rollback':
|
||||
subprocess.run(['bash', str(base / 'rollback.sh')], check=True)
|
||||
report('activate')
|
||||
print('Framely operation completed.', flush=True)
|
||||
finally:
|
||||
if work:
|
||||
@@ -198,6 +223,7 @@ def main(argv=None):
|
||||
parser.add_argument('--checksums', type=pathlib.Path, help='External SHA256SUMS for the archive')
|
||||
parser.add_argument('--user', default=os.environ.get('SUDO_USER', 'steamos'))
|
||||
parser.add_argument('--yes', action='store_true', help='Confirm the selected operation')
|
||||
parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args(argv)
|
||||
if not re.fullmatch(r'[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+', args.repo):
|
||||
parser.error('Invalid repository')
|
||||
@@ -239,6 +265,8 @@ def main(argv=None):
|
||||
download(assets[packages[0]]['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:
|
||||
emit_progress('verify')
|
||||
verify_archive(args.archive, args.checksums)
|
||||
print('Archive SHA256 verified.', flush=True)
|
||||
if os.geteuid() == 0:
|
||||
@@ -247,6 +275,8 @@ def main(argv=None):
|
||||
command = ['sudo', '--', sys.executable, str(pathlib.Path(__file__).resolve()), args.action, '--repo', args.repo, '--user', args.user, '--yes']
|
||||
if args.archive:
|
||||
command += ['--archive', str(args.archive), '--checksums', str(args.checksums)]
|
||||
if args.progress_json:
|
||||
command += ['--progress-json']
|
||||
# stdin belongs to curl | bash; sudo reads its password from the terminal.
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in menu_geometry keyboard_state notification_geometry; do
|
||||
for test in menu_geometry keyboard_state notification_geometry ui_visibility; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
+2
-1
@@ -32,13 +32,14 @@ function PluginFrame({plugin,entry='quick'}:{plugin:string;entry?:string}){
|
||||
const {id,op,params}=e.data;if(!Number.isSafeInteger(id)||typeof op!=='string')return;
|
||||
try{let result;
|
||||
if(op==='language.get')result={preference:pluginLanguagePreference,language:currentLanguage()};
|
||||
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
|
||||
else if(op==='haptic')result=await api('host.haptic',{view:viewKey()});
|
||||
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
|
||||
else{const method=methods[op];if(!method)throw new Error(t('插件请求不支持的能力'));result=await api(method,{...params,plugin});}
|
||||
if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,result},'*');
|
||||
}catch(e){if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,error:String(e)},'*');}
|
||||
}
|
||||
const event=(e:Event)=>{const value=(e as CustomEvent).detail;if(value.kind==='plugin.dependency.changed')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'dependencies.changed'}},'*');if(value.plugin===plugin&&value.kind==='plugin.event')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:value.event,data:value.data}},'*');};
|
||||
const event=(e:Event)=>{const value=(e as CustomEvent).detail;if(value.kind==='ui.visibility.changed'){api('ui.visibility.get',{view:viewKey()}).then(data=>{if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'ui.visibility.changed',data}},'*');}).catch(()=>{});}if(value.kind==='plugin.dependency.changed')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'dependencies.changed'}},'*');if(value.plugin===plugin&&value.kind==='plugin.event')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:value.event,data:value.data}},'*');};
|
||||
const language=()=>ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'language.changed',data:{preference:pluginLanguagePreference,language:currentLanguage()}}},'*');
|
||||
window.addEventListener('framely.event',event);window.addEventListener('framely.language',language);window.addEventListener('message',message);
|
||||
void (async()=>{
|
||||
|
||||
Reference in new issue
Block a user