Compare commits

...
27 changed files with 1026 additions and 83 deletions

No files matched your search

Generated
+1 -1
View File
@@ -291,7 +291,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.2-preview.9"
version = "0.4.2-preview.11"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.2-preview.9"
version = "0.4.2-preview.11"
edition = "2021"
license = "AGPL-3.0-only"
+4
View File
@@ -117,3 +117,7 @@ Development preview simulates windows, notifications and some bridge operations.
`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)`.
`framely.ui.close()` closes the current native surface: the quick panel or the current plugin window. It does not stop the backend or recording. Requires a host with this bridge method.
Standalone plugin windows display the plugin page without a host title bar. Plugins provide their own title, navigation and close button using `framely.ui.close()` or `framely.windows.close(id)`. Quick panels retain host navigation.
+6 -6
View File
@@ -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.9`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.7`), `.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.11`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.8`), `.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.9
git push origin v0.4.2-preview.9
git tag v0.4.2-preview.11
git push origin v0.4.2-preview.11
# Independent installer release:
git tag installer-v0.4.1-preview.7
git push origin installer-v0.4.1-preview.7
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
```
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.
+2
View File
@@ -26,4 +26,6 @@ Settings provides language, network panel and proxy configuration. HTTP and GitH
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. About shows the device version, updates and terms. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
In About, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
Log commands are in the [installation guide](installation.md). A working manager does not establish that all plugins work. Enable plugins individually and verify their sources and runtime users.
+4
View File
@@ -124,3 +124,7 @@ const off = framely.ui.onVisibilityChanged(state => {
});
// 页面卸载时调用 off()
```
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
+6 -6
View File
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.9`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.7`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.11`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.8`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.4.2-preview.9
git push origin v0.4.2-preview.9
git tag v0.4.2-preview.11
git push origin v0.4.2-preview.11
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.7
git push origin installer-v0.4.1-preview.7
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
+2
View File
@@ -26,4 +26,6 @@
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“关于”查看本体版本、检查更新和复查协议。撤回协议会停用全部插件,再次同意后需要手动启用。
在“关于”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
日志排查命令见[安装指南](installation.md#修复与日志)。界面能打开不代表所有插件都正常;先逐个启用,确认运行身份与来源可信。
+14 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.7"
version = "0.4.1-preview.8"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -2003,6 +2003,7 @@ dependencies = [
"tempfile",
"ureq",
"url",
"winreg",
"winresource",
"zeroize",
]
@@ -6549,6 +6550,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -7450,6 +7462,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"url",
"webpki-roots 0.26.11",
]
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.7"
version = "0.4.1-preview.8"
edition = "2024"
license = "AGPL-3.0-only"
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hex = "0.4"
ureq = { version = "2.12", features = ["json"] }
ureq = { version = "2.12", features = ["json", "socks-proxy"] }
url = "2"
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
if-addrs = "0.13"
@@ -33,6 +33,9 @@ tempfile = "3"
zeroize = "1"
directories = "6"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "=0.55.0"
[build-dependencies]
winresource = "=0.1.31"
+5 -1
View File
@@ -34,6 +34,8 @@ Scanning, downloads, and SSH work run on background threads, with progress and l
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
Release queries, checksum files and package downloads automatically use the computer's manual system proxy: Windows Internet Settings, macOS network settings, or Linux GNOME/KDE settings. `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (or their lowercase forms) override desktop settings; `NO_PROXY` and desktop bypass rules are respected, including on redirects. HTTP CONNECT and SOCKS proxies are supported. PAC scripts and IPv6 proxy endpoints are currently unsupported; use a manual proxy with a hostname or IPv4 address. SSH/SFTP and downloads initiated by Frame use the device connection and network rather than the computer's HTTP proxy.
## Build
Linux requires C/C++ build tools, CMake, pkg-config, and development libraries for Fontconfig/Freetype, X11/XCB, XKBCommon, Wayland, and Vulkan. macOS requires Xcode command-line tools. Windows requires MSVC, CMake, and Perl for built-in OpenSSL. GPUI Kit is pinned to 0.7.0; matching dependencies are locked in `Cargo.lock`.
@@ -60,12 +62,14 @@ 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.7`) 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.8`) 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.
The titlebar and application icons share the pixel logo. Windows builds embed the ICO in the EXE; macOS packaging uses the system `iconutil` to generate and configure `.icns`. On Linux, run `bash install-desktop-entry.sh` from the extracted archive to register an icon-bearing entry in the current user's application menu. The entry points to that directory; rerun the script after moving it.
Installer archives do not include a separate `assets` directory. The UI logo is embedded in the executable; the Linux registration script embeds its menu icon, while macOS keeps its application icon inside the `.app` bundle.
Automated tests and Actions builds validate software and artifacts. Discovery, login, permissions, update, rollback, and uninstall still need acceptance testing on actual computers and Frame hardware.
Discovery queries `frame.local` mDNS address records directly. It does not require an advertised SSH service or system DNS support for `.local`. Run the same discovery logic independently for diagnosis:
+5 -1
View File
@@ -34,6 +34,8 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。
版本查询、校验文件和压缩包下载自动读取电脑的手动系统代理:Windows Internet 设置、macOS 网络设置及 Linux GNOME/KDE 设置。`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`(也支持小写)优先于桌面设置;遵循 `NO_PROXY` 及桌面绕过规则,重定向后也会重新判断。支持 HTTP CONNECT 和 SOCKS 代理。暂不支持 PAC 脚本及 IPv6 代理地址,可使用主机名或 IPv4 地址的手动代理。SSH/SFTP 使用设备连接,Frame 主动下载使用设备网络,不使用电脑的 HTTP 代理。
## 构建
Linux 需要 C/C++ 构建工具、CMake、pkg-config、Fontconfig/Freetype、X11/XCB、XKBCommon、Wayland 和 Vulkan 开发库。macOS 需要 Xcode 命令行工具;Windows 需要 MSVC、CMake 和 Perl(用于内置 OpenSSL)。GPUI Kit 固定为 0.7.0,配套依赖由 `Cargo.lock` 锁定。
@@ -60,12 +62,14 @@ 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.7`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.8`)触发 `.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 代码签名,发布前应分别验证系统的首次启动体验。
安装器标题栏和应用图标使用统一的像素 Logo。Windows 构建将 ICO 嵌入 EXE,macOS 打包使用系统 `iconutil` 生成并配置 `.icns`。Linux 解压后可在解压目录执行 `bash install-desktop-entry.sh`,把带图标的入口注册到当前用户的应用菜单;入口指向该目录,移动后需重新执行脚本。
安装器压缩包不包含独立的 `assets` 目录。界面 Logo 内嵌在可执行文件中,Linux 注册脚本内嵌菜单图标,macOS 的应用图标保存在 `.app` 内部。
所有自动测试和 Actions 构建只能验证软件及产物;仍需在真实电脑和 Frame 上完成扫描、登录、权限、升级回滚及卸载验收。
扫描会直接查询 `frame.local` 的 mDNS 地址记录,不要求设备广播 SSH 服务,也不依赖电脑的系统 DNS 支持 `.local`。可单独运行与界面相同的发现逻辑进行诊断:
+5 -2
View File
@@ -3,7 +3,7 @@
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")" && pwd)
python3 - "$base" <<'PY'
import os, pathlib, shutil
import os, pathlib
base = pathlib.Path(__import__('sys').argv[1])
binary = base / 'framely-installer'
if not binary.is_file():
@@ -13,7 +13,10 @@ apps = data / 'applications'
icons = data / 'icons/hicolor/scalable/apps'
apps.mkdir(parents=True, exist_ok=True)
icons.mkdir(parents=True, exist_ok=True)
shutil.copy2(base / 'assets/branding/framely-app-icon.svg', icons / 'org.framely.installer.svg')
icon = None # Embedded by tools/package-installer.py.
if icon is None:
icon = (base.parent / 'assets/branding/framely-app-icon.svg').read_bytes()
(icons / 'org.framely.installer.svg').write_bytes(icon)
# Desktop entries parse backslash escapes before parsing the quoted Exec value.
quoted = str(binary).replace('%', '%%').replace('\\', '\\\\')
for character in ('"', '`', '$'):
+1
View File
@@ -2,6 +2,7 @@ pub mod discovery;
pub mod instance;
pub mod maintenance;
pub mod progress;
pub mod proxy;
pub mod release;
pub mod remote;
pub const DEFAULT_REPO: &str = "SteamFramelyHomebrew/framely";
+1 -2
View File
@@ -615,8 +615,7 @@ impl Installer {
(None, None)
};
if let (Some(archive), Some(sums)) = (&archive, &sums) {
progress(Progress::new(Stage::Verify, "校验本地安装包 SHA256"));
release::verify(archive, sums)?;
release::verify_with_progress(archive, sums, &mut progress)?;
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
}
remote::operate(
+560
View File
@@ -0,0 +1,560 @@
//! Read proxy settings without changing the computer or the SSH device.
use anyhow::{Result, bail, ensure};
use std::{collections::HashMap, net::IpAddr};
use url::Url;
#[derive(Default)]
struct Settings {
proxies: HashMap<String, String>,
bypass: Vec<String>,
exclude_simple: bool,
reverse_bypass: bool,
}
impl Settings {
fn select(&self, url: &Url) -> Option<&str> {
let host = url.host_str()?;
let excluded = self.exclude_simple && !host.contains('.')
|| self
.bypass
.iter()
.any(|pattern| bypass_matches(pattern, host, url.port_or_known_default()));
if excluded != self.reverse_bypass {
return None;
}
self.proxies
.get(url.scheme())
.or_else(|| self.proxies.get("*"))
.map(String::as_str)
}
}
fn env_settings(mut get: impl FnMut(&str) -> Option<String>) -> Option<Settings> {
let mut settings = Settings::default();
for (scheme, lower, upper) in [
("https", "https_proxy", "HTTPS_PROXY"),
("http", "http_proxy", "HTTP_PROXY"),
("*", "all_proxy", "ALL_PROXY"),
] {
if let Some(value) = get(lower).or_else(|| get(upper)) {
settings.proxies.insert(scheme.into(), value);
}
}
(!settings.proxies.is_empty()).then_some(settings)
}
pub fn for_url(url: &Url) -> Result<Option<ureq::Proxy>> {
if let Ok(bypass) = std::env::var("no_proxy").or_else(|_| std::env::var("NO_PROXY")) {
if split_bypass(&bypass).iter().any(|pattern| {
bypass_matches(
pattern,
url.host_str().unwrap_or(""),
url.port_or_known_default(),
)
}) {
return Ok(None);
}
}
let settings = match env_settings(|key| std::env::var(key).ok()) {
Some(settings) => settings,
None => system_settings()?.unwrap_or_default(),
};
settings
.select(url)
.filter(|value| !value.trim().is_empty())
.map(parse_proxy)
.transpose()
}
fn parse_proxy(value: &str) -> Result<ureq::Proxy> {
let value = value.trim();
let value = if value.contains("://") {
value.to_owned()
} else {
format!("http://{value}")
};
let value = value
.strip_prefix("socks5h://")
.map(|rest| format!("socks5://{rest}"))
.unwrap_or(value);
let parsed = Url::parse(&value)?;
ensure!(
matches!(
parsed.scheme(),
"http" | "socks" | "socks4" | "socks4a" | "socks5"
) && parsed.host_str().is_some(),
"系统代理地址无效或协议不受支持"
);
ensure!(
!matches!(parsed.host(), Some(url::Host::Ipv6(_))),
"当前下载客户端暂不支持 IPv6 代理地址,请使用主机名或 IPv4 地址"
);
ureq::Proxy::new(value).map_err(|_| anyhow::anyhow!("系统代理地址无效"))
}
fn split_bypass(value: &str) -> Vec<String> {
value
.split([',', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn bypass_matches(pattern: &str, host: &str, port: Option<u16>) -> bool {
let pattern = pattern.trim().to_ascii_lowercase();
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if pattern == "<local>" {
return !host.contains('.');
}
if let Some((network, bits)) = pattern.split_once('/') {
if let (Ok(network), Ok(address), Ok(bits)) = (
network.parse::<IpAddr>(),
host.parse::<IpAddr>(),
bits.parse::<u32>(),
) {
return match (network, address) {
(IpAddr::V4(network), IpAddr::V4(address)) if bits <= 32 => {
bits == 0
|| u32::from(network) >> (32 - bits) == u32::from(address) >> (32 - bits)
}
(IpAddr::V6(network), IpAddr::V6(address)) if bits <= 128 => {
bits == 0
|| u128::from(network) >> (128 - bits)
== u128::from(address) >> (128 - bits)
}
_ => false,
};
}
}
let (pattern, required_port) = match pattern.rsplit_once(':') {
Some((name, number)) if !name.contains(':') => (name, number.parse::<u16>().ok()),
_ => (pattern.as_str(), None),
};
if required_port.is_some() && required_port != port {
return false;
}
let domain = pattern.trim_start_matches('.');
if !pattern.contains('*') {
return host == domain || host.ends_with(&format!(".{domain}"));
}
// Match '*' throughout the hostname, including Windows IP wildcards.
let (pattern, host) = (pattern.as_bytes(), host.as_bytes());
let (mut p, mut h, mut star, mut resume) = (0, 0, None, 0);
while h < host.len() {
if p < pattern.len() && pattern[p] == host[h] {
p += 1;
h += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
p += 1;
resume = h;
} else if let Some(index) = star {
p = index + 1;
resume += 1;
h = resume;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
const PAC_ERROR: &str = "暂不支持 PAC 自动代理脚本,请配置手动系统代理或 HTTPS_PROXY";
#[cfg(target_os = "windows")]
fn system_settings() -> Result<Option<Settings>> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, KEY_READ},
};
let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings",
KEY_READ,
) else {
return Ok(None);
};
if key.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 1 {
if key
.get_value::<String, _>("AutoConfigURL")
.is_ok_and(|value| !value.trim().is_empty())
{
bail!(PAC_ERROR);
}
return Ok(None);
}
let Ok(server) = key.get_value::<String, _>("ProxyServer") else {
return Ok(None);
};
let mut settings = parse_windows_servers(&server);
settings.bypass = split_bypass(
&key.get_value::<String, _>("ProxyOverride")
.unwrap_or_default(),
);
settings.exclude_simple = settings.bypass.iter().any(|value| value == "<local>");
Ok(Some(settings))
}
#[cfg(target_os = "macos")]
fn system_settings() -> Result<Option<Settings>> {
let output = std::process::Command::new("/usr/sbin/scutil")
.arg("--proxy")
.output()?;
if !output.status.success() {
return Ok(None);
}
parse_macos(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut settings = Settings::default();
let (mut depth, mut exceptions) = (0usize, false);
for line in text.lines().map(str::trim) {
if line == "}" {
depth = depth.saturating_sub(1);
if depth == 1 {
exceptions = false;
}
continue;
}
if depth == 1 {
if let Some((key, value)) = line.split_once(" : ") {
if key == "ExceptionsList" {
exceptions = true;
} else {
values.insert(key, value);
}
}
} else if depth == 2 && exceptions {
if let Some((_, value)) = line.split_once(" : ") {
settings.bypass.push(value.into());
}
}
if line.ends_with('{') {
depth += 1;
}
}
for (scheme, name, protocol) in [
("http", "HTTP", "http"),
("https", "HTTPS", "http"),
("*", "SOCKS", "socks5"),
] {
if values.get(format!("{name}Enable").as_str()) == Some(&"1") {
if let (Some(host), Some(port)) = (
values.get(format!("{name}Proxy").as_str()),
values.get(format!("{name}Port").as_str()),
) {
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
}
settings.exclude_simple = values.get("ExcludeSimpleHostnames") == Some(&"1");
if settings.proxies.is_empty() && values.get("ProxyAutoConfigEnable") == Some(&"1") {
bail!(PAC_ERROR);
}
Ok(Some(settings))
}
#[cfg(any(target_os = "windows", test))]
fn parse_windows_servers(server: &str) -> Settings {
let mut settings = Settings::default();
for entry in server.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let (scheme, value) = entry.split_once('=').unwrap_or(("*", entry));
let scheme = scheme.to_ascii_lowercase();
if scheme == "socks" {
let value = if value.contains("://") {
value.into()
} else {
format!("socks5://{value}")
};
settings.proxies.insert("*".into(), value);
} else {
settings.proxies.insert(scheme, value.into());
}
}
settings
}
#[cfg(target_os = "linux")]
fn system_settings() -> Result<Option<Settings>> {
let kde = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("kde");
if kde {
if let Some(settings) = kde_settings()? {
return Ok(Some(settings));
}
}
if let Ok(output) = std::process::Command::new("gsettings")
.args(["list-recursively", "org.gnome.system.proxy"])
.output()
{
if output.status.success() {
if let Some(settings) = parse_gnome(&String::from_utf8_lossy(&output.stdout))? {
return Ok(Some(settings));
}
}
}
if !kde {
return kde_settings();
}
Ok(None)
}
#[cfg(target_os = "linux")]
fn kde_settings() -> Result<Option<Settings>> {
let mut paths = Vec::new();
if let Some(config) = std::env::var_os("XDG_CONFIG_HOME") {
paths.push(std::path::PathBuf::from(config));
} else if let Some(home) = std::env::var_os("HOME") {
paths.push(std::path::PathBuf::from(home).join(".config"));
}
paths.extend(std::env::split_paths(
&std::env::var_os("XDG_CONFIG_DIRS").unwrap_or_else(|| "/etc/xdg".into()),
));
for path in paths {
if let Ok(text) = std::fs::read_to_string(path.join("kioslaverc")) {
if let Some(settings) = parse_kde(&text)? {
return Ok(Some(settings));
}
}
}
Ok(None)
}
#[cfg(any(target_os = "linux", test))]
fn unquote(value: &str) -> String {
let value = value.trim();
let quoted = value
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| value.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.unwrap_or(value);
quoted
.replace("\\'", "'")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
#[cfg(any(target_os = "linux", test))]
fn parse_gnome(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
for line in text.lines() {
let mut fields = line.splitn(3, ' ');
if let (Some(schema), Some(key), Some(value)) =
(fields.next(), fields.next(), fields.next())
{
values.insert(format!("{schema}.{key}"), value.trim());
}
}
let get = |key: &str| {
values
.get(&format!("org.gnome.system.proxy.{key}"))
.copied()
.unwrap_or("")
};
match unquote(get("mode")).as_str() {
"auto" => bail!(PAC_ERROR),
"manual" => {}
"none" => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, service) in [("http", "http"), ("https", "https"), ("*", "socks")] {
let host = unquote(get(&format!("{service}.host")));
let port = get(&format!("{service}.port")).parse::<u16>().unwrap_or(0);
if !host.is_empty() && port > 0 {
let protocol = if service == "socks" { "socks5" } else { "http" };
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
if get("use-same-proxy") == "true" {
if let Some(http) = settings.proxies.get("http").cloned() {
settings.proxies.insert("https".into(), http);
}
}
settings.bypass = get("ignore-hosts")
.trim_matches(['[', ']'])
.split(',')
.map(unquote)
.filter(|s| !s.is_empty())
.collect();
Ok(Some(settings))
}
#[cfg(any(target_os = "linux", test))]
fn parse_kde(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut proxy_group = false;
for line in text.lines().map(str::trim) {
if line.starts_with('[') {
proxy_group = line == "[Proxy Settings]";
} else if proxy_group {
if let Some((key, value)) = line.split_once('=') {
values.insert(key.trim(), value.trim());
}
}
}
match values.get("ProxyType").copied() {
Some("1") => {}
Some("2" | "3") => bail!(PAC_ERROR),
Some("0") => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, key) in [
("http", "httpProxy"),
("https", "httpsProxy"),
("*", "socksProxy"),
] {
if let Some(value) = values.get(key).filter(|value| !value.is_empty()) {
// KDE also stores endpoints as "http://host port".
let value = value
.rsplit_once(' ')
.map(|(host, port)| format!("{host}:{port}"))
.unwrap_or_else(|| (*value).into());
let value = if key == "socksProxy" {
value.replace("http://", "socks5://")
} else {
value
};
settings.proxies.insert(scheme.into(), value);
}
}
settings.bypass = split_bypass(values.get("NoProxyFor").copied().unwrap_or(""));
settings.reverse_bypass = values
.get("ReversedException")
.is_some_and(|value| *value == "true");
Ok(Some(settings))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn system_settings() -> Result<Option<Settings>> {
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_preserves_credentials_and_prefers_scheme_over_all() {
let vars = HashMap::from([
("HTTPS_PROXY", "http://User:PaSS@localhost:8080"),
("ALL_PROXY", "socks5h://localhost:1080"),
]);
let settings = env_settings(|key| vars.get(key).map(|s| s.to_string())).unwrap();
assert_eq!(
settings.select(&Url::parse("https://example.org").unwrap()),
Some("http://User:PaSS@localhost:8080")
);
parse_proxy(
settings
.select(&Url::parse("https://example.org").unwrap())
.unwrap(),
)
.unwrap();
parse_proxy(
settings
.select(&Url::parse("http://example.org").unwrap())
.unwrap(),
)
.unwrap();
assert!(env_settings(|_| None).is_none());
let direct = env_settings(|key| (key == "https_proxy").then(String::new)).unwrap();
assert_eq!(
direct.select(&Url::parse("https://example.org").unwrap()),
Some("")
);
}
#[test]
fn bypass_matches_domains_ports_wildcards_and_subnets() {
assert!(bypass_matches("*foo", "foofoo", Some(443)));
for (rule, host, port) in [
("*", "example.org", 443),
(".example.org", "SUB.EXAMPLE.ORG", 443),
("*.example.org:443", "sub.example.org", 443),
("192.168.*", "192.168.5.67", 443),
("127.0.0.0/8", "127.1.2.3", 443),
("::1", "[::1]", 443),
("<local>", "frame", 443),
] {
assert!(bypass_matches(rule, host, Some(port)), "{rule} / {host}");
}
assert!(!bypass_matches("example.org", "badexample.org", Some(443)));
assert!(!bypass_matches("example.org:80", "example.org", Some(443)));
assert!(!bypass_matches("192.168.0.0/16", "10.1.2.3", Some(443)));
assert!(parse_proxy("file:///tmp/proxy").is_err());
}
#[test]
fn desktop_settings_select_https_or_socks_and_honor_disabled_mode() {
let gnome = "org.gnome.system.proxy mode 'manual'\norg.gnome.system.proxy.https host '127.0.0.1'\norg.gnome.system.proxy.https port 7890\norg.gnome.system.proxy.socks host '127.0.0.1'\norg.gnome.system.proxy.socks port 7891\norg.gnome.system.proxy ignore-hosts ['localhost', '*.example.org']\n";
let settings = parse_gnome(gnome).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://127.0.0.1:7891")
);
assert!(
parse_gnome(&gnome.replace("'manual'", "'none'"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
assert!(parse_gnome(&gnome.replace("'manual'", "'auto'")).is_err());
let kde = "[Proxy Settings]\nProxyType=1\nhttpsProxy=http://127.0.0.1 7890\nNoProxyFor=localhost,*.example.org\n";
let settings = parse_kde(kde).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert!(
parse_kde(&kde.replace("ProxyType=1", "ProxyType=0"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
}
#[test]
fn macos_and_windows_settings_preserve_protocol_and_bypass_rules() {
let text = "<dictionary> {\n HTTPSEnable : 1\n HTTPSProxy : proxy.example.org\n HTTPSPort : 8080\n SOCKSEnable : 1\n SOCKSProxy : localhost\n SOCKSPort : 1080\n ExceptionsList : <array> {\n 0 : *.local\n }\n __SCOPED__ : <dictionary> {\n en0 : <dictionary> {\n HTTPSEnable : 0\n HTTPSProxy : wrong.example.org\n }\n }\n}";
let settings = parse_macos(text).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://proxy.example.org:8080")
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://localhost:1080")
);
assert!(
settings
.select(&Url::parse("https://frame.local").unwrap())
.is_none()
);
assert!(parse_macos("<dictionary> {\n ProxyAutoConfigEnable : 1\n}").is_err());
let settings =
parse_windows_servers("http=proxy1:8080;https=proxy2:8081;socks=localhost:1080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy2:8081")
);
assert_eq!(
settings.select(&Url::parse("ftp://example.org").unwrap()),
Some("socks5://localhost:1080")
);
let settings = parse_windows_servers("proxy.example.org:8080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy.example.org:8080")
);
}
}
+136 -8
View File
@@ -30,18 +30,22 @@ pub fn validate_repo(repo: &str) -> Result<()> {
);
Ok(())
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
fn agent(url: &url::Url) -> Result<ureq::Agent> {
let mut builder = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.timeout(Duration::from_secs(120))
.redirects(0)
.build()
.redirects(0);
if let Some(proxy) = crate::proxy::for_url(url)? {
builder = builder.proxy(proxy);
}
Ok(builder.build())
}
fn get(url: &str) -> Result<ureq::Response> {
let agent = agent();
let mut url = url::Url::parse(url)?;
for _ in 0..=5 {
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
let response = agent
// Re-evaluate bypass rules for every redirect target.
let response = agent(&url)?
.get(url.as_str())
.set("User-Agent", "Framely-Installer")
.call()?;
@@ -136,6 +140,13 @@ pub fn expected_hash(checksums: &str, name: &str) -> Result<String> {
matched.context("校验文件中没有该压缩包的条目")
}
pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
verify_with_progress(archive, checksums, &mut |_| {})
}
pub fn verify_with_progress(
archive: &Path,
checksums: &Path,
report: &mut dyn FnMut(crate::progress::Progress),
) -> Result<()> {
ensure!(
archive.is_file() && checksums.is_file(),
"请选择压缩包及外部 SHA256SUMS"
@@ -157,17 +168,30 @@ pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
let mut file = File::open(archive)?;
let mut digest = Sha256::new();
let mut block = [0; 256 * 1024];
let mut progress =
crate::progress::Progress::new(crate::progress::Stage::Verify, "校验本地安装包 SHA256");
let total = file.metadata()?.len();
progress.total = Some(total);
report(progress.clone());
let mut last_update = std::time::Instant::now();
loop {
let n = file.read(&mut block)?;
if n == 0 {
break;
}
digest.update(&block[..n]);
progress.completed += n as u64;
if progress.completed < total && last_update.elapsed() >= Duration::from_millis(200) {
report(progress.clone());
last_update = std::time::Instant::now();
}
}
ensure!(
hex::encode(digest.finalize()) == expected,
"SHA256 不匹配,已停止安装"
);
ensure!(progress.completed == total, "校验期间安装包长度发生变化");
report(progress);
Ok(())
}
pub fn download(
@@ -199,6 +223,100 @@ pub fn download(
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn https_requests_use_discovered_desktop_proxy() {
use std::{
io::Write,
net::TcpListener,
os::unix::fs::PermissionsExt,
process::{Command, Stdio},
time::Instant,
};
if std::env::var_os("FRAMELY_PROXY_TEST_CHILD").is_some() {
assert!(get("https://example.invalid/installer-proxy-check").is_err());
return;
}
let work = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let mock = work.path().join("gsettings");
std::fs::write(&mock, format!("#!/bin/sh\nprintf '%s\\n' \"org.gnome.system.proxy mode 'manual'\" \"org.gnome.system.proxy.https host '127.0.0.1'\" \"org.gnome.system.proxy.https port {port}\"\n")).unwrap();
std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut command = Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"release::tests::https_requests_use_discovered_desktop_proxy",
"--nocapture",
])
.env("FRAMELY_PROXY_TEST_CHILD", "1")
.env("XDG_CURRENT_DESKTOP", "GNOME")
.env("XDG_CONFIG_HOME", work.path())
.env("XDG_CONFIG_DIRS", work.path())
.env(
"PATH",
std::env::join_paths(std::iter::once(work.path().to_path_buf()).chain(
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
))
.unwrap(),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for key in [
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
"no_proxy",
"NO_PROXY",
] {
command.env_remove(key);
}
let mut child = command.spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(8);
let mut connection = loop {
if let Ok((connection, _)) = listener.accept() {
break connection;
}
if Instant::now() >= deadline || child.try_wait().unwrap().is_some() {
let _ = child.kill();
let output = child.wait_with_output().unwrap();
panic!(
"Installer did not connect through detected proxy: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(10));
};
connection
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
match connection.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => request.extend_from_slice(&buffer[..n]),
}
}
connection
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.unwrap();
drop(connection);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(request.starts_with(b"CONNECT example.invalid:443 HTTP/1.1\r\n"));
}
#[test]
fn checksum_rejects_ambiguity_and_tampering() {
let dir = tempfile::tempdir().unwrap();
@@ -211,9 +329,19 @@ mod tests {
archive.file_name().unwrap().to_str().unwrap()
);
std::fs::write(&sums, &line).unwrap();
verify(&archive, &sums).unwrap();
let mut progress = Vec::new();
verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).unwrap();
assert_eq!(progress.first().unwrap().completed, 0);
assert_eq!(progress.last().unwrap().fraction(), Some(1.0));
assert!(
progress
.iter()
.all(|value| value.stage == crate::progress::Stage::Verify)
);
std::fs::write(&archive, b"changed").unwrap();
assert!(verify(&archive, &sums).is_err());
progress.clear();
assert!(verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).is_err());
assert!(!progress.iter().any(|value| value.fraction() == Some(1.0)));
assert!(
expected_hash(
&(line.clone() + &line),
+1 -1
View File
@@ -9,7 +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),
ui:{...createVisibilityApi(bridge),close:():Promise<unknown>=>bridge().request('ui.close')},
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})},
+5
View File
@@ -1298,8 +1298,10 @@ mod tests {
.unwrap();
});
let (release_tx, release_rx) = mpsc::channel();
let (started_tx, started_rx) = mpsc::channel();
let slow_worker = std::thread::spawn(move || {
let req = slow.recv().unwrap();
started_tx.send(()).unwrap();
release_rx.recv().unwrap();
let _ = req.respond(Response::empty(500));
});
@@ -1325,6 +1327,9 @@ mod tests {
break;
}
}
// The cancellation check needs an in-flight request, otherwise the mock
// server can wait forever for a request cancelled before connecting.
started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let started = std::time::Instant::now();
token.stop();
assert!(worker.join().unwrap().is_err());
+143 -16
View File
@@ -89,18 +89,54 @@ pub fn create_descriptor(archive: &Path, url: &str, output: &Path, changelog: &s
Ok(())
}
fn file_digest(path: &Path) -> Result<String> {
file_digest_progress(path, |_| Ok(()))
}
fn file_digest_progress(
path: &Path,
mut progress: impl FnMut(u64) -> Result<()>,
) -> Result<String> {
let mut f = fs::File::open(path)?;
let mut hash = Sha256::new();
let mut b = [0u8; 65536];
let mut verified = 0;
progress(verified)?;
loop {
let n = f.read(&mut b)?;
if n == 0 {
break;
}
hash.update(&b[..n]);
verified += n as u64;
progress(verified)?;
}
Ok(hex::encode(hash.finalize()))
}
fn verify_archive(
archive: &Path,
release: &Release,
cancel: &crate::jobs::Cancellation,
mut progress: impl FnMut(Value) -> Result<()>,
) -> Result<()> {
let mut last_update = std::time::Instant::now();
let digest = file_digest_progress(archive, |verified| {
cancel.check()?;
ensure!(verified <= release.size, "发行包超过声明长度");
if verified == 0
|| verified == release.size
|| last_update.elapsed() >= Duration::from_millis(250)
{
progress(
json!({"phase":"verifying","verified":verified,"total":release.size,"version":release.version}),
)?;
last_update = std::time::Instant::now();
}
Ok(())
})?;
cancel.check()?;
ensure!(fs::metadata(archive)?.len() == release.size, "下载未完成");
ensure!(digest == release.sha256, "发行包 SHA256 不匹配");
Ok(())
}
#[derive(Clone, Deserialize)]
struct GitHubAsset {
name: String,
@@ -296,27 +332,66 @@ impl Updater {
.context("请先检查更新")?;
let updater = self.clone();
let state_path = root.join("update-status.json");
self.jobs.task("system.download",move |cancel| {
let _busy=busy;cancel.check()?;
let cache=PathBuf::from("/home/.framely/update-cache");fs::create_dir_all(&cache)?;let metadata=fs::symlink_metadata(&cache)?;ensure!(metadata.is_dir()&&!metadata.file_type().is_symlink()&&metadata.uid()==0,"更新缓存权限无效");fs::set_permissions(&cache,fs::Permissions::from_mode(0o700))?;
let stage=cache.join(format!("{:032x}",rand::random::<u128>()));fs::create_dir(&stage)?;fs::set_permissions(&stage,fs::Permissions::from_mode(0o700))?;
let result=(||->Result<Value>{
write_status(&state_path,json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
let response=crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
if let Some(length)=response.header("Content-Length"){ensure!(length.parse::<u64>()?==release.size,"下载长度与更新清单不一致");}
let mut input=response.into_reader();let mut output=fs::File::create(stage.join("release.tar.gz"))?;let mut buffer=[0u8;262144];let mut received=0u64;let mut update_at=std::time::Instant::now();
loop{cancel.check()?;let n=input.read(&mut buffer)?;if n==0{break;}received+=n as u64;ensure!(received<=release.size,"发行包超过声明长度");output.write_all(&buffer[..n])?;
if update_at.elapsed()>=Duration::from_millis(250){write_status(&state_path,json!({"phase":"downloading","received":received,"total":release.size,"version":release.version}))?;update_at=std::time::Instant::now();}}
output.sync_all()?;ensure!(received==release.size,"下载未完成");ensure!(file_digest(&stage.join("release.tar.gz"))?==release.sha256,"发行包 SHA256 不匹配");
fs::write(stage.join("descriptor.json"),descriptor)?;
cancel.commit(|| { *updater.prepared.lock().unwrap()=Some((stage.clone(),release.clone(),source_url));Ok(()) })?;
write_status(&state_path,json!({"phase":"ready","version":release.version,"received":received,"total":release.size}))?;
self.jobs.task_progress("system.download", move |cancel, progress| {
let _busy = busy;
cancel.check()?;
let cache = PathBuf::from("/home/.framely/update-cache");
fs::create_dir_all(&cache)?;
let metadata = fs::symlink_metadata(&cache)?;
ensure!(metadata.is_dir() && !metadata.file_type().is_symlink() && metadata.uid() == 0, "更新缓存权限无效");
fs::set_permissions(&cache, fs::Permissions::from_mode(0o700))?;
let stage = cache.join(format!("{:032x}", rand::random::<u128>()));
fs::create_dir(&stage)?;
fs::set_permissions(&stage, fs::Permissions::from_mode(0o700))?;
let report = |value: Value| -> Result<()> {
write_status(&state_path, value.clone())?;
progress(value);
Ok(())
};
let result = (|| -> Result<Value> {
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
let response = crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
if let Some(length) = response.header("Content-Length") {
ensure!(length.parse::<u64>()? == release.size, "下载长度与更新清单不一致");
}
let mut input = response.into_reader();
let archive = stage.join("release.tar.gz");
let mut output = fs::File::create(&archive)?;
let mut buffer = [0u8; 262144];
let mut received = 0u64;
let mut update_at = std::time::Instant::now();
loop {
cancel.check()?;
let n = input.read(&mut buffer)?;
if n == 0 { break; }
received += n as u64;
ensure!(received <= release.size, "发行包超过声明长度");
output.write_all(&buffer[..n])?;
if received == release.size || update_at.elapsed() >= Duration::from_millis(250) {
report(json!({"phase":"downloading","received":received,"total":release.size,"version":release.version}))?;
update_at = std::time::Instant::now();
}
}
output.sync_all()?;
drop(output);
ensure!(received == release.size, "下载未完成");
verify_archive(&archive, &release, &cancel, report)?;
fs::write(stage.join("descriptor.json"), descriptor)?;
cancel.commit(|| {
report(json!({"phase":"ready","version":release.version,"received":received,"verified":received,"total":release.size}))?;
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url));
Ok(())
})?;
Ok(json!(release))
})();
if let Err(e)=&result {let _=fs::remove_dir_all(stage);let _=write_status(&state_path,json!({"phase":"failed","error":e.to_string()}));}
if let Err(e) = &result {
let _ = fs::remove_dir_all(stage);
let _ = write_status(&state_path, json!({"phase":"failed","error":e.to_string()}));
}
result
})
}
pub fn apply(
&self,
root: &Path,
@@ -488,6 +563,58 @@ pub fn apply(root: &Path, manager: u32, stage: Option<PathBuf>, rollback: bool)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_verification_reports_bytes_and_rejects_tampering_or_cancellation() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("release.tar.gz");
let payload = vec![42u8; 150_000];
fs::write(&archive, &payload).unwrap();
let release = Release {
schema_version: 1,
version: "0.4.2".into(),
api_version: API_VERSION,
arch: "aarch64".into(),
url: "https://example.org/release.tar.gz".into(),
sha256: hex::encode(Sha256::digest(&payload)),
size: payload.len() as u64,
changelog: String::new(),
};
let mut events = Vec::new();
verify_archive(&archive, &release, &Default::default(), |event| {
events.push(event);
Ok(())
})
.unwrap();
assert_eq!(events.first().unwrap()["verified"], 0);
assert_eq!(events.last().unwrap()["verified"], release.size);
assert!(events
.iter()
.all(|event| event["phase"] == "verifying" && event["total"] == release.size));
let cancel = crate::jobs::Cancellation::default();
assert!(verify_archive(&archive, &release, &cancel, |event| {
if event["verified"] == release.size {
cancel.stop();
}
Ok(())
})
.unwrap_err()
.to_string()
.contains("取消"));
fs::write(&archive, vec![43u8; payload.len()]).unwrap();
assert!(
verify_archive(&archive, &release, &Default::default(), |_| Ok(()))
.unwrap_err()
.to_string()
.contains("SHA256")
);
fs::write(&archive, b"truncated").unwrap();
assert!(
verify_archive(&archive, &release, &Default::default(), |_| Ok(()))
.unwrap_err()
.to_string()
.contains("下载未完成")
);
}
fn source() -> UpdateSource {
UpdateSource {
url: "https://github.com/example/framely/releases/latest/download/framely-release.json"
+55 -22
View File
@@ -1,4 +1,4 @@
// Exercise channel persistence, stale-download invalidation and confirmation in the real UI.
// Exercise confirmation, download-to-install chaining, failures and channels in the real UI.
// Run: bash tools/test-install-review.sh /path/to/cef tests/system_updates_fixture.tsx
import React,{useState} from 'react';
import {createRoot} from 'react-dom/client';
@@ -8,9 +8,9 @@ import '../ui/src/style.css';
const root=createRoot(document.getElementById('root')!);
const wait=(ms=70)=>new Promise(resolve=>setTimeout(resolve,ms));
let status:any={version:'0.4.3-preview.1',build:'0.4.3-preview.1-012345abcdef',database:{updateSource:{url:'https://github.com/example/framely/releases/latest/download/framely-release.json'}},previousRelease:'releases/old'};
let updateStatus:React.Dispatch<React.SetStateAction<any>>;
let kind='',empty=false,hold=false,fail=false;
let checks=0,downloads=0,installs=0;
let updateStatus:React.Dispatch<React.SetStateAction<any>>,remount:()=>void;
let kind='',empty=false,hold=false,verifying=false,failCheck=false,failDownload=false,failApply=false;
let checks=0,downloads=0,attempts=0,installs=0,rollbacks=0;
const release=()=>({version:(status.database.updateChannel==='testing'?'0.4.3-preview.2':'0.4.2')+'-abcdef012345',size:1024*1024,changelog:'Fixture release notes'});
window.fetch=async(_url,init)=>{
const {method,params}=JSON.parse(String(init?.body));
@@ -19,14 +19,22 @@ window.fetch=async(_url,init)=>{
else if(method==='system.check.start'){kind='system.check';checks++;result={job:'fixture'};}
else if(method==='system.download.start'){kind='system.download';downloads++;result={job:'fixture'};}
else if(method==='system.job.status'){
if(fail)result={kind,phase:'failed',error:'Fixture check failed'};
else if(hold)result={kind,phase:'loading'};
else result={kind,phase:'done',result:kind==='system.check'?{release:empty?null:release(),version:status.database.updateChannel==='testing'?'0.4.3-preview.2':'0.4.2'}:{}};
}else if(method==='system.apply'){if(params.version!==release().version||!params.approve)throw Error('incorrect install confirmation');installs++;}
else if(method!=='host.haptic'&&method!=='host.keyboard')throw Error('unexpected API '+method);
if(kind==='system.check'&&failCheck)result={kind,phase:'failed',error:'Fixture check failed'};
else if(kind==='system.download'&&failDownload)result={kind,phase:'failed',error:'Fixture checksum failed'};
else if(hold)result={kind,phase:verifying?'verifying':'downloading',received:1024*1024,verified:512*1024,total:1024*1024};
else result={kind,phase:'done',result:kind==='system.check'?{release:empty?null:release(),version:status.database.updateChannel==='testing'?'0.4.3-preview.2':'0.4.2'}:release()};
}else if(method==='system.apply'){
if(params.version!==release().version||!params.approve)throw Error('incorrect install confirmation');
attempts++;
if(failApply)return new Response(JSON.stringify({error:'Fixture installation launch failed'}),{headers:{'Content-Type':'application/json'}});
installs++;
}else if(method==='system.rollback'){
if(!params.approve)throw Error('incorrect rollback confirmation');
rollbacks++;
}else if(method!=='host.haptic'&&method!=='host.keyboard')throw Error('unexpected API '+method);
return new Response(JSON.stringify({result}),{headers:{'Content-Type':'application/json'}});
};
function Fixture(){const[value,setValue]=useState(status);updateStatus=setValue;return <div className="app"><main className="content"><SystemUpdates status={value} refresh={async()=>{setValue({...status});}}/></main></div>;}
function Fixture(){const[value,setValue]=useState(status),[instance,setInstance]=useState(0);updateStatus=setValue;remount=()=>setInstance(v=>v+1);return <div className="app"><main className="content"><SystemUpdates key={instance} status={value} refresh={async()=>{setValue({...status});}}/></main></div>;}
function button(text:string){const found=[...document.querySelectorAll('button')].find(b=>b.textContent===text);if(!found)throw Error('missing button '+text);return found as HTMLButtonElement;}
async function until(check:()=>boolean){for(let i=0;i<80;i++){if(check())return;await wait();}throw Error('UI wait timed out');}
async function choose(label:string){(document.querySelector('.framely-select button') as HTMLButtonElement).click();await wait();const option=[...document.querySelectorAll('[role=option]')].find(o=>o.textContent===label);if(!option)throw Error('missing channel '+label);(option as HTMLButtonElement).click();await wait();}
@@ -36,31 +44,56 @@ async function choose(label:string){(document.querySelector('.framely-select but
if(!document.querySelector('.framely-select button')?.textContent?.includes('正式版'))throw Error('legacy source did not default to stable');
button('检查更新').click();await until(()=>!!document.querySelector('.release-card'));
if(!document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('channel downgrade not identified');
button('下载并校验').click();await until(()=>!![...document.querySelectorAll('button')].find(b=>b.textContent==='安装已校验版本'));
button('下载并安装').click();await wait();
if(downloads!==0||attempts!==0||!document.querySelector('.confirm-box'))throw Error('update bypassed confirmation');
button('取消').click();await wait();
if(downloads!==0||attempts!==0)throw Error('cancelled update performed work');
failDownload=true;button('下载并安装').click();await wait();button('确认下载并安装').click();
await until(()=>!!document.querySelector('[role=alert]'));
if(attempts!==0||!document.querySelector('[role=alert]')?.textContent?.includes('checksum failed'))throw Error('failed verification did not stop installation');
failDownload=false;failApply=true;button('下载并安装').click();await wait();button('确认下载并安装').click();
await until(()=>!!document.querySelector('[role=alert]')?.textContent?.includes('installation launch failed'));
if(attempts!==1||downloads!==2)throw Error('verified download did not automatically attempt installation');
console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_STABLE');
button('安装已校验版本').click();await wait();button('确认安装').click();await until(()=>attempts===2);await wait();
if(downloads!==2)throw Error('installation retry downloaded again');
await choose('测试版');
if(status.database.updateChannel!=='testing'||document.querySelector('.release-card'))throw Error('channel not saved or stale release retained');
button('检查更新').click();await until(()=>!!document.querySelector('.release-card'));
if(document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('new preview reported as downgrade');
if([...document.querySelectorAll('button')].some(b=>b.textContent==='安装已校验版本'))throw Error('download reused across channels');
hold=true;button('下载并校验').click();await wait();
if(!(document.querySelector('.framely-select button') as HTMLButtonElement).disabled)throw Error('channel mutable during download');
hold=false;await until(()=>!![...document.querySelectorAll('button')].find(b=>b.textContent==='安装已校验版本'));
hold=true;failApply=false;button('下载并安装').click();await wait();button('确认下载并安装').click();await until(()=>downloads===3);await wait();
if(!(document.querySelector('.framely-select button') as HTMLButtonElement).disabled||!button('处理中…').disabled)throw Error('settings mutable during download');
if(attempts!==2)throw Error('installation started before verification');
await until(()=>!!document.querySelector('progress[aria-label="下载进度"]'));
verifying=true;
await until(()=>!!document.querySelector('progress[aria-label="校验进度"]'));
if(!document.body.textContent?.includes('下载完成,正在校验发行包')||document.querySelector('progress[aria-label="下载进度"]'))throw Error('verification phase still shown as downloading');
const verification=document.querySelector('progress[aria-label="校验进度"]') as HTMLProgressElement;
if(verification.value!==512*1024||verification.max!==1024*1024||attempts!==2)throw Error('verification byte progress or installation gate incorrect');
console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_TESTING');
button('安装已校验版本').click();await wait();
if(installs!==0||!document.querySelector('.confirm-box'))throw Error('install bypassed confirmation');
button('取消').click();await wait();
fail=true;button('检查更新').click();await until(()=>!!document.querySelector('[role=alert]'));
hold=false;await until(()=>installs===1);
if(attempts!==3||document.querySelector('.confirm-box'))throw Error('verified download required a second confirmation');
remount();await wait();
failCheck=true;button('检查更新').click();await until(()=>!!document.querySelector('[role=alert]'));
if(document.querySelector('.release-card'))throw Error('failed recheck retained stale install');
fail=false;empty=true;await choose('正式版');button('检查更新').click();await until(()=>!!document.querySelector('[role=status]'));
failCheck=false;empty=true;await choose('正式版');button('检查更新').click();await until(()=>!!document.querySelector('[role=status]'));
if(!document.body.textContent?.includes('暂无可用发行'))throw Error('empty stable channel not shown');
empty=false;configureLanguage('en-US',[]);updateStatus({...status});await wait();
if(!document.querySelector('.framely-select button')?.textContent?.includes('Stable'))throw Error('English channel untranslated');
await choose('Testing');button('Check for updates').click();await until(()=>!!document.querySelector('.release-card'));
button('Download and verify').click();await until(()=>!![...document.querySelectorAll('button')].find(b=>b.textContent==='Install verified release'));
button('Install verified release').click();await wait();button('Confirm installation').click();await until(()=>installs===1);
if(checks!==5||downloads!==3)throw Error(`unexpected workflow counts ${checks}/${downloads}`);
button('Download and install').click();await wait();
if(!document.querySelector('.confirm-box')?.textContent?.includes('installed automatically'))throw Error('English update flow untranslated');
hold=true;button('Confirm download and installation').click();
await until(()=>!!document.querySelector('progress[aria-label="Verification progress"]'));
if(!document.body.textContent?.includes('Download complete. Verifying')||!document.body.textContent?.includes('Verified 0.5 / 1.0 MiB'))throw Error('English verification progress untranslated');
hold=false;await until(()=>installs===2);
if(checks!==5||downloads!==4||attempts!==4)throw Error(`unexpected workflow counts ${checks}/${downloads}/${attempts}`);
console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_ENGLISH');
remount();await wait();button('Roll back to previous release').click();await wait();
if(rollbacks!==0)throw Error('rollback bypassed confirmation');
button('Confirm rollback').click();await until(()=>rollbacks===1);
if(downloads!==4)throw Error('rollback downloaded a release');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
};
+6 -4
View File
@@ -39,11 +39,13 @@ with __import__('tempfile').TemporaryDirectory() as temp:
shutil.copy2(binary, stage / exe)
if not platform.startswith('windows'):
(stage / exe).chmod(0o755)
shutil.copy2(root / 'installer/install-desktop-entry.sh', stage / 'install-desktop-entry.sh')
desktop_script = (root / 'installer/install-desktop-entry.sh').read_text()
marker = 'icon = None # Embedded by tools/package-installer.py.'
if desktop_script.count(marker) != 1:
raise ValueError('Desktop registration script is missing its icon marker')
icon = (root / 'assets/branding/framely-app-icon.svg').read_bytes()
(stage / 'install-desktop-entry.sh').write_text(desktop_script.replace(marker, f'icon = {icon!r}'))
(stage / 'install-desktop-entry.sh').chmod(0o755)
(stage / 'assets/branding').mkdir(parents=True)
for asset in ('framely-logo.svg', 'framely-logo-light.svg', 'framely-app-icon.svg', 'framely-app-icon.png', 'framely.ico'):
shutil.copy2(root / 'assets/branding' / asset, stage / 'assets/branding' / asset)
shutil.copy2(root / 'installer/README.md', stage / 'README.md')
shutil.copy2(root / 'installer/README.zh-CN.md', stage / 'README.zh-CN.md')
shutil.copy2(root / 'LICENSE', stage / 'LICENSE')
+10
View File
@@ -83,6 +83,8 @@
"管理 {0}": "Manage {0}",
"未找到插件": "No plugins found",
"没有收藏的插件": "No favorite plugins",
"没有已启用的插件": "No enabled plugins",
"打开插件管理以启用插件。": "Open the manager to enable plugins.",
"还没有安装插件": "No plugins installed yet",
"换一个关键词再试。": "Try another search term.",
"点击插件旁的星标,将它加入常用。": "Click a plugin's star to add it to your favorites.",
@@ -292,6 +294,11 @@
"回滚上一版本": "Roll back to previous release",
"正在读取更新清单…": "Reading release information…",
"正在下载并校验发行包…": "Downloading and verifying the release package…",
"正在下载发行包…": "Downloading the release package…",
"下载完成,正在校验发行包…": "Download complete. Verifying the release package…",
"下载进度": "Download progress",
"校验进度": "Verification progress",
"已校验 {0} / {1} MiB": "Verified {0} / {1} MiB",
"上次更新失败:": "Last update failed: ",
"诊断日志:/var/lib/framely/logs/update.log": "Diagnostic log: /var/lib/framely/logs/update.log",
"上次更新操作已完成。": "The last update operation completed.",
@@ -300,6 +307,9 @@
"发布者未提供更新说明。": "The publisher has not provided release notes.",
"安装已校验版本": "Install verified release",
"下载并校验": "Download and verify",
"下载并安装": "Download and install",
"确认下载并安装": "Confirm download and installation",
"确认后将下载并校验发行包,校验通过后自动安装。": "After confirmation, the release package will be downloaded, verified, and installed automatically.",
"正在切换版本。Framely 入口会在服务恢复后重新出现。": "Switching releases. The Framely entry will return when the service recovers.",
"安装 Framely {0}?": "Install Framely {0}?",
"回滚 Framely?": "Roll back Framely?",
+10
View File
@@ -83,6 +83,8 @@
"管理 {0}": "管理 {0}",
"未找到插件": "未找到插件",
"没有收藏的插件": "没有收藏的插件",
"没有已启用的插件": "没有已启用的插件",
"打开插件管理以启用插件。": "打开插件管理以启用插件。",
"还没有安装插件": "还没有安装插件",
"换一个关键词再试。": "换一个关键词再试。",
"点击插件旁的星标,将它加入常用。": "点击插件旁的星标,将它加入常用。",
@@ -292,6 +294,11 @@
"回滚上一版本": "回滚上一版本",
"正在读取更新清单…": "正在读取更新清单…",
"正在下载并校验发行包…": "正在下载并校验发行包…",
"正在下载发行包…": "正在下载发行包…",
"下载完成,正在校验发行包…": "下载完成,正在校验发行包…",
"下载进度": "下载进度",
"校验进度": "校验进度",
"已校验 {0} / {1} MiB": "已校验 {0} / {1} MiB",
"上次更新失败:": "上次更新失败:",
"诊断日志:/var/lib/framely/logs/update.log": "诊断日志:/var/lib/framely/logs/update.log",
"上次更新操作已完成。": "上次更新操作已完成。",
@@ -300,6 +307,9 @@
"发布者未提供更新说明。": "发布者未提供更新说明。",
"安装已校验版本": "安装已校验版本",
"下载并校验": "下载并校验",
"下载并安装": "下载并安装",
"确认下载并安装": "确认下载并安装",
"确认后将下载并校验发行包,校验通过后自动安装。": "确认后将下载并校验发行包,校验通过后自动安装。",
"正在切换版本。Framely 入口会在服务恢复后重新出现。": "正在切换版本。Framely 入口会在服务恢复后重新出现。",
"安装 Framely {0}?": "安装 Framely {0}?",
"回滚 Framely?": "回滚 Framely?",
+11 -3
View File
@@ -32,6 +32,12 @@ 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.close'){
const view=viewKey();
if(view==='menu')result=await api('host.menu.close');
else if(view==='framely.manager')result=await api('host.manager.close');
else result=await api('window.close',{plugin,window:entry});
}
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()});
@@ -89,6 +95,8 @@ function App(){
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(location.hash==='#catalog'){setPage(null);setCatalogTarget(null);setTab('catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
async function loadCatalog(){setCatalogTarget(null);setTab('catalog');}
const plugins=Object.values(status.database.plugins).sort((a,b)=>a.order-b.order);
const listedPlugins=plugins.filter(p=>(tab==='installed'||p.favorite)&&(managerMode||tab!=='installed'||p.enabled)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase()));
const onlyDisabledInstalled=!managerMode&&tab==='installed'&&plugins.length>0&&listedPlugins.length===0&&!query;
const selected=page?status.database.plugins[page]:null;
const installedVersion=install?.info.installedVersion;
const installDifference=installedVersion?compareVersions(install.info.manifest.version,installedVersion):null;
@@ -100,17 +108,17 @@ function App(){
if(!status.agreement.accepted)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="required" initialDeclined={agreementDeclined} agreement={status.agreement} refresh={refresh}/>;
if(reviewAgreement)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="review" agreement={status.agreement} refresh={refresh} onClose={()=>setReviewAgreement(false)} onRevoked={closed=>{setAgreementDeclined(!closed);setReviewAgreement(false);setPage(null);setManage(null);}}/>;
if(parts[1]==='notifications')return <Notifications status={status} error={error} action={action}/>;
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app"><header className="shell-header"><strong>{p?.manifest.name??t("插件窗口")}</strong><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></header>{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<p className="empty">{t("插件已停用或正在启动。")}</p>}{error&&<p className="error">{error}</p>}</div>;}
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}><Icon name={icon as any}/><span>{label}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
{(tab==='favorites'||tab==='installed')&&<>
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{plugins.filter(p=>(tab==='installed'||p.favorite)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase())).map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
<button className="plugin-main" onClick={()=>setPage(p.manifest.id)}><PluginImage src={installedIcon(p)} name={p.manifest.name} size={48}/><span className="plugin-copy"><b>{p.manifest.name}</b>{managerMode&&<small>{p.manifest.description}</small>}<span className="plugin-meta"><i className={`status-dot ${p.error?'failed':!p.enabled?'off':''}`}/>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{!managerMode&&<span className="version">{p.manifest.version}</span>}</span></span></button>
{managerMode&&<><span className="row-state">{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}</span><span className="row-version">{p.manifest.version}</span><button className={`plugin-switch ${p.enabled?'on':''}`} role="switch" aria-checked={p.enabled} aria-label={t("启用 {0}", {"0": p.manifest.name})} disabled={busy} onClick={()=>void action(()=>changePlugin(p,'enable'))}><span/></button></>}<button className={`icon-button favorite ${p.favorite?'selected':''}`} aria-label={t("收藏 {0}", {"0": p.manifest.name})} onClick={()=>void action(()=>api('plugin.favorite',{plugin:p.manifest.id,favorite:!p.favorite}))}><Icon name="star" size={23} filled={p.favorite}/></button>
{managerMode&&<button className="icon-button" aria-label={t("管理 {0}", {"0": p.manifest.name})} onClick={()=>setManage(p)}><Icon name="more"/></button>}
</article>)}</div>
{plugins.filter(p=>(tab==='installed'||p.favorite)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase())).length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
</>}
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><SystemUpdates status={status} refresh={refresh}/><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
+1 -1
View File
@@ -9,7 +9,7 @@ export type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:b
export type Presentation={authorUrl?:string|null;documentationUrl?:string|null;homepage?:string|null;icon?:string|null;details?:string;tags?:string[];screenshots?:string[];changelog?:string;dependencies?:Record<string,string|{version:string;source:string}>;optionalDependencies?:Record<string,string|{version:string;source:string}>;conflicts?:Record<string,string>;exclusiveResources?:string[]};
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;url:string;sha256:string;runAs?:RuntimeUser|null};
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};error?:string;cached?:boolean;fetchedAt?:number};
export type Job={id:string;kind:string;phase:string;received:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
export type Job={id:string;kind:string;phase:string;received:number;verified?:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
const started=await api<{job:string}>(method,params);
const deadline=Date.now()+35*60*1000;
+26 -5
View File
@@ -11,7 +11,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
const[error,setError]=useState(''),[busy,setBusy]=useState(false),[job,setJob]=useState<Job|null>(null),[release,setRelease]=useState<any>(null),[ready,setReady]=useState(false),[checked,setChecked]=useState(false),[confirm,setConfirm]=useState<'apply'|'rollback'|null>(null),[installing,setInstalling]=useState(false);
function reset(){setRelease(null);setReady(false);setChecked(false);setConfirm(null);}
useEffect(reset,[source?.url,channel]);
const progress=status.systemUpdate;
const progress=job?.kind==='system.download'?job:status.systemUpdate;
const older=release&&compareVersions(release.targetVersion??release.version,status.version)===-1;
async function switchChannel(value:string){
if(!source||busy||installing||value===channel)return;
@@ -26,6 +26,25 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
catch(e){setError(String(e));}
finally{setBusy(false);setJob(null);await refresh();}
}
async function confirmUpdate(){
if(!confirm||busy||installing)return;
const action=confirm,version=release?.version;
setConfirm(null);setBusy(true);setError('');
let started=false;
try{
if(action==='apply'&&!ready){
await runJob('system.download.start',{},setJob,'system.job.status');
setReady(true);setJob(null);
}
setInstalling(true);
await api(action==='apply'?'system.apply':'system.rollback',{approve:true,version});
started=true;
}catch(e){setError(String(e));setInstalling(false);}
finally{
setBusy(false);setJob(null);
if(!started)await refresh().catch(()=>{});
}
}
return <section className="settings-card system-updates">
<h2>{t('Framely 更新')}</h2>
<p>{t('当前版本:')}<b>{status.build??status.version}</b></p>
@@ -38,8 +57,9 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
</div>
{checked&&!release&&<p className="banner" role="status">{t('所选渠道暂无可用发行版本。')}</p>}
{job&&<p className="banner">{job.kind==='system.check'?t('正在读取更新清单…'):t('正在下载并校验发行包…')}</p>}
{progress?.phase==='downloading'&&<div className="download-progress"><progress max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
{job&&<p className="banner" role="status">{job.kind==='system.check'?t('正在读取更新清单…'):job.phase==='verifying'?t('下载完成,正在校验发行包…'):t('正在下载发行包…')}</p>}
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}
{release&&<div className="release-card">
@@ -47,16 +67,17 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
<p>{release.version===(status.build??status.version)?t('当前已是此发行版本'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
{older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
<p className="prose">{release.changelog||t('发布者未提供更新说明。')}</p>
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>ready?setConfirm('apply'):void run('system.download.start',{},()=>setReady(true))}>{ready?t('安装已校验版本'):t('下载并校验')}</button>}
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
</div>}
{error&&<p className="error" role="alert">{error}</p>}
{installing&&<p className="banner">{t('正在切换版本。Framely 入口会在服务恢复后重新出现。')}</p>}
{confirm&&<div className="confirm-box">
<h3>{confirm==='apply'?t('安装 Framely {0}?',{'0':release.version}):t('回滚 Framely?')}</h3>
{confirm==='apply'&&!ready&&<p>{t('确认后将下载并校验发行包,校验通过后自动安装。')}</p>}
<p>{t('插件和数据会保留,Framely 界面会暂时关闭。SteamVR 不会被重启。')}</p>
{confirm==='apply'&&older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
<div className="row">
<button className="primary" disabled={installing||busy} onClick={()=>void(async()=>{setInstalling(true);setError('');try{await api(confirm==='apply'?'system.apply':'system.rollback',{approve:true,version:release?.version});setConfirm(null);}catch(e){setError(String(e));setInstalling(false);}})()}>{confirm==='apply'?t('确认安装'):t('确认回滚')}</button>
<button className="primary" disabled={installing||busy} onClick={()=>void confirmUpdate()}>{confirm==='apply'?(ready?t('确认安装'):t('确认下载并安装')):t('确认回滚')}</button>
<button disabled={installing} onClick={()=>setConfirm(null)}>{t('取消')}</button>
</div>
</div>}