Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c59fab881a | ||
|
|
4108d4e5be | ||
|
|
1c6e36e63b | ||
|
|
799433e041 | ||
|
|
e02fde75f4 | ||
|
|
c4f672cba7 | ||
|
|
e54689bff6 | ||
|
|
fb1f67f39f | ||
|
|
f5089e9bea |
No files matched your search
@@ -63,8 +63,8 @@ jobs:
|
||||
run: |
|
||||
bash tools/package-release.sh target/cef
|
||||
mkdir -p release-assets
|
||||
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' -print -quit)
|
||||
cp "$archive" release-assets/
|
||||
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
|
||||
cp release/*.tar.gz release/framely-cef.json release-assets/
|
||||
cp tools/bootstrap.py release-assets/bootstrap.py
|
||||
cp install.sh release-assets/install.sh
|
||||
"$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG"
|
||||
@@ -93,8 +93,8 @@ jobs:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cd release-assets
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nInstall on Frame over SSH using the repository install.sh.\n\nSHA256SUMS verifies downloaded archives; each Framely archive also includes an internal SHA256SUMS.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
prerelease=false
|
||||
latest=true
|
||||
if [[ "$RELEASE_TAG" == *-* ]]; then
|
||||
@@ -102,5 +102,5 @@ jobs:
|
||||
latest=false
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json
|
||||
gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease"
|
||||
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.7"
|
||||
version = "0.4.3-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.7"
|
||||
version = "0.4.3-preview.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
The target device is a Linux ARM64 Steam Frame. Download the `Framely Installer` for your computer from [Releases](https://github.com/SteamFramelyHomebrew/framely/releases), connect to the device, and install Framely. Alternatively, run this in the Frame SSH terminal to install the latest stable release:
|
||||
@@ -23,7 +25,7 @@ For a Preview release, specify an actual published tag:
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
|
||||
```
|
||||
|
||||
Runtime packages include CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager.
|
||||
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager.
|
||||
|
||||
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
|
||||
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
目标为 Linux ARM64 Steam Frame。电脑端从 [Releases](https://github.com/SteamFramelyHomebrew/framely/releases) 下载对应平台的 `Framely Installer`,连接设备后安装本体。也可在 Frame 的 SSH 终端安装最新正式版:
|
||||
@@ -23,7 +25,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
|
||||
```
|
||||
|
||||
运行包包含 CEF,设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。
|
||||
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。
|
||||
|
||||
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Framely branding assets / 品牌资源
|
||||
|
||||
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` is the application tile on sky blue. The wordmark uses vector paths and needs no font installation.
|
||||
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` uses a charcoal tile with a sky blue sprite. Square marks center the window body, allowing the left tail to extend outward; the native Dock sprite is 10% smaller. The wordmark uses vector paths and needs no font installation.
|
||||
|
||||
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为浅蓝底应用图标。字标已转成路径,无需安装字体。
|
||||
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为黑底蓝色应用图标。正方形图标以窗口主体居中,尾巴向左伸出;原生插件入口中的图形缩小 10%。字标已转成路径,无需安装字体。
|
||||
|
||||
After editing the SVGs, regenerate PNGs, the Windows ICO and native Dock artwork from the repository root, then commit the generated files together:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" role="img" aria-labelledby="title desc" color="#17191c">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Charcoal pixel window sprite on a sky blue application tile.</desc>
|
||||
<desc id="desc">Sky blue pixel window sprite on a charcoal application tile, centered on its window body.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="32" height="32" rx="3" fill="#93c5ed" shape-rendering="geometricPrecision"/>
|
||||
<path fill="#17191c" fill-rule="evenodd" transform="translate(3.4 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
<rect width="32" height="32" rx="3" fill="#17191c" shape-rendering="geometricPrecision"/>
|
||||
<path fill="#93c5ed" fill-rule="evenodd" transform="translate(1.6 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 658 B After Width: | Height: | Size: 687 B |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384" viewBox="0 0 24 24" role="img" aria-labelledby="title desc" color="#93c5ed">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Sky blue pixel window sprite for dark backgrounds.</desc>
|
||||
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 555 B |
@@ -2,6 +2,6 @@
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 557 B After Width: | Height: | Size: 555 B |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 189 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 480 B |
@@ -8,7 +8,7 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
|
||||
|
||||
1. [Install, update, repair and uninstall](user-guide/installation.md): desktop installer, SSH, Preview builds, local packages and rollback.
|
||||
2. [Basic usage](user-guide/basic-usage.md): Dock, manager, enabling plugins, favorites and safe mode.
|
||||
3. [Install and manage plugins](user-guide/plugins.md): store, sources, subscriptions, local packages and updates.
|
||||
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
|
||||
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
|
||||
|
||||
## Plugin development and publishing
|
||||
|
||||
@@ -39,7 +39,7 @@ Leave source `files` empty. `framely pack` scans payload and supplies hashes. At
|
||||
| `screenshots` | Default empty; up to eight payload PNG/JPEG paths |
|
||||
| `authorUrl` / `documentationUrl` / `homepage` | Optional HTTP/HTTPS links, no embedded credentials |
|
||||
| `downloadUrl` | Optional fixed-version HTTPS package URL; GitHub database source may omit it for repository/version/ID derivation, but community packages need the resolved URL; see publishing |
|
||||
| `publish` | Optional external store image configuration |
|
||||
| `publish` | Optional external plugin library image configuration |
|
||||
| `backend` / `lifecycle` / `ui` | Optional backend/hooks/pages |
|
||||
| `dependencies` / `optionalDependencies` / `conflicts` | Default empty maps |
|
||||
| `exclusiveResources` | Default empty array; matching names prevent simultaneous enabling |
|
||||
@@ -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.
|
||||
@@ -77,7 +77,7 @@ Automatic URLs always require a Release digest, even when a hash is supplied, an
|
||||
|
||||
### Icons and window compatibility
|
||||
|
||||
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [store metadata](../plugin-development.md).
|
||||
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [plugin library metadata](../plugin-development.md).
|
||||
|
||||
The community database supports window `entry`, `title`, `dockIcon`, `localWeb`, `width`, `height`, `widthMeters`. Pixel width is an integer 640–2560, height an integer 360–1440, and physical width follows the core's float32 validation for 0.4–4.0 meters. Omitted dimensions normalize to 1600×900 and 3 meters, matching pack output. Custom dimensions participate in source/package comparison; undeclared changes are rejected. The template's default standalone window passed actual packaging and database validation. Registration still requires whole-package hash, ownership and other field checks.
|
||||
|
||||
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
|
||||
cargo run --locked -- catalog --name 'My source' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
|
||||
```
|
||||
|
||||
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a store URL.
|
||||
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a plugin library URL.
|
||||
|
||||
**Upload the entire generated directory, not only `catalog.json`:**
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
|
||||
| `framely.windows.open(key)` | Declared key; asynchronous result | Open a declared window |
|
||||
| `framely.windows.close(key)` | Key; asynchronous result | Close a window without disabling the backend |
|
||||
| `framely.notifications.send(notification)` | Notification; asynchronous result | Send or replace by ID |
|
||||
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Withdraw notification |
|
||||
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Close the popup and remove the inbox copy |
|
||||
| `framely.notifications.dismiss(id)` | Notification ID; asynchronous result | Close the popup, retaining the inbox copy |
|
||||
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | Query declared dependency state |
|
||||
| `framely.language.get()` | `Promise<{preference, language}>` | Language preference and effective language |
|
||||
| `framely.onEvent(callback)` | Unsubscribe function | Receive forwarded events |
|
||||
@@ -43,14 +44,38 @@ try {
|
||||
|
||||
```tsx
|
||||
await framely.notifications.send({
|
||||
id: 'task', title: 'Done', body: 'File processed', durationMs: 8000,
|
||||
id: 'task', title: 'Done', body: 'File processed', inbox: true, durationMs: 8000,
|
||||
actions: [{id: 'open', label: 'Open', icon: '↗'}],
|
||||
});
|
||||
```
|
||||
|
||||
`id`, `title`, `body` are required. Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with `id`, `label`, `icon`. `durationMs` is 1000–60000; omission defaults to 8000 ms. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 concurrent notifications. Updating an existing ID does not occupy another slot.
|
||||
`id`, `title`, `body` are required. `inbox` defaults to `false`: with popups enabled, only notifications explicitly sent with `inbox: true` appear in the quick panel's Notifications tab. Inbox messages survive popup expiry, closing the popup, plugin disable/restart, and Framely restart. They are removed by an action's policy, explicit removal, or plugin uninstall. Restored inbox messages do not replay as popups.
|
||||
|
||||
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name. UI-only plugins can listen for the event. Reuse an ID to update; remove it to withdraw.
|
||||
| Field | Default | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `inbox` | `false` | Save the message in the inbox; user popup settings may also require saving it |
|
||||
| `durationMs` | `8000` | Popup countdown in milliseconds; `1000-60000`, or `0` to stay open until dismissed |
|
||||
| `actions[].closeOnClick` | `true` | Close the popup after this action succeeds |
|
||||
| `actions[].removeFromInboxOnClick` | `true` | Remove the inbox copy after this action succeeds |
|
||||
|
||||
User notification settings take priority over sender preferences. If the global popup switch or this plugin's popup switch is off, Framely suppresses the popup and saves the message in the inbox even when `inbox` is false. Reading the inbox clears the launcher unread badge but leaves messages and actions available. Plugins cannot override popup or badge settings.
|
||||
|
||||
The two action policies are independent. For a status button that keeps the message, set both to `false`:
|
||||
|
||||
```tsx
|
||||
await framely.notifications.send({
|
||||
id: 'capture', title: 'Recording', body: 'Recording is in progress.',
|
||||
inbox: true, durationMs: 0,
|
||||
actions: [{id: 'status', label: 'View status',
|
||||
closeOnClick: false, removeFromInboxOnClick: false}],
|
||||
});
|
||||
```
|
||||
|
||||
Popups display the remaining auto-close countdown; inbox entries show their received time. Closing a popup keeps its saved inbox copy. `remove(id)` withdraws both copies; `dismiss(id)` closes only the popup. Reuse the same ID to replace a message and restart its countdown. Failed actions leave the message available for retry. Buttons use their text `label`; the legacy `icon` string remains optional for compatibility.
|
||||
|
||||
Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with required `id` and `label`. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and optional action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 simultaneous popups and 256 saved inbox messages. When full, sending a new message fails; remove older messages first. Replacing an existing ID does not occupy another slot.
|
||||
|
||||
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name after a successful backend response. UI-only plugins can listen for the event. A disabled plugin's saved messages remain readable and removable, but their action buttons are disabled until it is enabled again.
|
||||
|
||||
## React hooks
|
||||
|
||||
@@ -111,3 +136,13 @@ 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)`.
|
||||
|
||||
`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.
|
||||
|
After Width: | Height: | Size: 183 KiB |
@@ -79,9 +79,9 @@ Manifest supports payload PNG icon ≤1 MiB/1024×1024, details/tags/changelog a
|
||||
}
|
||||
```
|
||||
|
||||
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the store default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
|
||||
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the plugin library default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
|
||||
|
||||
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. Stores filter keywords/tags/origins/states, retain session caches on failures and install reviewed bytes without downloading again after confirmation.
|
||||
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. The plugin library filters keywords/tags/origins/states, retains session caches on failures and installs reviewed bytes without downloading again after confirmation.
|
||||
|
||||
## Scaffold and preview
|
||||
|
||||
|
||||
@@ -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.12`), `.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,18 +47,18 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.4.2-preview.7
|
||||
git push origin v0.4.2-preview.7
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
# 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.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.
|
||||
|
||||
Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only.
|
||||
|
||||
Device attachments include the runtime archive, `framely-release.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
|
||||
External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS.
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
Click Framely in the SteamVR Dashboard Dock. First use requires accepting the terms and privacy statement; plugins do not automatically start before consent.
|
||||
|
||||
- **Favorites**: open favorite plugin quick pages.
|
||||
- **Installed**: browse local plugins and their pages.
|
||||
- **Settings**: language, safe mode and the plugin manager entry.
|
||||
- **Installed**: browse enabled local plugins and their pages.
|
||||
- **Notifications**: view saved notifications and update reminders, and toggle floating notifications.
|
||||
- **Settings**: choose an installed language, toggle safe mode, open the plugin manager or **Check for updates**. The update entry opens the manager’s About/update page. Install language files and download the language template from the manager’s Settings page.
|
||||
|
||||
The manager opens a separate large window with navigation for plugins, store, sources, settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
|
||||
The manager opens a separate large window with navigation for plugins, plugin library, sources, settings, notification settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
|
||||
|
||||
## Plugins and windows
|
||||
|
||||
@@ -26,4 +27,16 @@ 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.
|
||||
|
||||
Automatic update checks are enabled by default. Framely checks about 30 seconds after starting (once terms are accepted), then every 6 hours. In About, turn **Check for updates automatically** off or change the interval. Checks use the selected Stable/Testing channel and configured proxy, continue with the panel closed, and never download or install automatically. A newer version saves a notification to the inbox and shows a popup when notification settings allow it. **Ignore** dismisses that version's reminder; **Open updates** opens About's update page. The same version is not repeatedly announced, including after restart. Switching the source or channel clears the old reminder.
|
||||
|
||||
Floating notifications follow the headset near the upper-right of the view. Their input mask matches the visible card; short button clicks tolerate small tracking movements.
|
||||
|
||||
Notification popups display an auto-close countdown when the sender sets a duration. Closing a popup keeps any saved inbox copy. Remove a saved message from Notifications with its trash button. Action buttons close and remove messages by default; senders can configure different behavior. Ordinary notifications are not saved unless the sender opts in, except when popups are disabled.
|
||||
|
||||
The **Allow notifications** switch at the top of Notifications controls all floating popups. When off, new notifications are saved in the inbox even if the sender did not request it; active popups are also moved to the inbox. Turning popups back on does not replay old messages.
|
||||
|
||||
In the manager, **Notification settings** provides global **Floating notifications** and **Launcher badge** switches, plus independent **Badge** and **Popup** switches for Framely itself (including update reminders) and every installed plugin, including disabled plugins. Global switches take priority and preserve each app’s preferences. The Framely Dock launcher badge counts unread inbox messages from sources allowed to show a badge, displays `99+` for larger counts, and disappears when you view the inbox. Hidden headset panels do not mark messages as read. Reading keeps messages and their actions available; it does not remove them. Settings and read state survive restart.
|
||||
|
||||
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.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Before installation
|
||||
|
||||
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The runtime includes CEF, so users do not need Node.js or Rust on the device.
|
||||
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The complete offline installation package includes CEF, so users do not need Node.js or Rust on the device.
|
||||
|
||||
Connect your computer and Frame to the same network. Enable developer mode and SSH on Frame, and prepare the `steamos` account password. Verify the SSH fingerprint. Installation uses sudo to create an account, systemd services and release directories. It does not automatically disable SteamOS read-only protection; it stops if required directories cannot be written.
|
||||
|
||||
@@ -40,14 +40,26 @@ Replace the example with an existing device release tag. An explicit tag selects
|
||||
|
||||
## Option 3: local archive
|
||||
|
||||
Download `framely-<version-and-build>-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
|
||||
Download `framely-<version-and-build>-offline-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
|
||||
|
||||
```bash
|
||||
python3 bootstrap.py install --archive "./framely-<version-and-build>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
|
||||
python3 bootstrap.py install --archive "./framely-<version-and-build>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
|
||||
```
|
||||
|
||||
Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`.
|
||||
|
||||
## Release packages and CEF
|
||||
|
||||
Each device release provides three archives:
|
||||
|
||||
- `framely-<version-and-build>-offline-linux-arm64.tar.gz`: complete offline package including CEF; always used for first installation.
|
||||
- `framely-<version-and-build>-linux-arm64.tar.gz`: core package without CEF; selected automatically for updates by the desktop installer, SSH engine and in-app updater.
|
||||
- `framely-cef-<runtime-id>-linux-arm64.tar.gz`: separate CEF runtime, available as an online download. `framely-cef.json` provides its URL, size and SHA256.
|
||||
|
||||
CEF is stored under `/home/.framely/cef/<runtime-id>`. Updates reuse the exact verified runtime; matching CEF files from older full installations migrate automatically. If the required runtime is missing or a release changes CEF, the core installation downloads the separate matching CEF archive over HTTPS and verifies its size, SHA256 and internal checksums. A complete offline package supplies the runtime without downloading it.
|
||||
|
||||
Each release refers to its own CEF version, so rollback retains the previous runtime. Repair verifies it; uninstall removes the CEF cache with the programs. Local first installation must use the offline package. Local updates can use the core package when its required CEF is already installed; otherwise runtime recovery needs network access.
|
||||
|
||||
## Verify installation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,7 +24,7 @@ Change passwords from authenticated settings or the device's native manager. If
|
||||
|
||||
## Available operations
|
||||
|
||||
Manage sources and subscriptions, browse the store, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
|
||||
Manage sources and subscriptions, browse the plugin library, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
|
||||
|
||||
VR windows, controller vibration and SteamVR keyboard need headset validation; a browser cannot replace those interactions. Some external links open in the device's default browser.
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
[Documentation](../README.md) · [Network panel](network-panel.md)
|
||||
|
||||
## Install from the store
|
||||
## Install from the plugin library
|
||||
|
||||
1. Open **Store** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
|
||||
1. Open **Plugin library** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
|
||||
2. Filter by keywords, tags, source or installation state.
|
||||
3. Open details and review author, description, changes, runtime user and dependencies. The recommended version is shown first; history loads on demand.
|
||||
4. Install or inspect the selected version. Read the confirmation after download, verification and dependency resolution.
|
||||
@@ -49,7 +49,7 @@ Uninstall through the management dialog. Hooks handle external cleanup and saved
|
||||
|
||||
| Symptom | Action |
|
||||
| --- | --- |
|
||||
| Empty store | Check enabled sources, connectivity and filters |
|
||||
| Empty plugin library | Check enabled sources, connectivity and filters |
|
||||
| Download/hash failure | Check network and URL; download again without bypassing verification |
|
||||
| Missing history | The source may lack its history file; use latest or a trusted local old package |
|
||||
| Cannot enable | Read dependency/conflict errors and install required plugins |
|
||||
|
||||
@@ -10,7 +10,7 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
|
||||
|
||||
1. [安装、更新、修复与卸载](user-guide/installation.md):桌面安装器、SSH、Preview、本地包、回滚。
|
||||
2. [基本使用](user-guide/basic-usage.md):Dock 入口、管理窗口、启停、收藏、安全模式。
|
||||
3. [安装与管理插件](user-guide/plugins.md):商店、来源、订阅、本地包和更新。
|
||||
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
|
||||
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
|
||||
|
||||
## 插件开发与发布
|
||||
|
||||
@@ -40,7 +40,7 @@ npm run dev
|
||||
|
||||
在浏览器打开 `http://127.0.0.1:5173`;修改页面后自动重载。预览只模拟窗口和通知,不启动 Python 后端,不拥有 Frame 权限;读取/保存按钮在预览中报出后端不可用是正常情况。不要据此声称设备功能已验收。
|
||||
|
||||
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与商店资料补充见[插件开发参考](../plugin-development.md)。
|
||||
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与插件库资料补充见[插件开发参考](../plugin-development.md)。
|
||||
|
||||
## 4. 实现后端与生命周期
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| `screenshots` | 默认空数组,最多 8 个 payload 内 PNG/JPEG 路径 |
|
||||
| `authorUrl` / `documentationUrl` / `homepage` | 可选 HTTP/HTTPS 地址,禁止 URL 内用户名密码 |
|
||||
| `downloadUrl` | 可选,固定版本 HTTPS 包下载地址;GitHub 数据库源码可省略,按仓库/版本/ID 拼接,社区登记的包内需补全;见发布指南 |
|
||||
| `publish` | 可选外部商店图片配置,见下文 |
|
||||
| `publish` | 可选外部插件库图片配置,见下文 |
|
||||
| `backend` / `lifecycle` / `ui` | 可选,配置后端、钩子和页面 |
|
||||
| `dependencies` / `optionalDependencies` / `conflicts` | 默认空对象,见关系参考 |
|
||||
| `exclusiveResources` | 默认空数组,禁止同时启用占用同名资源的插件 |
|
||||
@@ -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。后端必须及时回复;通知无需页面挂载。该字段需要支持可见性事件的新版宿主。
|
||||
@@ -77,7 +77,7 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify "dist/$P
|
||||
|
||||
### 图标与窗口兼容
|
||||
|
||||
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[商店资料参考](../plugin-development.md#商店资料与图标)
|
||||
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[插件库资料参考](../plugin-development.md#插件库资料与图标)
|
||||
|
||||
社区数据库支持窗口 `entry`、`title`、`dockIcon`、`localWeb`、`width`、`height`、`widthMeters`。像素宽度为整数 640–2560,高度为整数 360–1440,物理宽度按本体 float32 规则校验 0.4–4.0 米。源码省略尺寸时,数据库会按 1600×900、3 米补全,与 pack 输出一致;自定义尺寸也会参与源码和包内清单比较,未声明的变化会被拒绝。模板包含的默认独立窗口已通过实际打包和数据库校验;登记仍需完成整包哈希、归属及其他字段检查。
|
||||
|
||||
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
|
||||
cargo run --locked -- catalog --name '我的插件源' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
|
||||
```
|
||||
|
||||
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成商店 URL。
|
||||
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成插件库 URL。
|
||||
|
||||
**上传整个生成目录,不能只上传 `catalog.json`:**
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
|
||||
| `framely.windows.open(key)` | 窗口 key;异步结果 | 打开已在 Manifest 声明的窗口 |
|
||||
| `framely.windows.close(key)` | 窗口 key;异步结果 | 关闭窗口,不停用后端 |
|
||||
| `framely.notifications.send(notification)` | 通知对象;异步结果 | 发送或按 ID 更新通知 |
|
||||
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 撤回通知 |
|
||||
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 关闭弹窗并移除收件箱副本 |
|
||||
| `framely.notifications.dismiss(id)` | 通知 ID;异步结果 | 仅关闭弹窗,保留收件箱副本 |
|
||||
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | 查询本插件声明的依赖状态 |
|
||||
| `framely.language.get()` | `Promise<{preference, language}>` | 获取语言偏好和当前生效语言 |
|
||||
| `framely.onEvent(callback)` | 返回取消订阅函数 | 接收宿主转发的事件 |
|
||||
@@ -43,14 +44,38 @@ try {
|
||||
|
||||
```tsx
|
||||
await framely.notifications.send({
|
||||
id: 'task', title: '完成', body: '文件已处理', durationMs: 8000,
|
||||
id: 'task', title: '完成', body: '文件已处理', inbox: true, durationMs: 8000,
|
||||
actions: [{id: 'open', label: '打开', icon: '↗'}],
|
||||
});
|
||||
```
|
||||
|
||||
`id`、`title`、`body` 必填。`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项有 `id`、`label`、`icon`。`durationMs` 范围 1000–60000,省略时默认 8000 ms。`id` 和按钮 `id` 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、`icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 条同时存在的通知;同 ID 更新不额外占队列位置。
|
||||
`id`、`title`、`body` 必填。`inbox` 默认 `false`:浮窗启用时,只有明确设置 `inbox: true` 的通知才进入快捷面板的“通知”收件箱。收件箱消息在弹窗到期、关闭弹窗、插件停用或重启、本体重启后都会保留;按钮策略、主动移除或卸载插件会移除消息。重启后恢复的收件箱消息不会重新弹出。
|
||||
|
||||
按钮动作调用后端 `notification.action`,参数 `{id, action}`;同时转发 `notification.action` 页面事件。无后端插件可只监听页面事件。同一 ID 用于更新,调用 remove 撤回。
|
||||
| 字段 | 默认值 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `inbox` | `false` | 保存到收件箱;用户浮窗设置也可要求保存 |
|
||||
| `durationMs` | `8000` | 弹窗自动关闭倒计时,单位毫秒;支持 `1000-60000`,`0` 表示持续显示直到主动关闭 |
|
||||
| `actions[].closeOnClick` | `true` | 此按钮操作成功后关闭弹窗 |
|
||||
| `actions[].removeFromInboxOnClick` | `true` | 此按钮操作成功后移除收件箱副本 |
|
||||
|
||||
用户的通知设置优先于发送方偏好。全局浮窗或此插件的浮窗开关关闭时,Framely 不展示弹窗,并将消息转存到收件箱,即使 `inbox` 是 `false`。查看收件箱会清除启动图标的未读角标,但保留消息及按钮。插件不能覆盖用户的浮窗和角标设置。
|
||||
|
||||
两个按钮策略独立配置。例如查看状态后仍要保留消息,可将两个字段均设为 `false`:
|
||||
|
||||
```tsx
|
||||
await framely.notifications.send({
|
||||
id: 'capture', title: '录制中', body: '当前录制正在进行。',
|
||||
inbox: true, durationMs: 0,
|
||||
actions: [{id: 'status', label: '查看状态',
|
||||
closeOnClick: false, removeFromInboxOnClick: false}],
|
||||
});
|
||||
```
|
||||
|
||||
弹窗显示剩余关闭时间,收件箱显示接收时间。关闭弹窗保留其收件箱副本。`framely.notifications.remove(id)` 同时撤回弹窗和收件箱消息;`framely.notifications.dismiss(id)` 仅关闭弹窗。重复发送同 ID 会替换消息并重新开始倒计时。按钮操作失败时保留消息供重试。按钮使用 `label` 文字显示,旧版 `icon` 字符串保持可选兼容。
|
||||
|
||||
`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项必填 `id`、`label`。通知和按钮 ID 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、可选 `icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 个同时显示的弹窗及 256 条收件箱消息。达到上限后,新消息发送会报错,请先移除旧消息;替换同 ID 不额外占用位置。
|
||||
|
||||
按钮调用后端 `notification.action`,参数为 `{id, action}`;后端调用成功后转发同名页面事件。纯前端插件可以监听该事件。插件停用时,其收件箱消息仍可查看和移除,操作按钮需重新启用插件后使用。
|
||||
|
||||
## React hooks
|
||||
|
||||
@@ -111,3 +136,20 @@ 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()
|
||||
```
|
||||
|
||||
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
|
||||
|
||||
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
|
||||
@@ -87,11 +87,11 @@ Framely 主页面和插件 iframe 自动为可交互控件提供轻微的手柄
|
||||
|
||||
振动由 OpenVR 的当前激光控制器执行(12 ms、120 Hz、幅度 0.15),宿主只接受当前可见且被激光命中的视图,限制相邻脉冲间隔为 80 ms。插件发出的反馈消息由 iframe 宿主绑定到实际视图,不能指定另一个窗口或手柄。
|
||||
|
||||
## 商店资料与图标
|
||||
## 插件库资料与图标
|
||||
|
||||
Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`details`、`tags`、`screenshots`(最多 8 个载荷内 PNG/JPEG 路径)、`changelog`。这些文件会和页面一样加入文件哈希清单。图标用于插件列表和插件独立窗口的原生 Dock 缩略图,省略时使用默认拼图图标。
|
||||
|
||||
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到商店目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;商店列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
|
||||
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到插件库目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;插件库列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -113,11 +113,11 @@ Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`deta
|
||||
}
|
||||
```
|
||||
|
||||
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,商店采用默认图标。包内资源仍用于已安装插件。
|
||||
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,插件库采用默认图标。包内资源仍用于已安装插件。
|
||||
|
||||
插件数据库只登记 submodule 固定提交,读取该提交的清单,补全默认下载地址,先取得 Release digest 或源码 `downloadSha256` 中的预期整包哈希,再下载并比对 SHA256、清单声明和载荷。发布分支提供各渠道目录及历史版本 JSON,不保存插件包或图片,不需要 entries 登记。数据库 CI 自动展示版本和运行用户变化,发布时禁止同 ID、版本更换 SHA256;不执行作者代码,不要求逐插件人工源码审核,也不验证源码与二进制可复现关系。
|
||||
|
||||
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。商店支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
|
||||
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。插件库支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
|
||||
|
||||
后端默认按失败自动恢复,包括已经启动的按需插件;可用 `backend.restart: "never"` 关闭。默认第三次连续失败后停用,运行稳定一分钟后重置计数,详见[生命周期](plugin-lifecycle.md)。
|
||||
|
||||
@@ -145,8 +145,8 @@ SDK 提供 `Section`、`Button`、`Toggle`、`Slider`、`TextField`、`Select`
|
||||
|
||||
插件只使用 `tags` 描述用途,不再使用分类;为兼容已发布的包,旧 `category` 字段读取时忽略,新的清单及目录不再导出它。插件数据库每次生成目录时同时生成 `tags.json`,格式为 `{ "schemaVersion": 1, "tags": ["工具", "温控"] }`,聚合当前目录全部插件的标签、精确去重并排序。stable/testing 分别生成自己的标签文件,不生成跨渠道并集;程序加载多个源后在本地聚合去重。标签随插件更新自动增删,无须手工维护。
|
||||
|
||||
上传的插件包、商店下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
|
||||
上传的插件包、插件库下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
|
||||
|
||||
本机网页窗口在 `ui.windows.<key>` 设置 `localWeb: true`,宿主通过后端 `window.get` 获取窗口 URL;这是窗口加载方式,不是权限声明。URL 使用 `http://localhost:<端口>/framely-window/<key>`。普通窗口无需设置此项。
|
||||
|
||||
商店主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。商店进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
|
||||
插件库主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。插件库进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
|
||||
@@ -38,12 +38,18 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
|
||||
|
||||
该机制不能恢复已被系统清除的 `/home` 数据,也不保证操作系统更新后无需人工操作。回滚到不支持修复模式的旧发行后,需要使用支持该模式的新发行包恢复安装。正式发布前应进行一次真实 SteamOS 更新,验证设置、插件数据、账号身份、服务恢复和 SteamVR 宿主兼容性。
|
||||
|
||||
## 本体包与独立 CEF
|
||||
|
||||
每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。
|
||||
|
||||
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。
|
||||
|
||||
## GitHub Actions 发布
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.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.12`);`.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 应用版本使用安装器版本。
|
||||
|
||||
@@ -53,19 +59,19 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.7
|
||||
git push origin v0.4.2-preview.7
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
|
||||
# 独立发布安装器,不触发 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.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。
|
||||
|
||||
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
|
||||
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为设备运行包;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
|
||||
外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
默认每天自动刷新,也可关闭自动刷新或立即刷新。自动检查在 Framely 会话运行期间进行,启动后检查到期项;支持 ETag/Last-Modified 和最多五次 HTTPS 重定向。离线/失败保留已持久化的上次成功源列表,记录错误,按一分钟起的指数退避重试,最多一天。
|
||||
|
||||
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线商店不保证有目录缓存。
|
||||
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线插件库不保证有目录缓存。
|
||||
|
||||
目录 URL 规范化后去重,一个源可由多个订阅和手动添加共同引用。删除订阅或移除订阅中的源只移除对应引用;没有任何引用才从源列表移除。已安装插件、来源记录和数据保留,不静默切换来源。
|
||||
|
||||
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、商店资料和更新提示,不自动安装、更新或启用插件。
|
||||
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、插件库资料和更新提示,不自动安装、更新或启用插件。
|
||||
@@ -9,10 +9,11 @@
|
||||
在 SteamVR Dashboard 点击 Framely Dock 图标打开快捷菜单。首次访问需阅读并同意协议及隐私声明;未同意时插件不会自动启动。
|
||||
|
||||
- **常用**:打开收藏的插件快捷页。
|
||||
- **已安装**:查看本机插件,进入插件页面。
|
||||
- **设置**:语言、安全模式和“管理插件”入口。
|
||||
- **已安装**:查看已启用的本机插件,进入插件页面。
|
||||
- **通知**:查看收件箱消息和更新提醒,并控制悬浮通知。
|
||||
- **设置**:选择已安装的语言、切换安全模式,或打开“管理插件”和“检查更新”。“检查更新”打开管理窗口的关于/更新页面;安装语言文件和下载语言模板在管理窗口的设置中进行。
|
||||
|
||||
“管理插件”打开独立大窗口,左侧导航进入插件、商店、插件源、设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
|
||||
“管理插件”打开独立大窗口,左侧导航进入插件、插件库、插件源、设置、通知设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
|
||||
|
||||
## 插件启停与窗口
|
||||
|
||||
@@ -26,4 +27,20 @@
|
||||
|
||||
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“关于”查看本体版本、检查更新和复查协议。撤回协议会停用全部插件,再次同意后需要手动启用。
|
||||
|
||||
在“关于”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
|
||||
|
||||
日志排查命令见[安装指南](installation.md#修复与日志)。界面能打开不代表所有插件都正常;先逐个启用,确认运行身份与来源可信。
|
||||
|
||||
## 通知收件箱与自动更新检查
|
||||
|
||||
快捷面板的“通知”标签展示明确设置为进入收件箱的消息,浮窗启用时,普通通知默认不保存;浮窗关闭时会转存到收件箱。浮动通知显示自动关闭倒计时,关闭浮动通知会保留已保存的收件箱副本;收件箱里的删除按钮会移除消息。通知按钮默认在操作成功后关闭弹窗并移除收件箱副本,发送方可以分别修改这两个行为。
|
||||
|
||||
“通知”顶部的“允许通知”开关控制全局浮窗。关闭后,新通知即使没有声明进入收件箱也会保存到这里,正在显示的浮窗也会转存。重新开启不会重新弹出旧消息。
|
||||
|
||||
悬浮通知跟随头显,显示在视野内右上方。激光命中区域与实际卡片对齐,短按按钮可容忍小幅追踪抖动。
|
||||
|
||||
管理窗口的“通知设置”提供全局“悬浮通知”和“启动图标角标”开关,并为 Framely 本体(包括更新提醒)及所有已安装插件(包括已停用插件)提供独立的“角标”和“悬浮”开关。全局开关优先,关闭后仍保留各应用偏好。Framely Dock 启动图标的角标统计允许显示角标来源的未读收件箱消息,超过 99 显示 `99+`;实际打开通知标签后消除角标,隐藏的头显面板不会自动标记已读。查看消息不会删除消息,也不会关闭其操作按钮。设置和已读状态均会保存并在重启后恢复。
|
||||
|
||||
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“关于”的更新页关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
|
||||
|
||||
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 安装前
|
||||
|
||||
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。运行包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
|
||||
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。完整离线安装包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
|
||||
|
||||
电脑与 Frame 连接同一网络,在 Frame 开启开发者模式并启用 SSH,准备 Steam 用户 `steamos` 的登录密码。安装会使用 sudo 创建账号、systemd 服务和发行目录;请核对 SSH 指纹。安装器不会自动解除 SteamOS 只读保护;实际所需目录不可写时会停止。
|
||||
|
||||
@@ -40,14 +40,26 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
## 方式三:本地发行包
|
||||
|
||||
下载设备运行包 `framely-<版本及构建号>-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
|
||||
下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
|
||||
|
||||
```bash
|
||||
python3 bootstrap.py install --archive "./framely-<版本及构建号>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
|
||||
python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
|
||||
```
|
||||
|
||||
尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。
|
||||
|
||||
## 发行包与 CEF
|
||||
|
||||
每个本体 Release 提供三种压缩包:
|
||||
|
||||
- `framely-<版本及构建号>-offline-linux-arm64.tar.gz`:包含 CEF 的完整离线包,首次安装固定使用此包。
|
||||
- `framely-<版本及构建号>-linux-arm64.tar.gz`:不含 CEF 的本体包;桌面安装器、SSH 引擎及本体更新器在更新时自动选择此包。
|
||||
- `framely-cef-<运行库标识>-linux-arm64.tar.gz`:单独的 CEF 运行库,提供在线下载;`framely-cef.json` 包含下载 URL、大小和 SHA256。
|
||||
|
||||
CEF 保存在 `/home/.framely/cef/<运行库标识>`。更新复用经过校验的对应运行库;旧完整安装中相同的 CEF 文件会自动迁移。所需运行库缺失或发行更换 CEF 时,本体安装流程通过 HTTPS 下载单独的匹配 CEF 包,并校验大小、SHA256 和包内哈希。完整离线包直接提供运行库,无需下载 CEF。
|
||||
|
||||
各发行引用自己的 CEF 版本,回滚时仍保留之前的运行库;修复会校验运行库,卸载则与程序一起移除 CEF 缓存。本地首次安装必须选择完整离线包;本地更新可使用不含 CEF 的包,前提是对应运行库已安装,否则恢复运行库需要联网。
|
||||
|
||||
## 验证安装
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,7 +24,7 @@ http://设备IP:15915
|
||||
|
||||
## 可以做什么
|
||||
|
||||
登录后可以管理来源和订阅、浏览商店、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
|
||||
登录后可以管理来源和订阅、浏览插件库、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
|
||||
|
||||
VR 窗口、手柄振动和 SteamVR 虚拟键盘需要头显端验收;浏览器无法替代这些设备交互。部分外部链接由设备默认浏览器打开。
|
||||
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
|
||||
[文档首页](../README.md) · [网络面板](network-panel.md)
|
||||
|
||||
## 从商店安装
|
||||
## 从插件库安装
|
||||
|
||||
1. 打开管理窗口“商店”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
|
||||
1. 打开管理窗口“插件库”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
|
||||
2. 可按关键词、标签、来源或安装状态筛选。
|
||||
3. 打开详情,查看作者、介绍、更新记录、运行用户和依赖。默认显示推荐版本,历史版本会按需加载。
|
||||
4. 点击安装或检查所选版本。下载、校验和依赖解析完成后,阅读确认页。
|
||||
5. 检查包内实际运行用户以及依赖带来的 root 权限,确认后安装。安装后在插件列表启用或打开。
|
||||
|
||||
商店提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
|
||||
插件库提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
|
||||
|
||||
## 添加源与订阅
|
||||
|
||||
单个目录地址:在“插件源”添加名称和 `https://.../catalog.json`,测试连接后保存。
|
||||
|
||||
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和商店资料,不自动安装或更新插件。
|
||||
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和插件库资料,不自动安装或更新插件。
|
||||
|
||||
停用或删除来源不会自动卸载已安装插件;订阅管理的 URL 变更需另行确认。格式与刷新行为见[源订阅参考](../source-subscriptions.md)。
|
||||
|
||||
@@ -41,7 +41,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
|
||||
|
||||
## 更新、选择旧版与卸载
|
||||
|
||||
商店提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
|
||||
插件库提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
|
||||
|
||||
从插件管理弹窗卸载。卸载钩子负责清理该插件的外部资源,保存的数据保留;不要直接删除系统中的插件目录。停用只停止使用,不卸载程序和数据。
|
||||
|
||||
@@ -49,7 +49,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| 商店空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
|
||||
| 插件库空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
|
||||
| 下载或校验失败 | 检查网络和包地址,重新下载;不要跳过哈希校验 |
|
||||
| 历史版本不可用 | 来源可能没有历史文件,可先安装最新版或导入可信旧包 |
|
||||
| 插件无法启用 | 查看依赖、冲突和错误说明,确认其他必要插件已安装 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React,{useState} from 'react';
|
||||
import {registerPlugin,framely,Section,Button} from '@framely/sdk';
|
||||
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
|
||||
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',inbox:true,title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
|
||||
registerPlugin({QuickPage:Page,WindowPage:Page});
|
||||
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.5"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.5"
|
||||
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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,6 +32,10 @@ After an operation, maintenance shows a separate success/failure card and a reco
|
||||
|
||||
Scanning, downloads, and SSH work run on background threads, with progress and logs in the UI. The network panel button assumes port 15915; use the actual address if the device port has changed.
|
||||
|
||||
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
|
||||
|
||||
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`.
|
||||
@@ -56,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.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.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:
|
||||
|
||||
@@ -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 默认账号配置。
|
||||
|
||||
@@ -30,6 +32,10 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
|
||||
扫描、下载和 SSH 工作在后台线程运行,界面展示进度和日志。默认网络管理面板按钮使用端口 15915;设备修改端口后应使用实际地址。
|
||||
|
||||
在线首次安装固定下载包含 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` 锁定。
|
||||
@@ -56,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.5`)触发 `.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`。可单独运行与界面相同的发现逻辑进行诊断:
|
||||
|
||||
@@ -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 ('"', '`', '$'):
|
||||
|
||||
@@ -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,8 @@
|
||||
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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -436,7 +463,7 @@ impl Installer {
|
||||
} else {
|
||||
self.selected
|
||||
.and_then(|i| self.releases.get(i))
|
||||
.is_some_and(|r| release::package(r).is_ok())
|
||||
.is_some_and(|r| release::package_for(r, &self.chosen_action).is_ok())
|
||||
}
|
||||
}
|
||||
fn action_available(&self, action: &str) -> bool {
|
||||
@@ -453,7 +480,7 @@ impl Installer {
|
||||
} else {
|
||||
self.selected
|
||||
.and_then(|i| self.releases.get(i))
|
||||
.and_then(|r| release::package(r).ok())
|
||||
.and_then(|r| release::package_for(r, "update").ok())
|
||||
.map(|(package, _)| package.name.as_str())
|
||||
};
|
||||
self.chosen_action == "update"
|
||||
@@ -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 {
|
||||
(
|
||||
@@ -556,18 +592,30 @@ impl Installer {
|
||||
} else {
|
||||
let selected =
|
||||
selected.ok_or_else(|| anyhow::anyhow!("请选择 Release 版本"))?;
|
||||
let (package, checksum) = release::package(&selected)?;
|
||||
let (package, checksum) = release::package_for(&selected, &action)?;
|
||||
let archive = folder.path().join(&package.name);
|
||||
let sums = folder.path().join("SHA256SUMS");
|
||||
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) {
|
||||
release::verify(archive, sums)?;
|
||||
release::verify_with_progress(archive, sums, &mut progress)?;
|
||||
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
|
||||
}
|
||||
remote::operate(
|
||||
@@ -580,6 +628,7 @@ impl Installer {
|
||||
repo: &repo,
|
||||
},
|
||||
&mut log,
|
||||
&mut progress,
|
||||
)
|
||||
})()
|
||||
.map_err(|e| format!("{e:#}"));
|
||||
@@ -593,6 +642,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 +689,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 +734,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 +755,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 +763,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 +780,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 +794,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 +856,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 +874,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ impl Installation {
|
||||
}
|
||||
}
|
||||
pub fn same_package(&self, name: &str) -> bool {
|
||||
self.current_version
|
||||
.as_ref()
|
||||
.is_some_and(|version| name == format!("framely-{version}-linux-arm64.tar.gz"))
|
||||
self.current_version.as_ref().is_some_and(|version| {
|
||||
name == format!("framely-{version}-linux-arm64.tar.gz")
|
||||
|| name == format!("framely-{version}-offline-linux-arm64.tar.gz")
|
||||
})
|
||||
}
|
||||
}
|
||||
pub fn requires_package(action: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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, "配置账号、安装文件与系统服务"),
|
||||
"cef-download" => (4, "设备下载 CEF 运行库"),
|
||||
"activate" => (5, "启动服务并检查安装状态"),
|
||||
_ => continue,
|
||||
};
|
||||
if step
|
||||
.total
|
||||
.is_some_and(|total| total == 0 || step.completed.unwrap_or(0) > total)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut value = Progress::new(
|
||||
if step.step == "cef-download" {
|
||||
Stage::Download
|
||||
} else {
|
||||
Stage::Install
|
||||
},
|
||||
detail,
|
||||
);
|
||||
if step.step != "cef-download" {
|
||||
value.step = Some((index, 5));
|
||||
}
|
||||
value.completed = step.completed.unwrap_or(0);
|
||||
value.total = step.total;
|
||||
report(value);
|
||||
}
|
||||
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);
|
||||
decoder.push(
|
||||
"FRAMELY_PROGRESS {\"step\":\"cef-download\",\"completed\":50,\"total\":100}\n",
|
||||
&mut |p| reports.push(p),
|
||||
);
|
||||
assert_eq!(reports[2].stage, Stage::Download);
|
||||
assert_eq!(reports[2].fraction(), Some(0.5));
|
||||
assert_eq!(reports[2].step, None);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -35,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()?;
|
||||
@@ -73,7 +72,11 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
|
||||
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
|
||||
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
|
||||
let finished = items.len() < 100;
|
||||
all.extend(items.into_iter().filter(|r| !r.draft && package(r).is_ok()));
|
||||
all.extend(
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|r| !r.draft && (package(r).is_ok() || package_for(r, "update").is_ok())),
|
||||
);
|
||||
if finished {
|
||||
return Ok(all);
|
||||
}
|
||||
@@ -81,10 +84,26 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
|
||||
Ok(all)
|
||||
}
|
||||
pub fn package(release: &Release) -> Result<(&Asset, &Asset)> {
|
||||
package_for(release, "install")
|
||||
}
|
||||
pub fn package_for<'a>(release: &'a Release, action: &str) -> Result<(&'a Asset, &'a Asset)> {
|
||||
let offline = action == "install"
|
||||
&& release
|
||||
.assets
|
||||
.iter()
|
||||
.any(|a| safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz"));
|
||||
ensure!(
|
||||
action != "install"
|
||||
|| offline
|
||||
|| !release.assets.iter().any(|a| a.name == "framely-cef.json"),
|
||||
"首次安装需要包含 CEF 的完整离线包"
|
||||
);
|
||||
let packages: Vec<_> = release
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|a| safe_archive_name(&a.name))
|
||||
.filter(|a| {
|
||||
safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz") == offline
|
||||
})
|
||||
.collect();
|
||||
ensure!(packages.len() == 1, "该版本缺少唯一的 Framely ARM64 发行包");
|
||||
let checksum = release
|
||||
@@ -121,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"
|
||||
@@ -142,53 +168,155 @@ 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(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(())
|
||||
}
|
||||
#[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();
|
||||
@@ -201,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),
|
||||
@@ -244,4 +382,46 @@ mod tests {
|
||||
release.assets[0].name = "framely-0.4.2-build-linux-arm64.tar.gz".into();
|
||||
assert!(package(&release).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_install_uses_offline_and_updates_use_core_with_three_archives() {
|
||||
let mut release = Release {
|
||||
tag_name: "v0.4.2".into(),
|
||||
name: None,
|
||||
published_at: None,
|
||||
body: None,
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [
|
||||
"framely-0.4.2-build-linux-arm64.tar.gz",
|
||||
"framely-0.4.2-build-offline-linux-arm64.tar.gz",
|
||||
"framely-cef-154-build-linux-arm64.tar.gz",
|
||||
"framely-cef.json",
|
||||
"SHA256SUMS",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| Asset {
|
||||
name: name.into(),
|
||||
browser_download_url: format!("https://example.org/{name}"),
|
||||
size: 1,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
assert!(
|
||||
package_for(&release, "install")
|
||||
.unwrap()
|
||||
.0
|
||||
.name
|
||||
.ends_with("-offline-linux-arm64.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
package_for(&release, "update").unwrap().0.name,
|
||||
"framely-0.4.2-build-linux-arm64.tar.gz"
|
||||
);
|
||||
release
|
||||
.assets
|
||||
.retain(|a| !a.name.ends_with("-offline-linux-arm64.tar.gz"));
|
||||
assert!(package_for(&release, "install").is_err());
|
||||
assert!(package_for(&release, "update").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,34 +12,34 @@ inline constexpr unsigned char mask[48 * 48] = {
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,255,255,0,0,0,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,255,255,0,0,0,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
#include "overlay_placement.h"
|
||||
#include "render_resolution.h"
|
||||
#include "notification_geometry.h"
|
||||
#include "notification_badge.h"
|
||||
#include "keyboard_state.h"
|
||||
#include "keyboard_input.h"
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
#include <arpa/inet.h>
|
||||
@@ -39,7 +42,9 @@ static vr::IVRSystem* vr_system=nullptr;
|
||||
static Display* xdisplay=nullptr;
|
||||
static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
|
||||
static float notification_curve_radius=1.3f;
|
||||
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
|
||||
static MenuButtonInput menu_button;
|
||||
static cef_frame_t* keyboard_frame=nullptr;
|
||||
@@ -47,6 +52,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 {
|
||||
@@ -56,8 +63,8 @@ template<class T> struct Handler {
|
||||
};
|
||||
struct View {
|
||||
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
OverlayPlacement placement;std::chrono::steady_clock::time_point haptic_at{};
|
||||
bool active_seen=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
OverlayPlacement placement;NotificationClick notification_click;std::chrono::steady_clock::time_point haptic_at{};
|
||||
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};std::vector<uint8_t> pixels;
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<View>> views;
|
||||
@@ -79,8 +86,8 @@ static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
int gx=std::min(47,int((x+.5f)*48/128)),gy=std::min(47,int((y+.5f)*48/128));
|
||||
float mark=framely_brand::mask[gy*48+gx]/255.f;
|
||||
int i=(y*128+x)*4;
|
||||
const int normal[3]={23,25,28},highlight[3]={61,68,80},muted[3]={147,197,237},bright[3]={240,241,243};
|
||||
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];float fg=(hover||active)?bright[c]:muted[c];p[i+c]=uint8_t(bg+(fg-bg)*mark);}
|
||||
const int normal[3]={23,25,28},highlight[3]={32,35,40},blue[3]={147,197,237};
|
||||
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];p[i+c]=uint8_t(bg+(blue[c]-bg)*mark);}
|
||||
// 32px-wide, 6px-high tab indicator is clipped to its upper 3px by the BarSurface.
|
||||
if(active){float qx=std::abs(x-63.5f)-20.8f,qy=std::abs(y-129.6f);float line=std::clamp(5.3f-std::hypot(std::max(qx,0.f),qy),0.f,1.f);const int blue[3]={26,159,255};for(int c=0;c<3;c++)p[i+c]=uint8_t(p[i+c]+(blue[c]-p[i+c])*line);}
|
||||
p[i+3]=uint8_t(255*coverage);
|
||||
@@ -88,7 +95,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
|
||||
@@ -96,7 +103,7 @@ static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)thr
|
||||
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
|
||||
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
|
||||
static std::vector<uint8_t> dock_icon(bool hover,bool active){
|
||||
auto source=icon(hover,active);std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
auto source=icon(hover,active);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
|
||||
return canvas;
|
||||
}
|
||||
@@ -115,8 +122,30 @@ static void laser_cursor(vr::VROverlayHandle_t overlay,const vr::VREvent_t& even
|
||||
if(event.eventType==vr::VREvent_MouseMove){vr::HmdVector2_t position{{event.data.mouse.x,event.data.mouse.y}};overlays->SetOverlayCursorPositionOverride(overlay,&position);}
|
||||
else if(event.eventType==vr::VREvent_FocusLeave)overlays->ClearOverlayCursorPositionOverride(overlay);
|
||||
}
|
||||
static void show(View& v,bool shown){if(v.closing)return;if(v.key=="notifications")overlays->SetOverlayInputMethod(v.overlay,shown?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None);if(v.key=="menu"&&shown!=v.shown)overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,shown&&!(keyboard_open&&keyboard_view=="menu"));bool actual=overlays->IsOverlayVisible(v.overlay);if(shown!=actual){auto e=shown?overlays->ShowOverlay(v.overlay):overlays->HideOverlay(v.overlay);if(e){std::cerr<<"Overlay visibility failed "<<v.key<<" "<<e<<"\n";return;}}if(shown!=v.shown){if(!shown)overlays->ClearOverlayCursorPositionOverride(v.overlay);if(v.key!="notifications")v.host->was_hidden(v.host,!shown);v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);}}
|
||||
static void close_keyboard(bool hide=true){if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu"&&views.count("menu")&&!views.at("menu")->closing)overlays->SetOverlayFlag(views.at("menu")->overlay,vr::VROverlayFlags_WantsModalBehavior,views.at("menu")->shown);if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
|
||||
static void update_menu_modal(){
|
||||
if(!views.count("menu"))return;
|
||||
auto& menu=*views.at("menu");
|
||||
bool toast=views.count("notifications")&&views.at("notifications")->shown&&!views.at("notifications")->closing;
|
||||
// A modal quick menu must not monopolize the laser while a toast needs input.
|
||||
overlays->SetOverlayFlag(menu.overlay,vr::VROverlayFlags_WantsModalBehavior,menu.shown&&!toast&&!(keyboard_open&&keyboard_view=="menu"));
|
||||
}
|
||||
static void show(View& v,bool shown){
|
||||
if(v.closing)return;
|
||||
bool actual=overlays->IsOverlayVisible(v.overlay);
|
||||
if(v.key=="notifications"&&(shown!=v.shown||shown!=actual)){
|
||||
auto e=overlays->SetOverlayInputMethod(v.overlay,shown?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None);
|
||||
if(!e)e=overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,shown);
|
||||
if(e){std::cerr<<"Notification input state failed "<<e<<"\n";shown=false;overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
|
||||
}
|
||||
if(shown!=actual){auto e=shown?overlays->ShowOverlay(v.overlay):overlays->HideOverlay(v.overlay);if(e){std::cerr<<"Overlay visibility failed "<<v.key<<" "<<e<<"\n";return;}}
|
||||
if(shown!=v.shown){
|
||||
if(!shown){overlays->ClearOverlayCursorPositionOverride(v.overlay);if(v.key=="notifications"&&v.notification_click.active){cef_mouse_event_t outside{};outside.x=outside.y=-1;v.host->send_mouse_click_event(v.host,&outside,MBT_LEFT,1,1);v.notification_click.clear();}}
|
||||
if(v.key!="notifications")v.host->was_hidden(v.host,!shown);
|
||||
v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);
|
||||
if(v.key=="menu"||v.key=="notifications")update_menu_modal();
|
||||
}
|
||||
}
|
||||
static void close_keyboard(bool hide=true){if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
|
||||
static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_overlay=0){
|
||||
// The runtime toolbar opens the keyboard without a host.keyboard request.
|
||||
if(e.eventType==vr::VREvent_KeyboardOpened_Global){
|
||||
@@ -141,11 +170,23 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
|
||||
}
|
||||
static void close_view(View& v){if(v.closing)return;if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
|
||||
static void anchor_notification(View& v){
|
||||
// Let the compositor follow the HMD's render pose; a captured standing-space
|
||||
// transform leaves the toast behind when the user turns or moves their head.
|
||||
vr::HmdMatrix34_t head{{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};
|
||||
NotificationPlacement toast{};
|
||||
if(!notification_placement(head,notification_curve_radius,toast))return;
|
||||
auto error=overlays->SetOverlayWidthInMeters(v.overlay,toast.width);
|
||||
if(!error)error=overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&toast.transform);
|
||||
if(error){std::cerr<<"Notification head attachment failed "<<error<<"\n";return;}
|
||||
v.placement.transform=toast.transform;v.placement.width=toast.width;v.placement.valid=true;
|
||||
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
|
||||
}
|
||||
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
|
||||
if(views.count(key)){auto& v=*views.at(key);if(dock)overlays->ShowDashboard(key.c_str());else if(key!="notifications")show(v,true);return v;}
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="framely.manager"?4:(key=="menu"||key=="notifications")?2:1;
|
||||
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,key=="notifications"?100:0);
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
|
||||
|
||||
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
|
||||
if(dock){
|
||||
@@ -174,9 +215,9 @@ static void plugin_thumbnail(View& view,const std::string& encoded){
|
||||
for(int y=0;y<h;y++)for(int x=0;x<w;x++){int sx=std::min(width-1,int(x/scale)),sy=std::min(height-1,int(y/scale));std::copy_n(source.data()+(sy*width+sx)*4,4,pixels.data()+((y+(128-h)/2)*128+x+(128-w)/2)*4);}
|
||||
overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4);
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}create_view("framely.manager","Framely 插件管理",origin+"/manager",true,1440,810);menu_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_to_head(v,.42f,.42f,.32f,-.8f);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications"))show(*views.at("notifications"),false);}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="updates"||page=="notification-settings")?"#"+page:""),true,1440,810);if(page=="catalog"||page=="updates"||page=="notification-settings"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
|
||||
}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing||(v.key=="notifications"&&!v.shown))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:mouse.x=v.width/2;mouse.y=v.height/2;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications")close_view(v);break;default:break;}}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing||(v.key=="notifications"&&!v.shown))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:mouse.x=v.width/2;mouse.y=v.height/2;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications")close_view(v);break;default:break;}}
|
||||
|
||||
int main(int argc,char** argv){
|
||||
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
|
||||
@@ -199,13 +240,13 @@ int main(int argc,char** argv){
|
||||
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t*,cef_frame_t*,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);return int(s.rfind(origin+"/",0)!=0&&s!="about:blank");};
|
||||
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
|
||||
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;}};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);std::lock_guard<std::mutex> lock(queue_mutex);for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));std::lock_guard<std::mutex> lock(queue_mutex);for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}keyboard_event(global);}vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
|
||||
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
|
||||
@@ -215,7 +256,7 @@ int main(int argc,char** argv){
|
||||
vr::HmdMatrix34_t frame{};bool frame_ok=visible&&fresh&&!overlays->GetTransformForOverlayCoordinates(dock,vr::TrackingUniverseStanding,{{mouse_scale.v[0]*.5f,mouse_scale.v[1]*.5f}},¢er)&&dock_rigid_frame(center,frame);
|
||||
auto sample_now=std::chrono::steady_clock::now();
|
||||
if(frame_ok&&sample_now-sampled_at>=std::chrono::milliseconds(100)){sampled_at=sample_now;anchor_ok=dock_probe_origin(center,head)&&dock_uv_anchor(overlays,dock,center,head,width,height,anchor,evidence,size,uv);}
|
||||
auto now=std::chrono::steady_clock::now();if(anchor_ok){saved_anchor=anchor;saved_frame=frame;saved_size=size;saved_evidence=evidence;anchored_at=now;}else if(frame_ok&&now-anchored_at<std::chrono::milliseconds(350)){anchor=dock_follow_pose(saved_anchor,saved_frame,frame);size=saved_size;evidence=saved_evidence;anchor_ok=true;}
|
||||
auto now=std::chrono::steady_clock::now();if(anchor_ok){if(evidence.radius>0)notification_curve_radius=evidence.radius;saved_anchor=anchor;saved_frame=frame;saved_size=size;saved_evidence=evidence;anchored_at=now;}else if(frame_ok&&now-anchored_at<std::chrono::milliseconds(350)){anchor=dock_follow_pose(saved_anchor,saved_frame,frame);size=saved_size;evidence=saved_evidence;anchor_ok=true;}
|
||||
if(keyboard_menu.holds(now)){if(views.count("menu"))show(*views.at("menu"),menu_open);}else if(anchor_ok){// Apply the button tilt in its pose: nonzero API pre-curve pitch hid it on Frame.
|
||||
auto icon_pose=anchor;float cp=std::cos(evidence.pre_curve_pitch),sp=std::sin(evidence.pre_curve_pitch);for(int r=0;r<3;r++){icon_pose.m[r][1]=anchor.m[r][1]*cp+anchor.m[r][2]*sp;icon_pose.m[r][2]=anchor.m[r][2]*cp-anchor.m[r][1]*sp;}button_placement.apply(overlays,button,icon_pose,size*5);button_placement.curve(overlays,button,size*5,evidence.radius,0);if(!overlays->IsOverlayVisible(button))overlays->ShowOverlay(button);if(views.count("menu")){MenuPlacement popup{};auto& v=*views.at("menu");vr::HmdMatrix34_t main_pose{};vr::VROverlayHandle_t main_overlay=0;vr::HmdVector2_t main_scale{};const vr::HmdMatrix34_t* main=nullptr;
|
||||
for(auto&[key,view]:views)if(view->dashboard&&view->shown&&!view->closing)main_overlay=view->overlay;
|
||||
@@ -233,7 +274,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);
|
||||
@@ -266,11 +307,22 @@ int main(int argc,char** argv){
|
||||
auto error=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1);
|
||||
if(error){std::cerr<<"Notification input mask failed "<<error<<"\n";visible=false;}
|
||||
}
|
||||
show(*v,visible);
|
||||
v->notification_content=visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(key=="notifications"){
|
||||
if(v->notification_click.active&&std::chrono::steady_clock::now()-v->notification_click.pressed>std::chrono::milliseconds(800)){
|
||||
// A release outside the overlay may not arrive. Never leave CEF pressed.
|
||||
cef_mouse_event_t outside{};outside.x=outside.y=-1;
|
||||
v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);v->notification_click.clear();
|
||||
}
|
||||
if(v->notification_content&&!v->placement.valid)anchor_notification(*v);
|
||||
if(v->placement.valid)v->placement.curve(overlays,v->overlay,v->placement.width,notification_curve_radius);
|
||||
show(*v,v->notification_content&&v->placement.valid);
|
||||
}
|
||||
}
|
||||
{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.
|
||||
@@ -278,7 +330,7 @@ int main(int argc,char** argv){
|
||||
close_keyboard(false);
|
||||
}
|
||||
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
|
||||
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);if(icon_active!=menu_open||icon_hover!=hovered){icon_active=menu_open;pixels=dock_icon(hovered,menu_open);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
|
||||
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=menu_open||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open;pixels=dock_icon(hovered,menu_open);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
|
||||
auto sync=overlays->WaitFrameSync(8);
|
||||
// A timeout already waited: never append another sleep to it. Bound retries
|
||||
// only for immediate API failures, so a broken sync call cannot busy-spin.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Rasterize the unread count directly onto the Dock's 128px icon texture.
|
||||
// The zero state leaves the original icon byte-for-byte unchanged.
|
||||
inline void notification_badge(std::vector<uint8_t>& rgba, unsigned count) {
|
||||
if (!count || rgba.size()!=128*128*4) return;
|
||||
const std::string label=count>99?"99+":std::to_string(count);
|
||||
const int text_width=int(label.size())*12-3,width=text_width+14,left=124-width,top=4,height=29;
|
||||
for(int y=top;y<top+height;y++)for(int x=left;x<124;x++){
|
||||
const int dx=std::max({left+6-x,0,x-117}),dy=std::max({top+6-y,0,y-(top+height-7)});
|
||||
if(dx*dx+dy*dy>36)continue;
|
||||
auto i=(y*128+x)*4;rgba[i]=147;rgba[i+1]=197;rgba[i+2]=237;rgba[i+3]=255;
|
||||
}
|
||||
// Compact 3x5 numerals retain a readable count at the Frame Dock's display scale.
|
||||
constexpr uint16_t digits[]={0b111101101101111,0b010110010010111,0b111001111100111,0b111001111001111,0b101101111001001,0b111100111001111,0b111100111101111,0b111001001001001,0b111101111101111,0b111101111001111};
|
||||
for(size_t n=0;n<label.size();n++){
|
||||
const uint16_t bits=label[n]=='+'?0b000010111010000:digits[label[n]-'0'];
|
||||
for(int y=0;y<5;y++)for(int x=0;x<3;x++)if(bits&(1u<<(14-y*3-x)))
|
||||
for(int sy=0;sy<3;sy++)for(int sx=0;sx<3;sx++){
|
||||
auto i=((top+7+y*3+sy)*128+left+7+int(n)*12+x*3+sx)*4;
|
||||
rgba[i]=23;rgba[i+1]=25;rgba[i+2]=28;rgba[i+3]=255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,41 @@
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
#include "geometry_math.h"
|
||||
|
||||
struct NotificationPlacement {
|
||||
vr::HmdMatrix34_t transform{};
|
||||
float width=.32f,radius=0;
|
||||
};
|
||||
|
||||
// Keep the compact toast at the upper right, but turn its front toward the eye.
|
||||
// Copying the headset rotation leaves a side-offset overlay facing past the user.
|
||||
inline bool notification_placement(const vr::HmdMatrix34_t& headset,float radius,NotificationPlacement& out){
|
||||
vr::HmdMatrix34_t head{};
|
||||
if(!dock_rigid_frame(headset,head)||!std::isfinite(radius)||radius<=0)return false;
|
||||
vr::HmdVector3_t normal{},head_up{{head.m[0][1],head.m[1][1],head.m[2][1]}};
|
||||
for(int r=0;r<3;r++){
|
||||
out.transform.m[r][3]=head.m[r][3]+head.m[r][0]*.24f+head.m[r][1]*.12f-head.m[r][2]*1.f;
|
||||
normal.v[r]=head.m[r][3]-out.transform.m[r][3];
|
||||
}
|
||||
if(!normalize_dock_vector(normal))return false;
|
||||
auto right=cross_dock_vector(head_up,normal);
|
||||
if(!normalize_dock_vector(right))return false;
|
||||
auto up=cross_dock_vector(normal,right);
|
||||
for(int r=0;r<3;r++){
|
||||
out.transform.m[r][0]=right.v[r];out.transform.m[r][1]=up.v[r];out.transform.m[r][2]=normal.v[r];
|
||||
}
|
||||
out.radius=radius;return true;
|
||||
}
|
||||
|
||||
struct NotificationBounds {
|
||||
float x=0,y=0,width=0,height=0;
|
||||
bool visible() const {return width>0&&height>0;}
|
||||
};
|
||||
|
||||
// The submitted BGRA buffer is flipped in on_paint to OpenVR's bottom-left origin.
|
||||
// BGRA rows are bottom-up after on_paint. Intersection rectangles use top-left
|
||||
// coordinates, so the alpha bounds must be flipped back before submitting the mask.
|
||||
inline NotificationBounds notification_bounds(const std::vector<uint8_t>& pixels,int width,int height,int scale){
|
||||
if(width<=0||height<=0||scale<=0||pixels.size()!=size_t(width)*height*4)return {};
|
||||
int left=width,low=height,right=-1,high=-1;
|
||||
@@ -17,5 +45,22 @@ inline NotificationBounds notification_bounds(const std::vector<uint8_t>& pixels
|
||||
if(x<left)left=x;if(x>right)right=x;if(y<low)low=y;if(y>high)high=y;
|
||||
}
|
||||
if(right<left)return {};
|
||||
return {float(left)/scale,float(low)/scale,float(right-left+1)/scale,float(high-low+1)/scale};
|
||||
return {float(left)/scale,float(height-1-high)/scale,float(right-left+1)/scale,float(high-low+1)/scale};
|
||||
}
|
||||
|
||||
// Preserve the intended button during a brief click with normal tracking jitter.
|
||||
// Deliberate movement and long holds cancel stabilization.
|
||||
struct NotificationClick {
|
||||
using Time=std::chrono::steady_clock::time_point;
|
||||
bool active=false,cancelled=false;int x=0,y=0;Time pressed{};
|
||||
void begin(int px,int py,Time now){active=true;cancelled=false;x=px;y=py;pressed=now;}
|
||||
bool hold(int px,int py,Time now){
|
||||
if(!active)return false;
|
||||
if(now-pressed>std::chrono::milliseconds(800)||std::hypot(float(px-x),float(py-y))>18.f)cancelled=true;
|
||||
return !cancelled;
|
||||
}
|
||||
bool release(int& px,int& py,Time now){
|
||||
bool stable=hold(px,py,now);if(stable){px=x;py=y;}active=false;return stable;
|
||||
}
|
||||
void clear(){active=false;cancelled=false;}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
// Other localhost ports are only for embedded pages in plugin windows.
|
||||
// The main frame remains on the Framely origin.
|
||||
inline bool allow_plugin_navigation(const std::string& url, const std::string& origin,
|
||||
bool main_frame, bool plugin_window) {
|
||||
if (url == "about:blank" || url.rfind(origin + "/", 0) == 0) return true;
|
||||
if (main_frame || !plugin_window) return false;
|
||||
const std::string prefix = "http://localhost:";
|
||||
if (url.rfind(prefix, 0) != 0) return false;
|
||||
auto end = url.find('/', prefix.size());
|
||||
if (end == std::string::npos || end == prefix.size()) return false;
|
||||
unsigned port = 0;
|
||||
for (auto i = prefix.size(); i < end; ++i) {
|
||||
if (url[i] < '0' || url[i] > '9') return false;
|
||||
port = port * 10 + unsigned(url[i] - '0');
|
||||
if (port > 65535) return false;
|
||||
}
|
||||
return port > 0;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"examples/showcase"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
@@ -713,6 +714,32 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.48.0.tgz",
|
||||
"integrity": "sha512-lQk06gVHNVBnJp0UOgtth54mcZBJM7rdYKsAxKOProj1bbSAx+/xiuOWfHlyvgdL3M+Y+I0HINrjHa3ygp8eXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.48.0.tgz",
|
||||
"integrity": "sha512-OCeAlpaWHEMV+EsgdVeGQ3YuKfmfnThZhc++CaZ/ujYzi5+/DONDQlX4OGvH1ikYhIfmc0grI4otJgHy7QAvng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.48.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"plugin:init": "node tools/plugin-dev.mjs init"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
|
||||
[[ -w /etc/systemd/system && -w /etc && -w /var/lib ]] || { echo 'System configuration is not writable. Framely did not change the read-only setting; installation stopped.' >&2; exit 1; }
|
||||
cd "$base"
|
||||
sha256sum --quiet -c SHA256SUMS
|
||||
for binary in bin/framely lib/cef/framely-vr; do
|
||||
for binary in bin/framely; do
|
||||
dependencies=$(ldd "$binary" 2>&1)
|
||||
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
|
||||
done
|
||||
@@ -74,6 +74,9 @@ mkdir -p "$root/logs"
|
||||
chmod 755 "$root" "$root/releases" "$root/plugins" "$root/data"
|
||||
chmod 700 "$root/logs"
|
||||
old=$(readlink "$root/current" || true)
|
||||
if ! $repair && [[ -z $old && -f CEF_RUNTIME.json && ! -f lib/cef/libcef.so ]]; then
|
||||
echo 'First installation requires the complete offline package with CEF.' >&2; exit 1
|
||||
fi
|
||||
if $repair; then
|
||||
[[ $old == "releases/$version" && -d $release && ! -L $release ]] || { echo 'Repair requires the currently installed release.' >&2; exit 1; }
|
||||
(cd "$release" && sha256sum --quiet -c SHA256SUMS)
|
||||
@@ -95,9 +98,23 @@ cleanup_install() {
|
||||
}
|
||||
trap cleanup_install EXIT
|
||||
if ! $repair; then
|
||||
cp -a bin lib share tools VERSION SHA256SUMS ./*.sh "$stage/"
|
||||
# Preserve every checksummed file, including documentation and runtime metadata.
|
||||
cp -a "$base/." "$stage/"
|
||||
chown -R root:root "$stage"
|
||||
find "$stage" -type d -exec chmod 755 {} +
|
||||
fi
|
||||
if [[ -f CEF_RUNTIME.json ]]; then
|
||||
# Cache exact runtimes outside releases so upgrades and rollback share files.
|
||||
cef_runtime=$(python3 "$base/tools/cef-runtime.py" prepare "$base" "$store")
|
||||
if $repair; then cef_release="$release"; else cef_release="$stage"; fi
|
||||
python3 "$base/tools/cef-runtime.py" attach "$cef_release" "$cef_runtime"
|
||||
(cd "$cef_release" && sha256sum --quiet -c SHA256SUMS)
|
||||
dependencies=$(ldd "$cef_release/lib/cef/framely-vr" 2>&1)
|
||||
else
|
||||
dependencies=$(ldd "$base/lib/cef/framely-vr" 2>&1)
|
||||
fi
|
||||
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
|
||||
if ! $repair; then
|
||||
mv "$stage" "$release"
|
||||
release_created=true
|
||||
fi
|
||||
|
||||
@@ -29,7 +29,7 @@ rm -f /etc/systemd/system/framely.service /etc/systemd/system/framely-session.se
|
||||
systemctl daemon-reload
|
||||
rm -f "$root/current" "$root/previous-release"
|
||||
rm -f /home/.framely/state/current /home/.framely/state/previous-release /home/.framely/repair.sh
|
||||
rm -rf /home/.framely/releases /run/framely
|
||||
rm -rf /home/.framely/releases /home/.framely/cef /run/framely
|
||||
rm -f "$root/releases" /home/.framely/state/releases
|
||||
# External plugin effects cannot be reliably erased; retain data for recovery.
|
||||
echo 'All plugins and Framely were uninstalled. Saved settings and plugin data were retained.'
|
||||
@@ -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,17 +1,20 @@
|
||||
export * from './visibility';
|
||||
import {createVisibilityApi, type UiVisibility} from './visibility';
|
||||
export * from './lifecycle';
|
||||
import {installScrollbars} from './scrollbars';
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
export interface NotificationAction {id:string;label:string;icon:string}
|
||||
export interface Notification {id:string;title:string;body:string;image?:string;actions?:NotificationAction[];durationMs?:number}
|
||||
export interface NotificationAction {id:string;label:string;icon?:string;closeOnClick?:boolean;removeFromInboxOnClick?:boolean}
|
||||
export interface Notification {id:string;title:string;body:string;image?:string;actions?:NotificationAction[];durationMs?:number;inbox?:boolean}
|
||||
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),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})},
|
||||
dependencies:():Promise<DependencyStatus[]>=>bridge().request('dependencies'),
|
||||
notifications:{send:(notification:Notification)=>bridge().request('notification.send',{notification}),remove:(id:string)=>bridge().request('notification.remove',{id})},
|
||||
notifications:{send:(notification:Notification)=>bridge().request('notification.send',{notification}),remove:(id:string)=>bridge().request('notification.remove',{id}),dismiss:(id:string)=>bridge().request('notification.dismiss',{id})},
|
||||
onEvent:(callback:(event:unknown)=>void)=>bridge().subscribe(callback),
|
||||
};
|
||||
export function registerPlugin(pages:{QuickPage:React.ComponentType;WindowPage?:React.ComponentType;windows?:Record<string,React.ComponentType>}){installScrollbars();const key=location.pathname.split('/').pop();const Page=key==='quick'?pages.QuickPage:pages.windows?.[key??'']??pages.WindowPage??pages.QuickPage;const style=document.createElement('style');style.textContent='*{box-sizing:border-box}body{margin:0;padding:20px;background:#101820;color:#edf2fa;font:20px system-ui}button,input,textarea{font:inherit;color:inherit;border:1px solid #52627a;border-radius:6px;padding:12px;background:#24333e;margin:6px 6px 6px 0;min-height:48px}button{cursor:pointer}button:disabled{opacity:.5}input,textarea{width:100%}p{line-height:1.6}small{color:#acb9cf}';document.head.append(style);createRoot(document.getElementById('root')!).render(<Boundary><Page/></Boundary>);console.log('FRAMELY_PLUGIN_READY');}
|
||||
@@ -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
|
||||
}
|
||||
@@ -578,13 +583,121 @@ pub struct Database {
|
||||
pub update_source: Option<UpdateSource>,
|
||||
#[serde(default, skip_serializing_if = "UpdateChannel::is_stable")]
|
||||
pub update_channel: UpdateChannel,
|
||||
#[serde(default)]
|
||||
pub update_check: UpdateCheckSettings,
|
||||
#[serde(default)]
|
||||
pub update_notice: Option<UpdateNotice>,
|
||||
#[serde(default)]
|
||||
pub notification_inbox: BTreeMap<String, NotificationEntry>,
|
||||
#[serde(default)]
|
||||
pub notification_settings: NotificationSettings,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NotificationPreference {
|
||||
#[serde(default = "yes")]
|
||||
pub popups: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub badge: bool,
|
||||
}
|
||||
impl Default for NotificationPreference {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
popups: true,
|
||||
badge: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NotificationSettings {
|
||||
#[serde(default = "yes")]
|
||||
pub popups: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub badge: bool,
|
||||
#[serde(default)]
|
||||
pub plugins: BTreeMap<String, NotificationPreference>,
|
||||
}
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
popups: true,
|
||||
badge: true,
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl NotificationSettings {
|
||||
pub fn popups_for(&self, plugin: &str) -> bool {
|
||||
self.popups && self.plugins.get(plugin).is_none_or(|p| p.popups)
|
||||
}
|
||||
pub fn badge_for(&self, plugin: &str) -> bool {
|
||||
self.badge && self.plugins.get(plugin).is_none_or(|p| p.badge)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NotificationSettingsPatch {
|
||||
pub plugin: Option<String>,
|
||||
pub popups: Option<bool>,
|
||||
pub badge: Option<bool>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateCheckSettings {
|
||||
pub enabled: bool,
|
||||
pub interval_hours: u64,
|
||||
}
|
||||
impl Default for UpdateCheckSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
interval_hours: 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl UpdateCheckSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
(1..=168).contains(&self.interval_hours),
|
||||
"自动检查间隔须为 1-168 小时"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateNotice {
|
||||
pub source: UpdateSource,
|
||||
pub channel: UpdateChannel,
|
||||
pub version: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NotificationEntry {
|
||||
pub plugin: String,
|
||||
pub plugin_name: String,
|
||||
pub created_at: u64,
|
||||
pub expires_at: Option<u64>,
|
||||
pub toast: bool,
|
||||
pub in_inbox: bool,
|
||||
#[serde(default)]
|
||||
pub read: bool,
|
||||
pub notification: Notification,
|
||||
#[serde(default)]
|
||||
pub update: Option<UpdateNotice>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Action {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default = "yes")]
|
||||
pub close_on_click: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub remove_from_inbox_on_click: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -598,6 +711,8 @@ pub struct Notification {
|
||||
pub actions: Vec<Action>,
|
||||
#[serde(default = "ttl")]
|
||||
pub duration_ms: u64,
|
||||
#[serde(default)]
|
||||
pub inbox: bool,
|
||||
}
|
||||
fn ttl() -> u64 {
|
||||
8000
|
||||
@@ -610,7 +725,7 @@ impl Notification {
|
||||
"Notification limit exceeded"
|
||||
);
|
||||
ensure!(
|
||||
(1000..=60000).contains(&self.duration_ms),
|
||||
self.duration_ms == 0 || (1000..=60000).contains(&self.duration_ms),
|
||||
"Invalid notification duration"
|
||||
);
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
@@ -349,7 +358,10 @@ impl Agent {
|
||||
commands.push(queue.pop_front().unwrap());
|
||||
}
|
||||
drop(queue);
|
||||
return send_json(r, json!({"commands":commands}));
|
||||
let badge = self
|
||||
.core("host.notification.badge", json!({}))
|
||||
.unwrap_or(json!(0));
|
||||
return send_json(r, json!({"commands":commands,"notificationBadge":badge}));
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
let (id, file) = rel.split_once('/').context("Invalid asset path")?;
|
||||
@@ -514,7 +526,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 +567,7 @@ impl Agent {
|
||||
| "agreement.decline"
|
||||
| "agreement.revoke"
|
||||
| "ui.events"
|
||||
| "ui.visibility.get"
|
||||
| "host.manager.open"
|
||||
| "host.manager.close"
|
||||
| "host.menu.close"
|
||||
@@ -659,6 +675,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
|
||||
@@ -673,7 +698,16 @@ impl Agent {
|
||||
"host.manager.close" => "manager.close",
|
||||
_ => "menu.close",
|
||||
};
|
||||
push(&self.commands, json!({"kind":kind}));
|
||||
let mut command = json!({"kind":kind});
|
||||
if method == "host.manager.open"
|
||||
&& matches!(
|
||||
p["page"].as_str(),
|
||||
Some("catalog" | "updates" | "notification-settings")
|
||||
)
|
||||
{
|
||||
command["page"] = p["page"].clone();
|
||||
}
|
||||
push(&self.commands, command);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"network.status" => Ok(
|
||||
@@ -761,8 +795,11 @@ impl Agent {
|
||||
| "window.open"
|
||||
| "window.close"
|
||||
| "notification.send"
|
||||
| "notification.dismiss"
|
||||
| "notification.remove"
|
||||
| "notification.action"
|
||||
| "notification.settings.save"
|
||||
| "notification.read"
|
||||
| "sources.save"
|
||||
| "subscriptions.add"
|
||||
| "subscriptions.change"
|
||||
@@ -776,6 +813,7 @@ impl Agent {
|
||||
| "safeMode"
|
||||
| "system.source.save"
|
||||
| "system.channel.save"
|
||||
| "system.check.settings.save"
|
||||
| "system.check.start"
|
||||
| "system.download.start"
|
||||
| "system.job.status"
|
||||
@@ -1089,6 +1127,7 @@ pub fn serve(
|
||||
"window.close",
|
||||
"plugin.disabled",
|
||||
"notification.changed",
|
||||
"manager.open",
|
||||
]
|
||||
.contains(&e["kind"].as_str().unwrap_or(""))
|
||||
{
|
||||
@@ -1272,8 +1311,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));
|
||||
});
|
||||
@@ -1299,6 +1340,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());
|
||||
@@ -1442,6 +1486,32 @@ mod tests {
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn manager_entry_preserves_catalog_and_update_targets_and_rejects_arbitrary_pages() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
for params in [
|
||||
json!({"page":"catalog"}),
|
||||
json!({"page":"updates"}),
|
||||
json!({"page":"notification-settings"}),
|
||||
json!({}),
|
||||
json!({"page":"https://example.org"}),
|
||||
] {
|
||||
agent
|
||||
.api(json!({"method":"host.manager.open","params":params}))
|
||||
.unwrap();
|
||||
}
|
||||
let commands = agent.commands.lock().unwrap();
|
||||
assert_eq!(commands[0], json!({"kind":"manager.open","page":"catalog"}));
|
||||
assert_eq!(commands[1], json!({"kind":"manager.open","page":"updates"}));
|
||||
assert_eq!(
|
||||
commands[2],
|
||||
json!({"kind":"manager.open","page":"notification-settings"})
|
||||
);
|
||||
assert_eq!(commands[3], json!({"kind":"manager.open"}));
|
||||
assert_eq!(commands[4], json!({"kind":"manager.open"}));
|
||||
}
|
||||
#[test]
|
||||
fn declining_agreement_only_closes_current_view() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
@@ -1814,6 +1884,52 @@ 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",
|
||||
"host.notification.badge",
|
||||
"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 {
|
||||
@@ -2152,7 +2268,7 @@ mod tests {
|
||||
.unwrap();
|
||||
print!("{}", String::from_utf8_lossy(&store_result.stdout));
|
||||
eprint!("{}", String::from_utf8_lossy(&store_result.stderr));
|
||||
agent.core("notification.send",json!({"plugin":"test.bridge","notification":{"id":"toast","title":"Toast integration","body":"Compact notification body","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=","durationMs":15000,"actions":[{"id":"ok","label":"确认","icon":"✓"},{"id":"save","label":"保存","icon":"↓"},{"id":"more","label":"详情","icon":"⋯"}]}})).unwrap();
|
||||
agent.core("notification.send",json!({"plugin":"test.bridge","notification":{"id":"toast","title":"Toast integration","body":"Compact notification body","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=","durationMs":15000,"actions":[{"id":"ok","label":"确认","icon":"✓","closeOnClick":false,"removeFromInboxOnClick":false},{"id":"save","label":"保存","icon":"↓"},{"id":"more","label":"详情","icon":"⋯"}]}})).unwrap();
|
||||
let notification_result = Command::new(std::env::var_os("FRAMELY_BROWSER_PROBE").unwrap())
|
||||
.arg(format!(
|
||||
"{}/boot/{}?view=notifications",
|
||||
|
||||
@@ -299,6 +299,7 @@ fn notification_limits_and_sources() {
|
||||
image: None,
|
||||
actions: vec![],
|
||||
duration_ms: 8000,
|
||||
inbox: false,
|
||||
};
|
||||
n.validate().unwrap();
|
||||
let mut invalid = n.clone();
|
||||
@@ -307,6 +308,8 @@ fn notification_limits_and_sources() {
|
||||
id: i.to_string(),
|
||||
label: "x".into(),
|
||||
icon: "x".into(),
|
||||
close_on_click: true,
|
||||
remove_from_inbox_on_click: true,
|
||||
})
|
||||
.collect();
|
||||
assert!(invalid.validate().is_err());
|
||||
@@ -1639,3 +1642,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();
|
||||
}
|
||||
@@ -69,6 +69,10 @@ pub fn create_descriptor(archive: &Path, url: &str, output: &Path, changelog: &s
|
||||
.strip_prefix("framely-")
|
||||
.and_then(|n| n.strip_suffix("-linux-arm64.tar.gz"))
|
||||
.context("必须使用 Framely 发行包命名")?;
|
||||
ensure!(
|
||||
!version.ends_with("-offline"),
|
||||
"更新清单必须使用不含 CEF 的本体包"
|
||||
);
|
||||
let release = Release {
|
||||
schema_version: 1,
|
||||
version: version.into(),
|
||||
@@ -85,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,
|
||||
@@ -110,7 +150,7 @@ struct GitHubRelease {
|
||||
draft: bool,
|
||||
assets: Vec<GitHubAsset>,
|
||||
}
|
||||
fn descriptor_version(version: &str) -> Result<semver::Version> {
|
||||
pub(crate) fn descriptor_version(version: &str) -> Result<semver::Version> {
|
||||
// Runtime VERSION appends a twelve-digit content hash to the Cargo version.
|
||||
let version = version
|
||||
.rsplit_once('-')
|
||||
@@ -292,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,
|
||||
@@ -484,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"
|
||||
@@ -608,6 +739,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn split_release_always_resolves_the_core_archive() {
|
||||
let mut item = fixture("0.4.2-preview.1", false);
|
||||
item.0["assets"].as_array_mut().unwrap().extend([
|
||||
json!({"name":"framely-0.4.2-preview.1-012345abcdef-offline-linux-arm64.tar.gz","browser_download_url":"https://example.org/offline","size":900}),
|
||||
json!({"name":"framely-cef-154-build-linux-arm64.tar.gz","browser_download_url":"https://example.org/cef","size":800}),
|
||||
]);
|
||||
let resolved = resolve_fixture(UpdateChannel::Testing, vec![item])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(resolved
|
||||
.0
|
||||
.url
|
||||
.ends_with("framely-0.4.2-preview.1-012345abcdef-linux-arm64.tar.gz"));
|
||||
}
|
||||
#[test]
|
||||
fn custom_descriptors_distinguish_channels_and_ignore_packaging_hashes() {
|
||||
for (version, channel) in [
|
||||
("0.4.2", UpdateChannel::Stable),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ int main(int argc,char** argv){
|
||||
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
|
||||
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=30;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
|
||||
auto end=std::chrono::steady_clock::now()+std::chrono::seconds(20);bool injected=false;
|
||||
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input"))throw Error("release configuration leaked into user settings");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].find(b=>b.textContent==="取消").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于"));[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于").click();await until(()=>document.querySelector(".system-updates"));[...document.querySelectorAll("nav button")].find(b=>b.textContent.includes("插件商店")).click();const card=await until(()=>document.querySelector(".store-card"));if(!card.textContent.includes("可更新"))throw Error("update not detected");if(card.querySelector(".plugin-avatar").getBoundingClientRect().width<64)throw Error("store icon not enlarged");if(docLine truncated
|
||||
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].find(b=>b.textContent==="取消").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于"));[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于").click();await until(()=>document.querySelector(".system-updates"));[...document.querySelectorAll("nav button")].find(b=>b.textContent.includes("插件库")).click();const card=await until(()=>document.querySelector(".store-card"));if(!card.textContenLine truncated
|
||||
if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");}
|
||||
host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<<ok<<" paints="<<paints<<" closed="<<closed<<"\n";if(!closed)_Exit(5);host->base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "notification_badge.h"
|
||||
#include <cassert>
|
||||
|
||||
int main(){
|
||||
std::vector<uint8_t> original(128*128*4,40),pixels=original;
|
||||
notification_badge(pixels,0);assert(pixels==original);
|
||||
notification_badge(pixels,1);assert(pixels!=original);
|
||||
auto one=pixels;pixels=original;notification_badge(pixels,12);assert(pixels!=one);
|
||||
pixels=original;notification_badge(pixels,99);auto ninety_nine=pixels;
|
||||
pixels=original;notification_badge(pixels,100);assert(pixels!=ninety_nine);
|
||||
auto maximum=pixels;pixels=original;notification_badge(pixels,256);assert(pixels==maximum);
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++)if(y>=33||x<74)
|
||||
for(int c=0;c<4;c++)assert(pixels[(y*128+x)*4+c]==40);
|
||||
std::vector<uint8_t> invalid(12,0);notification_badge(invalid,1);assert(invalid==std::vector<uint8_t>(12,0));
|
||||
}
|
||||
@@ -2,6 +2,47 @@
|
||||
#include <cassert>
|
||||
|
||||
int main(){
|
||||
// A side-offset toast must face the eye, including tilted/rotated tracking frames.
|
||||
for(float yaw:{-.7f,0.f,.8f})for(float tilt:{-.6f,0.f,.5f})for(float radius:{.6f,1.3f,2.f}){
|
||||
float cy=std::cos(yaw),sy=std::sin(yaw),ct=std::cos(tilt),st=std::sin(tilt);
|
||||
vr::HmdMatrix34_t head{{{cy,sy*st,sy*ct,.6f},{0,ct,-st,.8f},{-sy,cy*st,cy*ct,-.7f}}};
|
||||
NotificationPlacement toast{};assert(notification_placement(head,radius,toast));
|
||||
auto& m=toast.transform;
|
||||
vr::HmdVector3_t eye{},right{{m.m[0][0],m.m[1][0],m.m[2][0]}},up{{m.m[0][1],m.m[1][1],m.m[2][1]}};
|
||||
float distance=0;
|
||||
for(int r=0;r<3;r++){
|
||||
eye.v[r]=head.m[r][3]-m.m[r][3];distance+=eye.v[r]*eye.v[r];
|
||||
float expected=head.m[r][3]+head.m[r][0]*.24f+head.m[r][1]*.12f-head.m[r][2]*1.f;
|
||||
assert(std::abs(expected-m.m[r][3])<1e-6f);
|
||||
}
|
||||
assert(normalize_dock_vector(eye));auto normal=cross_dock_vector(right,up);
|
||||
for(int r=0;r<3;r++){assert(std::abs(eye.v[r]-m.m[r][2])<1e-6f);assert(std::abs(normal.v[r]-m.m[r][2])<1e-6f);}
|
||||
assert(toast.radius==radius&&toast.width==.32f);
|
||||
// Both ends of the compact cylindrical surface remain front-facing to the eye.
|
||||
for(float side:{-1.f,1.f}){
|
||||
float angle=side*toast.width*.5f/radius,dot=0;
|
||||
for(int r=0;r<3;r++){
|
||||
float point=m.m[r][3]+right.v[r]*radius*std::sin(angle)+normal.v[r]*radius*(1-std::cos(angle));
|
||||
float front=normal.v[r]*std::cos(angle)-right.v[r]*std::sin(angle);
|
||||
dot+=front*(head.m[r][3]-point);
|
||||
}
|
||||
assert(dot>0);
|
||||
}
|
||||
}
|
||||
NotificationPlacement invalid{};vr::HmdMatrix34_t zero{};assert(!notification_placement(zero,1.3f,invalid));
|
||||
vr::HmdMatrix34_t identity{{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};
|
||||
assert(!notification_placement(identity,0,invalid));assert(!notification_placement(identity,NAN,invalid));
|
||||
// A card painted at the top of the UI must hit at the top, not below it.
|
||||
std::vector<uint8_t> card(600*360*4,0);
|
||||
for(int y=360-8-150;y<360-8;y++)for(int x=8;x<592;x++)card[(y*600+x)*4+3]=255;
|
||||
auto top=notification_bounds(card,600,360,1);
|
||||
assert(top.x==8&&top.y==8&&top.width==584&&top.height==150);
|
||||
NotificationClick click;auto now=std::chrono::steady_clock::now();int x=110,y=106;
|
||||
click.begin(100,100,now);assert(click.hold(x,y,now+std::chrono::milliseconds(80)));
|
||||
assert(click.release(x,y,now+std::chrono::milliseconds(180)));assert(x==100&&y==100&&!click.active);
|
||||
click.begin(100,100,now);assert(!click.hold(140,100,now));x=101;y=100;assert(!click.release(x,y,now));assert(x==101);
|
||||
click.begin(100,100,now);x=y=100;assert(!click.release(x,y,now+std::chrono::seconds(1)));
|
||||
click.begin(100,100,now);click.clear();assert(!click.release(x,y,now));
|
||||
std::vector<uint8_t> pixels(12*8*4,0);
|
||||
assert(!notification_bounds(pixels,12,8,2).visible());
|
||||
// RGB left over in a transparent frame must not capture input.
|
||||
@@ -11,13 +52,13 @@ int main(){
|
||||
pixels[(4*12+9)*4+3]=1;
|
||||
auto bounds=notification_bounds(pixels,12,8,2);
|
||||
assert(bounds.visible());
|
||||
assert(bounds.x==1&&bounds.y==.5f&&bounds.width==4&&bounds.height==2);
|
||||
assert(bounds.x==1&&bounds.y==1.5f&&bounds.width==4&&bounds.height==2);
|
||||
// Dismissing/expiring the final toast yields an empty frame again.
|
||||
pixels[(1*12+2)*4+3]=0;pixels[(4*12+9)*4+3]=0;
|
||||
assert(!notification_bounds(pixels,12,8,2).visible());
|
||||
pixels.back()=255;
|
||||
bounds=notification_bounds(pixels,12,8,2);
|
||||
assert(bounds.x==5.5f&&bounds.y==3.5f&&bounds.width==.5f&&bounds.height==.5f);
|
||||
assert(bounds.x==5.5f&&bounds.y==0&&bounds.width==.5f&&bounds.height==.5f);
|
||||
assert(!notification_bounds(pixels,12,8,0).visible());
|
||||
assert(!notification_bounds({},12,8,2).visible());
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Exercise the real notification controls, including failures and hidden headset views.
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {NotificationSettings,PopupPermission,useReadInbox,type NotificationConfig} from '../ui/src/notification-settings';
|
||||
import {NotificationCenter,type NotificationEntry} from '../ui/src/notifications';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const plugins={
|
||||
'capture.clip':{manifest:{id:'capture.clip',name:'Clip capture',description:'Capture your session',version:'1.0.0'},enabled:true,order:0},
|
||||
'capture.off':{manifest:{id:'capture.off',name:'Frame diagnostics',description:'Device diagnostics',version:'1.0.0'},enabled:false,order:1},
|
||||
};
|
||||
let config:NotificationConfig={popups:true,badge:true,plugins:{}},fail=false,visible=false,readCalls=0;
|
||||
const messages:NotificationEntry[]=[{plugin:'capture.clip',pluginName:'Clip capture',createdAt:Date.now(),expiresAt:null,toast:false,inInbox:true,notification:{id:'saved',title:'录像已保存',body:'录像已经保存,可以稍后打开。',inbox:true}}];
|
||||
const calls:any[]=[];
|
||||
window.fetch=async(_url,init)=>{
|
||||
const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='status')result={version:'0.4.2-preview.12',agreement:{accepted:true,version:'fixture'},database:{plugins,sources:[],safeMode:false,language:'en-US',notificationSettings:config},notifications:[],inbox:messages,notificationBadge:0,running:[]};
|
||||
else if(method==='language.list')result={packs:[]};
|
||||
else if(method==='ui.events')result={events:[],cursor:0};
|
||||
else if(method==='notification.settings.save'){
|
||||
if(fail)return new Response(JSON.stringify({error:'Fixture settings failed'}));
|
||||
config={...config,plugins:{...config.plugins}};
|
||||
const preference=typeof params.plugin==='string'?(config.plugins[params.plugin]={...(config.plugins[params.plugin]??{popups:true,badge:true})}):config;
|
||||
for(const key of ['popups','badge'] as const)if(typeof params[key]==='boolean')preference[key]=params[key];
|
||||
}else if(method==='ui.visibility.get')result={known:true,pageVisible:visible};
|
||||
else if(method==='notification.read'){
|
||||
readCalls++;
|
||||
for(const item of params.entries){const n=messages.find(n=>n.plugin===item.plugin&&n.notification.id===item.id&&n.createdAt===item.createdAt);if(n)n.read=true;}
|
||||
}else if(!['host.manager.open','host.haptic'].includes(method))throw Error('Unexpected API '+method);
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
let switchMode:(value:'settings'|'quick')=>void,refresh:()=>Promise<void>;
|
||||
function Fixture(){
|
||||
const[mode,setMode]=useState<'settings'|'quick'>('settings'),[,update]=useState(0);
|
||||
switchMode=setMode;refresh=async()=>update(v=>v+1);
|
||||
useReadInbox(mode==='quick',messages,refresh);
|
||||
return <div className={`app ${mode==='settings'?'manager':'quick'}`}><main className="content" style={mode==='quick'?{maxWidth:600,margin:'0 auto',width:'100%'}:undefined}>{mode==='settings'?<NotificationSettings config={config} plugins={plugins} refresh={refresh}/>:<NotificationCenter inbox items={messages} plugins={plugins} refresh={refresh} controls={<PopupPermission config={config} refresh={refresh}/>}/>}</main></div>;
|
||||
}
|
||||
function control(label:string){const node=document.querySelector(`[role=switch][aria-label="${label}"]`);if(!node)throw Error('Missing switch '+label);return node as HTMLButtonElement;}
|
||||
async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;await wait();}throw Error('UI timeout');}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('zh-CN',[]);await refresh();await wait(150);
|
||||
if(document.querySelectorAll('.notification-plugin-row').length!==3||document.querySelectorAll('[role=switch]').length!==8)throw Error('Installed plugins or notification controls missing');
|
||||
if(!document.body.textContent!.includes('已停用'))throw Error('Disabled plugin omitted');
|
||||
if(!document.body.textContent!.includes('本体通知,包括检查更新提醒。'))throw Error('Framely notification source missing');
|
||||
const globals=document.querySelector('.notification-global-options') as HTMLElement,list=document.querySelector('.notification-plugin-list') as HTMLElement;
|
||||
if(Math.abs(globals.getBoundingClientRect().width-list.getBoundingClientRect().width)>1)throw Error('Global options and app list widths differ');
|
||||
const globalSwitch=control('悬浮通知').getBoundingClientRect(),popupSwitch=control('Framely:悬浮').getBoundingClientRect();
|
||||
if(Math.abs(globalSwitch.left-popupSwitch.left)>1)throw Error('Global and app popup switch columns are misaligned');
|
||||
control('Framely:悬浮').click();await until(()=>control('Framely:悬浮').getAttribute('aria-checked')==='false');
|
||||
control('Framely:角标').click();await until(()=>control('Framely:角标').getAttribute('aria-checked')==='false');
|
||||
if(!config.popups||!config.badge||config.plugins[''].popups||config.plugins[''].badge)throw Error('Core preferences changed global policy');
|
||||
if(!calls.some(c=>c.method==='notification.settings.save'&&c.params.plugin===''&&c.params.popups===false))throw Error('Framely popup preference was saved as global');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_NOTIFICATION_SETTINGS_ZH');
|
||||
fail=true;control('悬浮通知').click();await until(()=>!!document.querySelector('[role=alert]'));
|
||||
if(!config.popups||control('悬浮通知').getAttribute('aria-checked')!=='true')throw Error('Failed save changed switch');
|
||||
fail=false;control('悬浮通知').click();await until(()=>control('悬浮通知').getAttribute('aria-checked')==='false');
|
||||
if(!config.badge)throw Error('Popup switch changed badge policy');
|
||||
control('Clip capture:角标').click();await until(()=>control('Clip capture:角标').getAttribute('aria-checked')==='false');
|
||||
if(config.plugins['capture.clip'].popups!==true)throw Error('Plugin badge switch changed popup policy');
|
||||
control('Frame diagnostics:悬浮').click();await until(()=>control('Frame diagnostics:悬浮').getAttribute('aria-checked')==='false');
|
||||
control('启动图标角标').click();await until(()=>control('启动图标角标').getAttribute('aria-checked')==='false');
|
||||
if(config.plugins['capture.clip'].badge!==false||config.plugins['capture.off'].popups!==false)throw Error('Global switch erased plugin preferences');
|
||||
const search=document.querySelector('[aria-label="搜索通知应用"]') as HTMLInputElement;
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(search,'Frame diagnostics');search.dispatchEvent(new Event('input',{bubbles:true}));await wait();
|
||||
if(document.querySelectorAll('.notification-plugin-row').length!==1)throw Error('Plugin search failed');
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(search,'');search.dispatchEvent(new Event('input',{bubbles:true}));await wait();
|
||||
configureLanguage('en-US',[]);await refresh();await wait(180);
|
||||
if(!document.body.textContent!.includes('Launcher badge')||!document.body.textContent!.includes('App notifications'))throw Error('English settings copy missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_NOTIFICATION_SETTINGS_EN');
|
||||
for(const row of document.querySelectorAll('.notification-plugin-row'))if(row.scrollWidth>row.clientWidth)throw Error('Plugin switches overflow the settings row');
|
||||
switchMode('quick');configureLanguage('zh-CN',[]);await wait(180);
|
||||
if(control('允许通知').getAttribute('aria-checked')!=='false'||!document.body.textContent!.includes('悬浮已关闭'))throw Error('Quick popup switch out of sync');
|
||||
if(readCalls)throw Error('Hidden headset view marked notifications as read');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_NOTIFICATION_PERMISSION_ZH');
|
||||
control('允许通知').click();await until(()=>control('允许通知').getAttribute('aria-checked')==='true');
|
||||
if(!config.popups||config.badge)throw Error('Quick switch changed unrelated setting');
|
||||
(document.querySelector('[aria-label="通知设置"]') as HTMLButtonElement).click();await wait();
|
||||
if(!calls.some(c=>c.method==='host.manager.open'&&c.params.page==='notification-settings'))throw Error('Notification settings entry target missing');
|
||||
visible=true;await until(()=>readCalls>0);
|
||||
if(!messages[0].read||!document.querySelector('.inbox-message'))throw Error('Reading inbox removed message');
|
||||
root.unmount();history.replaceState(null,'','/manager#notification-settings');await import('../ui/src/main');
|
||||
await until(()=>!!document.querySelector('.notification-settings'));
|
||||
const nav=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='Notifications');
|
||||
if(!nav||nav.getAttribute('aria-current')!=='page')throw Error('Manager notification settings navigation missing');
|
||||
if(document.querySelectorAll('.notification-plugin-row').length!==3)throw Error('Manager notification settings omitted notification source');
|
||||
await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_NOTIFICATION_MANAGER_EN');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
root.render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,104 @@
|
||||
// Exercise the actual notification views and quick menu in CEF, including saved messages and expiry.
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {NotificationCenter,type NotificationEntry} from '../ui/src/notifications';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const plugin={manifest:{id:'capture.clip',name:'Clip capture',description:'Capture your session',version:'1.0.0',author:'Framely',ui:{windows:{}}},enabled:true,favorite:false,order:0};
|
||||
const plugins:Record<string,typeof plugin>={'capture.clip':plugin,'capture.off':{...plugin,enabled:false,order:1,manifest:{...plugin.manifest,id:'capture.off',name:'Disabled capture'}}};
|
||||
const entry=(id:string,extra:Partial<NotificationEntry>={}):NotificationEntry=>({plugin:'capture.clip',pluginName:'Clip capture',createdAt:Date.now(),expiresAt:Date.now()+8000,toast:true,inInbox:true,notification:{id,title:'录像已保存',body:'录像已经保存,可以打开文件或继续录制。',inbox:true,actions:[{id:'open',label:'打开录像'}]},...extra});
|
||||
let entries=[entry('saved'),entry('transient',{inInbox:false,notification:{id:'transient',title:'临时消息',body:'只显示浮动通知'}}),entry('update',{plugin:'',pluginName:'Framely',update:{version:'0.4.3-preview.1'},notification:{id:'update',title:'Update',body:'New release',actions:[{id:'ignore',label:'Ignore'},{id:'open',label:'Open updates'}]}})];
|
||||
let language='zh-CN',fail=false,calls:any[]=[],render:(inbox:boolean)=>void;
|
||||
const status=()=>({version:'0.4.2-preview.12',agreement:{accepted:true,version:'fixture'},database:{plugins,sources:[],safeMode:false,language},notifications:entries.filter(n=>n.toast),inbox:entries.filter(n=>n.inInbox),notificationsCount:entries.length,running:[]});
|
||||
window.fetch=async(_url,init)=>{
|
||||
const{method,params}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status')result=status();
|
||||
else if(method==='language.list')result={packs:[]};
|
||||
else if(method==='language.save'){language=params.language;calls.push({method,params});}
|
||||
else if(method==='host.manager.open'){calls.push({method,params});}
|
||||
else if(method==='ui.events')result={events:[],cursor:0};
|
||||
else if(method==='ui.visibility.get')result={known:false,pageVisible:false};
|
||||
else if(method==='notification.read'){
|
||||
for(const item of params.entries){const n=entries.find(n=>n.plugin===item.plugin&&n.notification.id===item.id&&n.createdAt===item.createdAt);if(n)n.read=true;}
|
||||
}
|
||||
else if(method.startsWith('notification.')){
|
||||
calls.push({method,params});
|
||||
const n=entries.find(n=>n.plugin===params.plugin&&n.notification.id===params.id);
|
||||
if(!n)throw Error('unknown notification');
|
||||
if(params.createdAt!==n.createdAt)throw Error('stale notification');
|
||||
if(fail)return new Response(JSON.stringify({error:'Fixture action failed'}));
|
||||
if(method==='notification.action'){
|
||||
const a=n.notification.actions!.find(a=>a.id===params.action)!;
|
||||
n.toast=a.closeOnClick===false?n.toast:false;n.inInbox=a.removeFromInboxOnClick===false?n.inInbox:false;
|
||||
if(n.update&&a.id==='open')result={openUpdates:true};
|
||||
}else if(method==='notification.dismiss')n.toast=false;
|
||||
else if(method==='notification.remove'){n.toast=false;n.inInbox=false;}
|
||||
}else if(!['host.haptic','host.keyboard','host.manager.open','host.menu.close'].includes(method))throw Error('unexpected API '+method);
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
function Fixture(){const[value,setValue]=useState(entries),[inbox,setInbox]=useState(true);render=inbox=>{setValue([...entries]);setInbox(inbox);};return <div className="app quick" style={{maxWidth:600,margin:'0 auto'}}><main className="content"><NotificationCenter items={value} inbox={inbox} plugins={plugins} refresh={async()=>setValue([...entries])}/></main></div>;}
|
||||
function button(label:string){const b=document.querySelector(`button[aria-label="${label}"]`);if(!b)throw Error('missing button '+label);return b as HTMLButtonElement;}
|
||||
async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;await wait();}throw Error('UI timeout');}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('zh-CN',[]);render(true);await wait();
|
||||
if(document.querySelectorAll('.inbox-message').length!==2||document.body.textContent!.includes('临时消息'))throw Error('non-inbox notification leaked into inbox');
|
||||
if(!document.body.textContent!.includes('录像已保存')||!document.body.textContent!.includes('打开更新'))throw Error('Chinese notification content missing');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_INBOX_ZH');
|
||||
fail=true;button('打开录像').click();await until(()=>!!document.querySelector('[role=alert]'));
|
||||
if(!entries[0].inInbox||!entries[0].toast)throw Error('failed action removed message');
|
||||
fail=false;button('打开录像').click();await until(()=>document.querySelectorAll('.inbox-message').length===1);
|
||||
if(entries[0].inInbox||entries[0].toast)throw Error('default action did not remove message');
|
||||
entries=[entry('keep',{notification:{id:'keep',title:'录制进行中',body:'查看状态不会关闭这条通知。',actions:[{id:'open',label:'查看状态',closeOnClick:false,removeFromInboxOnClick:false}]} })];
|
||||
render(false);await wait();button('查看状态').click();await wait();
|
||||
if(!entries[0].toast||!entries[0].inInbox||!document.querySelector('.toast'))throw Error('opt-out action dismissed message');
|
||||
if(!document.querySelector('[role=timer]')?.textContent?.includes('秒后关闭'))throw Error('countdown not shown');
|
||||
button('关闭通知').click();await until(()=>!document.querySelector('.toast'));
|
||||
if(!entries[0].inInbox)throw Error('floating close removed saved message');
|
||||
render(true);await wait();button('移除通知').click();await until(()=>!!document.querySelector('.inbox-empty'));
|
||||
entries=[entry('expiry',{expiresAt:Date.now()+350})];render(false);await wait(700);
|
||||
if(document.querySelector('.toast')||!entries[0].inInbox)throw Error('automatic close removed inbox copy');
|
||||
entries=[entry('persistent',{expiresAt:null,notification:{id:'persistent',title:'Persistent status',body:'Stays visible until dismissed',durationMs:0}})];render(false);await wait();
|
||||
if(!document.querySelector('.toast')||document.querySelector('[role=timer]'))throw Error('persistent notification has a countdown');
|
||||
entries=[entry('long',{notification:{id:'long',title:'A recording with a long title is ready to review',body:'Recording details. '.repeat(80),actions:[{id:'open',label:'Open recording'},{id:'copy',label:'Copy path'},{id:'continue',label:'Continue recording'}]}})];render(false);await wait();
|
||||
const compact=document.querySelector('.toast') as HTMLElement,message=compact.querySelector('.notification-message') as HTMLElement;
|
||||
if(compact.getBoundingClientRect().height>185||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup');
|
||||
for(const action of compact.querySelectorAll('.toast-actions button')){const bounds=action.getBoundingClientRect();if(bounds.height<44||bounds.bottom>compact.getBoundingClientRect().bottom)throw Error('compact notification action is clipped or too small');}
|
||||
entries=[entry('saved',{notification:{id:'saved',title:'Recording saved',body:'Your recording is ready. Open it whenever you are ready.',actions:[{id:'open',label:'Open recording'}]}}),entry('update',{plugin:'',pluginName:'Framely',update:{version:'0.4.3-preview.1'},notification:{id:'update',title:'Update',body:'New release',actions:[{id:'ignore',label:'Ignore'},{id:'open',label:'Open updates'}]}})];
|
||||
configureLanguage('en-US',[]);render(true);await wait(180);
|
||||
if(!document.body.textContent!.includes('Inbox')||!document.body.textContent!.includes('Open updates'))throw Error('English translation missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_INBOX_EN');
|
||||
render(false);await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_TOAST_EN');
|
||||
if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>185))throw Error('floating notification exceeds compact height');
|
||||
if([...document.querySelectorAll('.toast-actions button')].some(b=>b.scrollWidth>b.clientWidth))throw Error('notification action overflowed');
|
||||
// Mount the real quick menu and assert the navigation actually reaches the inbox.
|
||||
root.unmount();await import('../ui/src/main');
|
||||
await until(()=>!!document.querySelector('nav button'));
|
||||
const installed=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='已安装') as HTMLButtonElement;
|
||||
installed.click();await wait();
|
||||
if(document.querySelectorAll('.plugin-row').length!==1)throw Error('quick installed list includes disabled plugins');
|
||||
const notifications=[...document.querySelectorAll('nav button')].find(b=>b.textContent?.startsWith('通知')) as HTMLButtonElement;
|
||||
if(!notifications)throw Error('quick inbox tab missing');notifications.click();await wait(180);
|
||||
if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==4)throw Error('quick inbox navigation broken');
|
||||
if(!document.querySelector('[role=switch][aria-label="允许通知"]')||!document.querySelector('[aria-label="通知设置"]'))throw Error('quick notification controls missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_QUICK_INBOX');
|
||||
button('打开更新').click();await until(()=>!document.querySelector('.inbox-message')?.textContent?.includes('可更新'));
|
||||
if(!calls.some(c=>c.method==='notification.action'&&c.params.action==='open'&&c.params.plugin===''))throw Error('update action not dispatched');
|
||||
const settings=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='设置') as HTMLButtonElement;
|
||||
settings.click();await until(()=>!!document.querySelector('.quick-settings'));
|
||||
if(document.querySelector('.quick-settings input[type=file]')||document.querySelector('.quick-settings [download]'))throw Error('Quick settings exposes language installation or template download');
|
||||
const safe=document.querySelector('.setting-toggle') as HTMLElement,languageCard=document.querySelector('.language-settings') as HTMLElement;
|
||||
if(languageCard.getBoundingClientRect().top-safe.getBoundingClientRect().bottom<24)throw Error('Safe mode is too close to language divider');
|
||||
button('界面语言').click();await wait();
|
||||
const english=[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English') as HTMLButtonElement;
|
||||
if(!english)throw Error('Language picker has no English option');english.click();
|
||||
await until(()=>!!document.querySelector('[aria-label="Interface language"]'));
|
||||
if(!calls.some(c=>c.method==='language.save'&&c.params.language==='en-US'))throw Error('Quick language selection did not save');
|
||||
const update=document.querySelector('.quick-update-entry') as HTMLButtonElement;
|
||||
if(!update?.textContent?.includes('Check for updates'))throw Error('Quick update entry missing');update.click();await wait();
|
||||
if(!calls.some(c=>c.method==='host.manager.open'&&c.params.page==='updates'))throw Error('Quick update entry did not open update settings');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_QUICK_SETTINGS_EN');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
root.render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "plugin_navigation.h"
|
||||
#include <cassert>
|
||||
int main() {
|
||||
const std::string origin="http://localhost:19626";
|
||||
assert(allow_plugin_navigation(origin+"/window",origin,true,true));
|
||||
assert(allow_plugin_navigation("about:blank",origin,false,false));
|
||||
assert(allow_plugin_navigation("http://localhost:19629/framely-window/main",origin,false,true));
|
||||
assert(allow_plugin_navigation("http://localhost:19629/framely-window/login?ticket=test",origin,false,true));
|
||||
assert(!allow_plugin_navigation("http://localhost:19629/",origin,true,true));
|
||||
assert(!allow_plugin_navigation("http://localhost:19629/",origin,false,false));
|
||||
for (const auto& url : {"https://example.com/", "http://192.168.5.67:19629/", "http://localhost.evil:19629/", "http://localhost:19629@evil/", "http://localhost:19629\\@evil/", "http://localhost:0/", "http://localhost:65536/", "http://localhost:/", "http://localhost:999999999999999999999/"})
|
||||
assert(!allow_plugin_navigation(url,origin,false,true));
|
||||
}
|
||||
@@ -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,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,25 +8,34 @@ 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,automaticSaves=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));
|
||||
let result:any=true;
|
||||
if(method==='system.channel.save')status={...status,database:{...status.database,updateChannel:params.channel}};
|
||||
if(method==='system.check.settings.save'){automaticSaves++;status={...status,database:{...status.database,updateCheck:params.settings}};}
|
||||
else if(method==='system.channel.save')status={...status,database:{...status.database,updateChannel:params.channel}};
|
||||
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();}
|
||||
@@ -34,33 +43,75 @@ async function choose(label:string){(document.querySelector('.framely-select but
|
||||
try{
|
||||
configureLanguage('zh-CN',[]);updateStatus({...status});await wait();
|
||||
if(!document.querySelector('.framely-select button')?.textContent?.includes('正式版'))throw Error('legacy source did not default to stable');
|
||||
const automaticSwitch=()=>document.querySelector('.automatic-update-settings input[type=checkbox]') as HTMLInputElement;
|
||||
if(!automaticSwitch().checked)throw Error('automatic checks did not default to enabled');
|
||||
automaticSwitch().click();await until(()=>automaticSaves===1);await wait();
|
||||
if(status.database.updateCheck.enabled||(document.querySelector('.automatic-update-settings .framely-select button') as HTMLButtonElement).disabled===false)throw Error('disabled automatic check settings not reflected');
|
||||
automaticSwitch().click();await until(()=>automaticSaves===2);await wait();
|
||||
(document.querySelector('.automatic-update-settings .framely-select button') as HTMLButtonElement).click();await wait();
|
||||
([...document.querySelectorAll('[role=option]')].find(o=>o.textContent==='每 12 小时') as HTMLButtonElement).click();await until(()=>automaticSaves===3);await wait();
|
||||
if(status.database.updateCheck.intervalHours!==12)throw Error('automatic interval not saved');
|
||||
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('下载并安装').focus();button('下载并安装').click();await wait();
|
||||
const dialog=document.querySelector('.update-confirmation') as HTMLDialogElement|null;
|
||||
if(downloads!==0||attempts!==0||!dialog?.open||!dialog.matches(':modal'))throw Error('update bypassed modal confirmation');
|
||||
if(!dialog.getAttribute('aria-labelledby')||!dialog.getAttribute('aria-describedby')||document.activeElement!==button('取消'))throw Error('confirmation lacked accessible focus or description');
|
||||
const bounds=dialog.getBoundingClientRect();
|
||||
if(bounds.width<=0||bounds.height<=0||bounds.left<0||bounds.top<0||bounds.right>innerWidth||bounds.bottom>innerHeight)throw Error('confirmation outside viewport');
|
||||
button('检查更新').focus();
|
||||
if(!dialog.contains(document.activeElement))throw Error('background took focus during confirmation');
|
||||
button('取消').click();await wait();
|
||||
if(downloads!==0||attempts!==0||document.querySelector('.update-confirmation')||document.activeElement!==button('下载并安装'))throw Error('cancelled update performed work or did not restore focus');
|
||||
button('下载并安装').click();await wait();
|
||||
document.querySelector('.update-confirmation')!.dispatchEvent(new Event('cancel',{cancelable:true}));await wait();
|
||||
if(document.querySelector('.update-confirmation')||downloads!==0||attempts!==0)throw Error('dialog cancellation 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||!automaticSwitch().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('.update-confirmation'))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('.update-confirmation')?.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);}
|
||||
};
|
||||
|
||||
@@ -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,126 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = pathlib.Path(__file__).parents[1]
|
||||
|
||||
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
packager = load('packager', ROOT / 'tools/package-runtime.py')
|
||||
runtime = load('cef_runtime', ROOT / 'tools/cef-runtime.py')
|
||||
bootstrap = load('bootstrap', ROOT / 'tools/bootstrap.py')
|
||||
|
||||
|
||||
class CEFRuntimeTests(unittest.TestCase):
|
||||
def fixture(self, folder, version='1.0.0-build'):
|
||||
stage = folder / ('framely-' + version)
|
||||
files = {'VERSION': version, 'bin/framely': 'core', 'lib/cef/framely-vr': 'host',
|
||||
'lib/cef/libcef.so': 'browser', 'lib/cef/icudtl.dat': 'icu',
|
||||
'lib/cef/locales/en-US.pak': 'locale', 'share/licenses/cef.txt': 'license',
|
||||
'share/licenses/cef-credits.html': 'credits', 'share/licenses/openvr.txt': 'openvr',
|
||||
'install.sh': 'install', 'uninstall.sh': 'uninstall'}
|
||||
for name, data in files.items():
|
||||
p = stage / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(data)
|
||||
(stage / 'tools').mkdir()
|
||||
shutil.copyfile(ROOT / 'tools/bootstrap.py', stage / 'tools/bootstrap.py')
|
||||
cef = folder / 'cef'
|
||||
(cef / 'include').mkdir(parents=True)
|
||||
(cef / 'include/cef_version.h').write_text('#define CEF_VERSION "154.0.32+g682c378+chromium-154.0.8037.58"\n')
|
||||
packager.package(stage, cef, 'owner/repo', 'v1.0.0')
|
||||
return stage
|
||||
|
||||
def test_archives_checksums_and_action_selection(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = pathlib.Path(temp)
|
||||
stage = self.fixture(folder)
|
||||
archives = list(folder.glob('*.tar.gz'))
|
||||
self.assertEqual(len(archives), 3)
|
||||
for archive in archives:
|
||||
bootstrap.verify_archive(archive, folder / 'SHA256SUMS')
|
||||
assets = {p.name: {} for p in archives} | {'SHA256SUMS': {}, 'framely-cef.json': {}}
|
||||
self.assertEqual(bootstrap.select_package(assets, 'install'), stage.name + '-offline-linux-arm64.tar.gz')
|
||||
self.assertEqual(bootstrap.select_package(assets, 'update'), stage.name + '-linux-arm64.tar.gz')
|
||||
del assets[stage.name + '-offline-linux-arm64.tar.gz']
|
||||
with self.assertRaises(ValueError):
|
||||
bootstrap.select_package(assets, 'install')
|
||||
for archive in archives:
|
||||
extracted = bootstrap.extract_archive(archive, folder / archive.name.replace('.tar.gz', ''),
|
||||
runtime=archive.name.startswith('framely-cef-'))
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=extracted, check=True)
|
||||
if archive.name == stage.name + '-linux-arm64.tar.gz':
|
||||
self.assertFalse((extracted / 'lib/cef/libcef.so').exists())
|
||||
self.assertFalse((extracted / 'share/licenses/cef-credits.html').exists())
|
||||
self.assertTrue((extracted / 'lib/cef/framely-vr').exists())
|
||||
|
||||
def test_offline_install_core_update_and_legacy_migration_without_download(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = pathlib.Path(temp)
|
||||
stage = self.fixture(folder)
|
||||
store = folder / 'store'
|
||||
store.mkdir()
|
||||
cached = runtime.prepare(stage, store)
|
||||
core = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'core')
|
||||
self.assertEqual(runtime.prepare(core, store), cached)
|
||||
runtime.attach(core, cached)
|
||||
self.assertTrue((core / 'lib/cef/libcef.so').is_symlink())
|
||||
self.assertEqual((core / 'lib/cef/libcef.so').read_text(), 'browser')
|
||||
self.assertEqual((core / 'lib/cef/framely-vr').read_text(), 'host')
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=core, check=True)
|
||||
# A pre-split installation with the same files seeds the new cache.
|
||||
legacy_store = folder / 'legacy'
|
||||
(legacy_store / 'state').mkdir(parents=True)
|
||||
(legacy_store / 'state/current').symlink_to(stage)
|
||||
unattached = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'legacy-core')
|
||||
self.assertEqual(runtime.prepare(unattached, legacy_store).name, cached.name)
|
||||
(cached / 'lib/cef/libcef.so').write_text('tampered')
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare(core, store)
|
||||
|
||||
def test_download_matching_runtime_and_reject_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = pathlib.Path(temp)
|
||||
stage = self.fixture(folder)
|
||||
core = bootstrap.extract_archive(folder / (stage.name + '-linux-arm64.tar.gz'), folder / 'core')
|
||||
requirement = json.loads((core / 'CEF_RUNTIME.json').read_text())
|
||||
data = (folder / requirement['archive']).read_bytes()
|
||||
# The downloaded helper imports the shipped bootstrap module.
|
||||
for bad in (True, False):
|
||||
store = folder / ('bad-store' if bad else 'download-store')
|
||||
store.mkdir()
|
||||
response = io.BytesIO(data[:-1] if bad else data)
|
||||
with patch('urllib.request.OpenerDirector.open', return_value=response):
|
||||
if bad:
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare(core, store)
|
||||
self.assertFalse((store / 'cef' / requirement['id']).exists())
|
||||
else:
|
||||
cached = runtime.prepare(core, store)
|
||||
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
|
||||
|
||||
def test_runtime_extraction_rejects_links(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = pathlib.Path(temp)
|
||||
archive = folder / 'bad.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
entry = tarfile.TarInfo('framely-cef-1/lib/cef/libcef.so')
|
||||
entry.type = tarfile.SYMTYPE
|
||||
entry.linkname = '/etc/shadow'
|
||||
tar.addfile(entry)
|
||||
with self.assertRaises(ValueError):
|
||||
bootstrap.extract_archive(archive, folder / 'out', runtime=True)
|
||||
self.assertFalse((folder / 'out').exists())
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Exercise production install cleanup with all machine paths and tools mocked."""
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -13,7 +15,10 @@ class InstallRetry(unittest.TestCase):
|
||||
def test_fresh_install_and_system_update_repair(self):
|
||||
self.exercise_install(legacy=False)
|
||||
|
||||
def exercise_install(self, legacy):
|
||||
def test_split_runtime_install_repair_update_rollback_and_uninstall(self):
|
||||
self.exercise_install(legacy=False, split=True)
|
||||
|
||||
def exercise_install(self, legacy, split=False):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = pathlib.Path(tmp)
|
||||
state, store, etc = base / 'state', base / 'store', base / 'etc'
|
||||
@@ -30,6 +35,14 @@ class InstallRetry(unittest.TestCase):
|
||||
(package / 'VERSION').write_text('test-version\n')
|
||||
(package / 'SHA256SUMS').write_text('')
|
||||
(package / 'lib/cef/framely-vr').write_text('mock')
|
||||
if split:
|
||||
for name, content in [('lib/cef/libcef.so', 'browser'), ('share/licenses/cef.txt', 'license')]:
|
||||
path = package / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
(package / 'CEF_RUNTIME.json').write_text(json.dumps({'schemaVersion': 1, 'id': 'test-cef'}))
|
||||
(package / 'CEF_SHA256SUMS').write_text(''.join(hashlib.sha256((package / name).read_bytes()).hexdigest() + ' ' + name + '\n' for name in ['lib/cef/libcef.so', 'share/licenses/cef.txt']))
|
||||
(package / 'tools/cef-runtime.py').write_text((pathlib.Path(__file__).parents[1] / 'tools/cef-runtime.py').read_text())
|
||||
(package / 'bin/framely').write_text('#!/bin/sh\nexit 0\n')
|
||||
(package / 'bin/framely').chmod(0o755)
|
||||
repair_source = (pathlib.Path(__file__).parents[1] / 'packaging/repair.sh').read_text()
|
||||
@@ -85,6 +98,9 @@ class InstallRetry(unittest.TestCase):
|
||||
second = subprocess.run(['bash', str(script), 'steam'], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stdout + second.stderr)
|
||||
self.assertTrue((store / 'releases/test-version').is_dir())
|
||||
if split:
|
||||
self.assertTrue((store / 'releases/test-version/lib/cef/libcef.so').is_symlink())
|
||||
self.assertEqual((store / 'cef/test-cef/lib/cef/libcef.so').read_text(), 'browser')
|
||||
if not legacy:
|
||||
(state / 'state.json').write_text('{"settings":"preserved"}')
|
||||
(state / 'update-source.json').write_text('{"url":"https://example.org/update.json"}')
|
||||
@@ -125,6 +141,25 @@ class InstallRetry(unittest.TestCase):
|
||||
(state / 'state.json').unlink()
|
||||
state.rmdir()
|
||||
state.symlink_to(store / 'state')
|
||||
if split:
|
||||
# Upgrade using only the core payload; old and new releases retain
|
||||
# their own host executable while sharing one verified runtime.
|
||||
(package / 'lib/cef/libcef.so').unlink()
|
||||
(package / 'share/licenses/cef.txt').unlink()
|
||||
(package / 'VERSION').write_text('next-version\n')
|
||||
(package / 'lib/cef/framely-vr').write_text('new host')
|
||||
upgraded = subprocess.run(['bash', str(script), 'steam'], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(upgraded.returncode, 0, upgraded.stdout + upgraded.stderr)
|
||||
self.assertEqual((state / 'current/lib/cef/framely-vr').read_text(), 'new host')
|
||||
self.assertEqual((state / 'previous-release').read_text(), 'releases/test-version\n')
|
||||
self.assertEqual((state / 'current/lib/cef/libcef.so').read_text(), 'browser')
|
||||
rollback_source = (pathlib.Path(__file__).parents[1] / 'packaging/rollback.sh').read_text().replace('if [[ $EUID != 0 ]]; then exec sudo -- bash "$0" "$@"; fi', '').replace('/var/lib/framely', str(state))
|
||||
rollback_script = base / 'rollback.sh'
|
||||
rollback_script.write_text(rollback_source)
|
||||
rolled = subprocess.run(['bash', str(rollback_script)], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(rolled.returncode, 0, rolled.stdout + rolled.stderr)
|
||||
self.assertEqual((state / 'current/lib/cef/framely-vr').read_text(), 'mock')
|
||||
self.assertEqual((state / 'current/lib/cef/libcef.so').read_text(), 'browser')
|
||||
uninstall_source = (pathlib.Path(__file__).parents[1] / 'packaging/uninstall.sh').read_text()
|
||||
uninstall_source = uninstall_source.replace('if [[ $EUID != 0 ]]; then exec sudo -- bash "$0" "$@"; fi', '')
|
||||
uninstall_source = uninstall_source.replace('/etc/systemd/system', str(units)).replace('/var/lib/framely', str(state))
|
||||
@@ -142,6 +177,7 @@ class InstallRetry(unittest.TestCase):
|
||||
removed = subprocess.run(['bash', str(uninstall)], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(removed.returncode, 0, removed.stdout + removed.stderr)
|
||||
self.assertFalse((store / 'releases').exists())
|
||||
self.assertFalse((store / 'cef').exists())
|
||||
self.assertFalse((store / 'repair.sh').exists())
|
||||
self.assertEqual((store / 'state/state.json').read_text(), '{"settings":"preserved"}')
|
||||
self.assertEqual((store / 'data/plugin/payload').read_text(), 'plugin data')
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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':
|
||||
@@ -32,14 +40,22 @@ def fetch(url):
|
||||
urllib.request.Request(url, headers={'User-Agent': 'Framely-Installer', 'Accept': 'application/vnd.github+json'}), timeout=60)
|
||||
|
||||
|
||||
def download(url, destination, limit):
|
||||
def download(url, destination, limit, progress=None):
|
||||
with fetch(url) as response, open(destination, 'xb') as output:
|
||||
size = 0
|
||||
reported_at = time.monotonic()
|
||||
if progress:
|
||||
progress(0)
|
||||
while block := response.read(256 * 1024):
|
||||
size += len(block)
|
||||
if size > limit:
|
||||
raise ValueError('Download exceeds size limit')
|
||||
output.write(block)
|
||||
if progress and time.monotonic() - reported_at >= 0.2:
|
||||
progress(size)
|
||||
reported_at = time.monotonic()
|
||||
if progress:
|
||||
progress(size)
|
||||
|
||||
|
||||
def verify_archive(archive, checksums):
|
||||
@@ -67,7 +83,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, runtime=False):
|
||||
# Check all headers before writing, and never use tar's link extraction.
|
||||
with tarfile.open(archive, 'r:gz') as source:
|
||||
members = source.getmembers()
|
||||
@@ -88,6 +104,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,18 +114,38 @@ 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()
|
||||
if base.name != 'framely-' + version:
|
||||
if base.name != ('framely-cef-' if runtime else 'framely-') + version:
|
||||
raise ValueError('Release VERSION does not match directory')
|
||||
for required in ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely']:
|
||||
required_files = ['SHA256SUMS', 'lib/cef/libcef.so'] if runtime else ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely']
|
||||
for required in required_files:
|
||||
if not (base / required).is_file():
|
||||
raise ValueError('Missing release file: ' + required)
|
||||
if progress:
|
||||
progress('extract', completed, total)
|
||||
return base
|
||||
|
||||
|
||||
def select_package(assets, action):
|
||||
packages = [name for name in assets if re.fullmatch(r'framely-[0-9][a-zA-Z0-9.+-]*-linux-arm64\.tar\.gz', name)]
|
||||
offline = [name for name in packages if name.endswith('-offline-linux-arm64.tar.gz')]
|
||||
core = [name for name in packages if name not in offline]
|
||||
selected = offline if action == 'install' and offline else core
|
||||
if action == 'install' and not offline and 'framely-cef.json' in assets:
|
||||
raise ValueError('First installation requires the complete offline package')
|
||||
if len(selected) != 1 or 'SHA256SUMS' not in assets:
|
||||
raise ValueError('Release must contain one matching ARM64 package and SHA256SUMS')
|
||||
return selected[0]
|
||||
|
||||
|
||||
def current_release():
|
||||
state = pathlib.Path('/home/.framely/state')
|
||||
if not state.is_dir():
|
||||
@@ -131,6 +170,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 +183,23 @@ 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)
|
||||
if args.action == 'install' and (base / 'CEF_RUNTIME.json').exists() and not (base / 'lib/cef/libcef.so').is_file():
|
||||
raise ValueError('First installation requires the complete offline package with CEF')
|
||||
report('extract')
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True)
|
||||
else:
|
||||
base = current_release()
|
||||
report('configure')
|
||||
if args.action in ('install', 'update'):
|
||||
command = ['bash', str(base / 'install.sh')]
|
||||
try:
|
||||
@@ -162,7 +208,9 @@ def root_operation(args):
|
||||
same = False
|
||||
if same:
|
||||
command.append('--repair')
|
||||
subprocess.run(command + [args.user], check=True)
|
||||
subprocess.run(command + [args.user], check=True,
|
||||
env=dict(os.environ, FRAMELY_PROGRESS_JSON='1' if getattr(args, 'progress_json', False) else '0'))
|
||||
report('activate')
|
||||
# Preconfigure the official update source without requiring agreement acceptance.
|
||||
state = pathlib.Path('/home/.framely/state')
|
||||
config = state / 'update-source.json'
|
||||
@@ -183,6 +231,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 +247,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')
|
||||
@@ -230,15 +280,15 @@ def main(argv=None):
|
||||
raise ValueError('Release metadata too large')
|
||||
release = json.loads(metadata)
|
||||
assets = {asset['name']: asset for asset in release['assets']}
|
||||
packages = [name for name in assets if re.fullmatch(r'framely-[0-9][a-zA-Z0-9.+-]*-linux-arm64\.tar\.gz', name)]
|
||||
if len(packages) != 1 or 'SHA256SUMS' not in assets:
|
||||
raise ValueError('Release must contain one ARM64 package and SHA256SUMS')
|
||||
args.archive = pathlib.Path(folder) / packages[0]
|
||||
package = select_package(assets, args.action)
|
||||
args.archive = pathlib.Path(folder) / package
|
||||
args.checksums = pathlib.Path(folder) / 'SHA256SUMS'
|
||||
print('Downloading ' + packages[0], flush=True)
|
||||
download(assets[packages[0]]['browser_download_url'], args.archive, MAX_ARCHIVE)
|
||||
print('Downloading ' + package, flush=True)
|
||||
download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE)
|
||||
download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2)
|
||||
args.archive, args.checksums = args.archive.resolve(), args.checksums.resolve()
|
||||
if args.progress_json:
|
||||
emit_progress('verify')
|
||||
verify_archive(args.archive, args.checksums)
|
||||
print('Archive SHA256 verified.', flush=True)
|
||||
if os.geteuid() == 0:
|
||||
@@ -247,6 +297,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)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {Resvg} from '@resvg/resvg-js';
|
||||
const root = new URL('../', import.meta.url);
|
||||
const branding = new URL('assets/branding/', root);
|
||||
const app = await readFile(new URL('framely-app-icon.svg', branding));
|
||||
const mark = await readFile(new URL('framely-mark.svg', branding));
|
||||
const mark = await readFile(new URL('framely-mark.svg', branding), 'utf8');
|
||||
await writeFile(new URL('framely-mark-light.svg', branding), mark.replace('color="#17191c"', 'color="#93c5ed"'));
|
||||
const logo = await readFile(new URL('framely-logo.svg', branding), 'utf8');
|
||||
await writeFile(new URL('framely-logo-light.svg', branding), logo.replace('color="#17191c"', 'color="#93c5ed"'));
|
||||
const sizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
|
||||
@@ -39,7 +40,10 @@ await writeFile(new URL('framely.ico', branding), Buffer.concat([directory, ...i
|
||||
|
||||
// A small alpha mask is enough for the native pixel-art Dock renderer. This
|
||||
// keeps the same SVG geometry without adding a runtime SVG/PNG dependency.
|
||||
const rendered = new Resvg(mark, {fitTo:{mode:'width', value:48}}).render();
|
||||
// Keep the body centered while the left tail extends beyond it. Expand the
|
||||
// viewBox by 1 / 0.9 so the Dock glyph is 10% smaller than the standard mark.
|
||||
const dockMark = mark.replace('viewBox="0 0 24 24"', 'viewBox="-1.333333 -1.333333 26.666666 26.666666"');
|
||||
const rendered = new Resvg(dockMark, {fitTo:{mode:'width', value:48}}).render();
|
||||
const mask = Array.from({length:48}, (_, y) => ' ' + Array.from({length:48}, (_, x) => rendered.pixels[(y * 48 + x) * 4 + 3]).join(',')).join(',\n');
|
||||
await mkdir(new URL('native/', root), {recursive:true});
|
||||
await writeFile(new URL('native/brand_mask.h', root), `// Generated by tools/build-branding.mjs from assets/branding/framely-mark.svg.\n#pragma once\nnamespace framely_brand {\ninline constexpr unsigned char mask[48 * 48] = {\n${mask}\n};\n}\n`);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify, cache and attach the exact CEF runtime required by a release."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def verify(root, checksums):
|
||||
for line in checksums.splitlines():
|
||||
match = re.fullmatch(r'([a-f0-9]{64}) (.+)', line)
|
||||
if not match:
|
||||
raise ValueError('Invalid CEF checksums')
|
||||
path = pathlib.PurePosixPath(match[2])
|
||||
if path.is_absolute() or '..' in path.parts or '\\' in match[2] or not (
|
||||
match[2].startswith('lib/cef/') or match[2] in ('share/licenses/cef.txt', 'share/licenses/cef-credits.html')):
|
||||
raise ValueError('Invalid CEF payload path')
|
||||
with (root / path).open('rb') as source:
|
||||
if hashlib.file_digest(source, 'sha256').hexdigest() != match[1]:
|
||||
raise ValueError('CEF checksum mismatch: ' + match[2])
|
||||
if not any(line.endswith(' lib/cef/libcef.so') for line in checksums.splitlines()):
|
||||
raise ValueError('CEF library checksum is missing')
|
||||
|
||||
|
||||
def prepare(base, store):
|
||||
base, store = pathlib.Path(base), pathlib.Path(store)
|
||||
requirement = json.loads((base / 'CEF_RUNTIME.json').read_text())
|
||||
runtime_id = requirement['id']
|
||||
if requirement['schemaVersion'] != 1 or not re.fullmatch(r'[a-zA-Z0-9.+-]+', runtime_id) or '..' in runtime_id:
|
||||
raise ValueError('Invalid CEF runtime identity')
|
||||
expected = (base / 'CEF_SHA256SUMS').read_text()
|
||||
parent = store / 'cef'
|
||||
if parent.is_symlink():
|
||||
raise ValueError('CEF storage must not be a symlink')
|
||||
parent.mkdir(mode=0o755, exist_ok=True)
|
||||
cached = parent / runtime_id
|
||||
if cached.exists() or cached.is_symlink():
|
||||
if cached.is_symlink() or not cached.is_dir():
|
||||
raise ValueError('Invalid CEF cache directory')
|
||||
verify(cached, expected)
|
||||
return cached
|
||||
with tempfile.TemporaryDirectory(prefix='.install-', dir=parent) as work:
|
||||
work = pathlib.Path(work)
|
||||
payload = work / 'payload'
|
||||
# Older full installations can seed the cache without a network request.
|
||||
for candidate in (base, store / 'state/current'):
|
||||
try:
|
||||
verify(candidate, expected)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
payload.mkdir()
|
||||
for line in expected.splitlines():
|
||||
relative = line[66:]
|
||||
target = payload / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(candidate / relative, target)
|
||||
target.chmod(0o755 if relative.startswith('lib/cef/') and (candidate / relative).stat().st_mode & 0o111 else 0o644)
|
||||
break
|
||||
else:
|
||||
spec = importlib.util.spec_from_file_location('bootstrap', base / 'tools/bootstrap.py')
|
||||
engine = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(engine)
|
||||
archive_name = f'framely-cef-{runtime_id}-linux-arm64.tar.gz'
|
||||
if requirement['archive'] != archive_name:
|
||||
raise ValueError('Invalid CEF archive name')
|
||||
archive = work / archive_name
|
||||
print('Downloading CEF ' + requirement['version'], file=sys.stderr, flush=True)
|
||||
def progress(completed):
|
||||
if os.environ.get('FRAMELY_PROGRESS_JSON') == '1':
|
||||
print('FRAMELY_PROGRESS ' + json.dumps(dict(step='cef-download', completed=completed, total=requirement['size'])), file=sys.stderr, flush=True)
|
||||
engine.download(requirement['url'], archive, engine.MAX_ARCHIVE, progress)
|
||||
if archive.stat().st_size != requirement['size']:
|
||||
raise ValueError('CEF archive length mismatch')
|
||||
with archive.open('rb') as source:
|
||||
if hashlib.file_digest(source, 'sha256').hexdigest() != requirement['sha256']:
|
||||
raise ValueError('CEF archive SHA256 mismatch')
|
||||
# The same link-free extraction policy protects standalone runtimes.
|
||||
payload = engine.extract_archive(archive, work / 'unpacked', runtime=True)
|
||||
if (payload / 'VERSION').read_text().strip() != runtime_id:
|
||||
raise ValueError('CEF archive version mismatch')
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=payload, check=True)
|
||||
verify(payload, expected)
|
||||
for directory, _, _ in os.walk(payload):
|
||||
pathlib.Path(directory).chmod(0o755)
|
||||
payload.rename(cached)
|
||||
return cached
|
||||
|
||||
|
||||
def attach(release, cached):
|
||||
release, cached = pathlib.Path(release), pathlib.Path(cached)
|
||||
for folder in ('lib/cef', 'share/licenses'):
|
||||
target_dir = release / folder
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for source in (cached / folder).iterdir():
|
||||
target = target_dir / source.name
|
||||
if target.is_symlink() or target.is_file():
|
||||
target.unlink()
|
||||
elif target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
target.symlink_to(source)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
if sys.argv[1] == 'prepare':
|
||||
print(prepare(*sys.argv[2:]))
|
||||
elif sys.argv[1] == 'attach':
|
||||
attach(*sys.argv[2:])
|
||||
else:
|
||||
raise ValueError('Invalid CEF operation')
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print('Framely CEF: ' + str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -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')
|
||||
|
||||
@@ -26,6 +26,7 @@ cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
|
||||
cp -a ui/dist/. "$stage/share/ui/"
|
||||
cp "$cef/LICENSE.txt" "$stage/share/licenses/cef.txt"
|
||||
cp native/vendor/openvr/LICENSE "$stage/share/licenses/openvr.txt"
|
||||
cp node_modules/@tabler/icons-react/LICENSE "$stage/share/licenses/tabler-icons.txt"
|
||||
[[ ! -f $cef/CREDITS.html ]] || cp "$cef/CREDITS.html" "$stage/share/licenses/cef-credits.html"
|
||||
cp native/vendor/json.hpp "$stage/share/licenses/nlohmann-json.hpp"
|
||||
cp packaging/*.sh "$stage/"
|
||||
@@ -43,13 +44,10 @@ cp -a assets/branding "$stage/tools/source/assets/"
|
||||
cp -a examples/showcase "$stage/tools/source/examples/"
|
||||
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
|
||||
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py "$stage/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py "$stage/tools/"
|
||||
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
||||
cp -a docs "$stage/share/"
|
||||
printf '%s\n' "$build_id" > "$stage/VERSION"
|
||||
chmod 755 "$stage"/*.sh "$stage/bin/"* "$stage/lib/cef/framely-vr"
|
||||
(cd "$stage" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
|
||||
tar -C "$base/release" -czf "$base/release/framely-$build_id-linux-arm64.tar.gz" "framely-$build_id"
|
||||
(cd "$base/release" && sha256sum "framely-$build_id-linux-arm64.tar.gz" > "framely-$build_id-linux-arm64.tar.gz.sha256")
|
||||
(cd "$base/release" && cp "framely-$build_id-linux-arm64.tar.gz.sha256" SHA256SUMS)
|
||||
python3 tools/package-runtime.py "$stage" "$cef" "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" "${RELEASE_TAG:-v$version}"
|
||||
echo "Package: release/framely-$build_id-linux-arm64.tar.gz"
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Produce core, offline and immutable CEF archives from a built release."""
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:
|
||||
return hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def checksums(root):
|
||||
return ''.join(f'{digest(p)} {p.relative_to(root).as_posix()}\n'
|
||||
for p in sorted(root.rglob('*')) if p.is_file() and p.name != 'SHA256SUMS')
|
||||
|
||||
|
||||
def archive(root, output, prefix=None):
|
||||
with tarfile.open(output, 'w:gz') as tar:
|
||||
tar.add(root, arcname=prefix or root.name)
|
||||
output.with_name(output.name + '.sha256').write_text(f'{digest(output)} {output.name}\n')
|
||||
|
||||
|
||||
def package(stage, cef, repo, tag):
|
||||
stage, cef = pathlib.Path(stage), pathlib.Path(cef)
|
||||
output = stage.parent
|
||||
version = re.search(r'^#define CEF_VERSION "([^"]+)"',
|
||||
(cef / 'include/cef_version.h').read_text(), re.M)[1]
|
||||
with tempfile.TemporaryDirectory(prefix='.cef-package-', dir=output) as work:
|
||||
runtime = pathlib.Path(work) / 'runtime'
|
||||
(runtime / 'lib/cef').mkdir(parents=True)
|
||||
(runtime / 'share/licenses').mkdir(parents=True)
|
||||
for source in (stage / 'lib/cef').iterdir():
|
||||
if source.name != 'framely-vr':
|
||||
target = runtime / 'lib/cef' / source.name
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
for source in (stage / 'share/licenses').glob('cef*'):
|
||||
shutil.copy2(source, runtime / 'share/licenses' / source.name)
|
||||
payload_sums = checksums(runtime)
|
||||
runtime_id = version.split('+chromium')[0].replace('+', '-') + '-' + hashlib.sha256(payload_sums.encode()).hexdigest()[:12]
|
||||
(runtime / 'VERSION').write_text(runtime_id + '\n')
|
||||
(runtime / 'SHA256SUMS').write_text(checksums(runtime))
|
||||
cef_archive = output / f'framely-cef-{runtime_id}-linux-arm64.tar.gz'
|
||||
archive(runtime, cef_archive, 'framely-cef-' + runtime_id)
|
||||
requirement = dict(schemaVersion=1, id=runtime_id, version=version,
|
||||
archive=cef_archive.name, size=cef_archive.stat().st_size,
|
||||
sha256=digest(cef_archive),
|
||||
url=f'https://github.com/{repo}/releases/download/{tag}/{cef_archive.name}')
|
||||
(stage / 'CEF_RUNTIME.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
(stage / 'CEF_SHA256SUMS').write_text(payload_sums)
|
||||
(output / 'framely-cef.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
(stage / 'SHA256SUMS').write_text(checksums(stage))
|
||||
archive(stage, output / f'{stage.name}-offline-linux-arm64.tar.gz')
|
||||
core = pathlib.Path(work) / 'core'
|
||||
shutil.copytree(stage, core, ignore=lambda directory, names:
|
||||
[name for name in names if pathlib.Path(directory) == stage / 'lib/cef' and name != 'framely-vr'
|
||||
or pathlib.Path(directory) == stage / 'share/licenses' and name.startswith('cef')])
|
||||
(core / 'SHA256SUMS').write_text(checksums(core))
|
||||
archive(core, output / f'{stage.name}-linux-arm64.tar.gz', stage.name)
|
||||
outputs = [output / f'{stage.name}-linux-arm64.tar.gz', output / f'{stage.name}-offline-linux-arm64.tar.gz', cef_archive, output / 'framely-cef.json']
|
||||
(output / 'SHA256SUMS').write_text(''.join(f'{digest(path)} {path.name}\n' for path in outputs))
|
||||
print(f'Core: {stage.name}-linux-arm64.tar.gz')
|
||||
print(f'Offline: {stage.name}-offline-linux-arm64.tar.gz')
|
||||
print(f'CEF: {cef_archive.name}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
package(*sys.argv[1:])
|
||||
@@ -28,7 +28,7 @@ if(command==='init'){
|
||||
const ctx=await context(options);await ctx.watch();
|
||||
const server=http.createServer(async(req,res)=>{try{
|
||||
if(req.url==='/__framely/events'){res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache'});res.write('data: connected\n\n');clients.add(res);req.on('close',()=>clients.delete(res));return;}
|
||||
if(req.url==='/'||req.url==='/quick'||req.url?.startsWith('/window/')||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html');res.end(`<!doctype html><meta charset="utf-8"><div id="root"></div><script>window.__framelyBridge={request:async(op,p)=>{if(op==='window.open'){window.open('/?window='+encodeURIComponent(p.window),'_blank');return true;}if(op==='window.close'){window.close();return true;}if(op==='notification.send'){alert(p.notification.title+'\\n'+p.notification.body);return true;}if(op==='dependencies')return [];if(op==='notification.remove'||op==='haptic'||op==='keyboard')return true;throw Error('浏览器预览不启动后端,请安装到 Frame 测试此调用。');},subscribe:()=>()=>{}};new EventSource('/__framely/events').onmessage=e=>{if(e.data==='reload')location.reload()};history.replaceState(null,'',location.pathname.startsWith('/window/')?location.pathname:location.search.includes('window=')?'/window/'+new URLSearchParams(location.search).get('window'):'/quick');</script><script src="/page.js"></script>`);return;}
|
||||
if(req.url==='/'||req.url==='/quick'||req.url?.startsWith('/window/')||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html');res.end(`<!doctype html><meta charset="utf-8"><div id="root"></div><script>window.__framelyBridge={request:async(op,p)=>{if(op==='window.open'){window.open('/?window='+encodeURIComponent(p.window),'_blank');return true;}if(op==='window.close'){window.close();return true;}if(op==='notification.send'){alert(p.notification.title+'\\n'+p.notification.body);return true;}if(op==='dependencies')return [];if(op==='notification.remove'||op==='notification.dismiss'||op==='haptic'||op==='keyboard')return true;throw Error('浏览器预览不启动后端,请安装到 Frame 测试此调用。');},subscribe:()=>()=>{}};new EventSource('/__framely/events').onmessage=e=>{if(e.data==='reload')location.reload()};history.replaceState(null,'',location.pathname.startsWith('/window/')?location.pathname:location.search.includes('window=')?'/window/'+new URLSearchParams(location.search).get('window'):'/quick');</script><script src="/page.js"></script>`);return;}
|
||||
const name=req.url?.split('?')[0];if(name!=='/page.js'&&name!=='/page.js.map'){res.writeHead(404);res.end();return;}res.setHeader('Content-Type',name.endsWith('.map')?'application/json':'application/javascript');res.end(await readFile(path.join(root,'payload',name.slice(1))));
|
||||
}catch(e){res.writeHead(500);res.end(String(e));}});
|
||||
server.listen(Number(process.env.FRAMELY_DEV_PORT??5173),'127.0.0.1',()=>console.log(`Preview: http://127.0.0.1:${server.address().port} (no device privileges)`));
|
||||
|
||||
@@ -16,6 +16,8 @@ const out=process.env.FRAMELY_INSTALL_REVIEW_OUTPUT;
|
||||
await build({entryPoints:[process.env.FRAMELY_INSTALL_REVIEW_FIXTURE],bundle:true,outfile:`${out}/fixture.js`,define:{'process.env.NODE_ENV':'"production"'}});
|
||||
await writeFile(`${out}/index.html`,'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="fixture.css"><div id="root"></div><script src="fixture.js"></script></html>');
|
||||
JS
|
||||
mkdir -p "$out/assets"
|
||||
cp -a assets/branding "$out/assets/branding"
|
||||
python3 - "$out" > "$out/server.log" 2>&1 <<'PY' &
|
||||
import functools,http.server,pathlib,sys
|
||||
root=pathlib.Path(sys.argv[1])
|
||||
|
||||