mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 05:01:23 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c59fab881a | ||
|
|
4108d4e5be | ||
|
|
1c6e36e63b | ||
|
|
799433e041 | ||
|
|
e02fde75f4 | ||
|
|
c4f672cba7 |
No files matched your search
Generated
+1
-1
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.9"
|
||||
version = "0.4.3-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.9"
|
||||
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:
|
||||
|
||||
@@ -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 终端安装最新正式版:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,3 +142,7 @@ Development preview simulates windows, notifications and some bridge operations.
|
||||
`framely.ui.getVisibility()` returns `{ known, captureObscured, pageVisible, sequence, sessionId }`. `framely.ui.onVisibilityChanged(callback, onError?)` subscribes and immediately queries the current snapshot; it returns an unsubscribe function. `useVisibility()` provides the same fields and `error`. Capture visibility combines the actual visibility of the quick panel, manager and every plugin window, excluding notifications and dock icons. Remote browser pages have `pageVisible: false` and cannot report headset visibility.
|
||||
|
||||
Declare `backend.uiVisibilityEvents: true` to receive the reserved JSON-line RPC `framely.ui.visibility` at startup and on changes, independently of mounted pages. Reply with the request id. The backend snapshot includes `known`, `captureObscured`, `sequence` and `views`; `pageVisible` is frontend-only. A native heartbeat missing for more than two seconds makes `known` false. Capture backends should pause on `!known || captureObscured`. Existing backends do not receive this RPC without opting in. Node/TS can use `registerVisibility` from `@framely/sdk/visibility`; Python supports `serve(dispatch, visibility=callback)`.
|
||||
|
||||
`framely.ui.close()` closes the current native surface: the quick panel or the current plugin window. It does not stop the backend or recording. Requires a host with this bridge method.
|
||||
|
||||
Standalone plugin windows display the plugin page without a host title bar. Plugins provide their own title, navigation and close button using `framely.ui.close()` or `framely.windows.close(id)`. Quick panels retain host navigation.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
+6
-6
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.9`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.7`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.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,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.4.2-preview.9
|
||||
git push origin v0.4.2-preview.9
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.7
|
||||
git push origin installer-v0.4.1-preview.7
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
```
|
||||
|
||||
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
|
||||
|
||||
@@ -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, plugin library, 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.
|
||||
@@ -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
|
||||
|
||||
@@ -124,3 +149,7 @@ const off = framely.ui.onVisibilityChanged(state => {
|
||||
});
|
||||
// 页面卸载时调用 off()
|
||||
```
|
||||
|
||||
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
|
||||
|
||||
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
|
||||
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.9`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.7`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.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 应用版本使用安装器版本。
|
||||
|
||||
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.9
|
||||
git push origin v0.4.2-preview.9
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.7
|
||||
git push origin installer-v0.4.1-preview.7
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
@@ -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 小时检查一次;面板关闭时也会继续检查。可在管理窗口“关于”的更新页关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
|
||||
|
||||
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
|
||||
@@ -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});
|
||||
Generated
+14
-1
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.7"
|
||||
version = "0.4.1-preview.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -2003,6 +2003,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"url",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -6549,6 +6550,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socks"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -7450,6 +7462,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socks",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.7"
|
||||
version = "0.4.1-preview.8"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
ureq = { version = "2.12", features = ["json"] }
|
||||
ureq = { version = "2.12", features = ["json", "socks-proxy"] }
|
||||
url = "2"
|
||||
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||
if-addrs = "0.13"
|
||||
@@ -33,6 +33,9 @@ tempfile = "3"
|
||||
zeroize = "1"
|
||||
directories = "6"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "=0.55.0"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "=0.1.31"
|
||||
|
||||
|
||||
+5
-1
@@ -34,6 +34,8 @@ Scanning, downloads, and SSH work run on background threads, with progress and l
|
||||
|
||||
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
|
||||
|
||||
Release queries, checksum files and package downloads automatically use the computer's manual system proxy: Windows Internet Settings, macOS network settings, or Linux GNOME/KDE settings. `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (or their lowercase forms) override desktop settings; `NO_PROXY` and desktop bypass rules are respected, including on redirects. HTTP CONNECT and SOCKS proxies are supported. PAC scripts and IPv6 proxy endpoints are currently unsupported; use a manual proxy with a hostname or IPv4 address. SSH/SFTP and downloads initiated by Frame use the device connection and network rather than the computer's HTTP proxy.
|
||||
|
||||
## Build
|
||||
|
||||
Linux requires C/C++ build tools, CMake, pkg-config, and development libraries for Fontconfig/Freetype, X11/XCB, XKBCommon, Wayland, and Vulkan. macOS requires Xcode command-line tools. Windows requires MSVC, CMake, and Perl for built-in OpenSSL. GPUI Kit is pinned to 0.7.0; matching dependencies are locked in `Cargo.lock`.
|
||||
@@ -60,12 +62,14 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
|
||||
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.7`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.8`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
|
||||
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
|
||||
|
||||
The titlebar and application icons share the pixel logo. Windows builds embed the ICO in the EXE; macOS packaging uses the system `iconutil` to generate and configure `.icns`. On Linux, run `bash install-desktop-entry.sh` from the extracted archive to register an icon-bearing entry in the current user's application menu. The entry points to that directory; rerun the script after moving it.
|
||||
|
||||
Installer archives do not include a separate `assets` directory. The UI logo is embedded in the executable; the Linux registration script embeds its menu icon, while macOS keeps its application icon inside the `.app` bundle.
|
||||
|
||||
Automated tests and Actions builds validate software and artifacts. Discovery, login, permissions, update, rollback, and uninstall still need acceptance testing on actual computers and Frame hardware.
|
||||
|
||||
Discovery queries `frame.local` mDNS address records directly. It does not require an advertised SSH service or system DNS support for `.local`. Run the same discovery logic independently for diagnosis:
|
||||
|
||||
@@ -34,6 +34,8 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
|
||||
在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。
|
||||
|
||||
版本查询、校验文件和压缩包下载自动读取电脑的手动系统代理:Windows Internet 设置、macOS 网络设置及 Linux GNOME/KDE 设置。`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`(也支持小写)优先于桌面设置;遵循 `NO_PROXY` 及桌面绕过规则,重定向后也会重新判断。支持 HTTP CONNECT 和 SOCKS 代理。暂不支持 PAC 脚本及 IPv6 代理地址,可使用主机名或 IPv4 地址的手动代理。SSH/SFTP 使用设备连接,Frame 主动下载使用设备网络,不使用电脑的 HTTP 代理。
|
||||
|
||||
## 构建
|
||||
|
||||
Linux 需要 C/C++ 构建工具、CMake、pkg-config、Fontconfig/Freetype、X11/XCB、XKBCommon、Wayland 和 Vulkan 开发库。macOS 需要 Xcode 命令行工具;Windows 需要 MSVC、CMake 和 Perl(用于内置 OpenSSL)。GPUI Kit 固定为 0.7.0,配套依赖由 `Cargo.lock` 锁定。
|
||||
@@ -60,12 +62,14 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
|
||||
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.7`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.8`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
|
||||
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
|
||||
|
||||
安装器标题栏和应用图标使用统一的像素 Logo。Windows 构建将 ICO 嵌入 EXE,macOS 打包使用系统 `iconutil` 生成并配置 `.icns`。Linux 解压后可在解压目录执行 `bash install-desktop-entry.sh`,把带图标的入口注册到当前用户的应用菜单;入口指向该目录,移动后需重新执行脚本。
|
||||
|
||||
安装器压缩包不包含独立的 `assets` 目录。界面 Logo 内嵌在可执行文件中,Linux 注册脚本内嵌菜单图标,macOS 的应用图标保存在 `.app` 内部。
|
||||
|
||||
所有自动测试和 Actions 构建只能验证软件及产物;仍需在真实电脑和 Frame 上完成扫描、登录、权限、升级回滚及卸载验收。
|
||||
|
||||
扫描会直接查询 `frame.local` 的 mDNS 地址记录,不要求设备广播 SSH 服务,也不依赖电脑的系统 DNS 支持 `.local`。可单独运行与界面相同的发现逻辑进行诊断:
|
||||
|
||||
@@ -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 ('"', '`', '$'):
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod discovery;
|
||||
pub mod instance;
|
||||
pub mod maintenance;
|
||||
pub mod progress;
|
||||
pub mod proxy;
|
||||
pub mod release;
|
||||
pub mod remote;
|
||||
pub const DEFAULT_REPO: &str = "SteamFramelyHomebrew/framely";
|
||||
|
||||
@@ -615,8 +615,7 @@ impl Installer {
|
||||
(None, None)
|
||||
};
|
||||
if let (Some(archive), Some(sums)) = (&archive, &sums) {
|
||||
progress(Progress::new(Stage::Verify, "校验本地安装包 SHA256"));
|
||||
release::verify(archive, sums)?;
|
||||
release::verify_with_progress(archive, sums, &mut progress)?;
|
||||
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
|
||||
}
|
||||
remote::operate(
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Read proxy settings without changing the computer or the SSH device.
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use std::{collections::HashMap, net::IpAddr};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Settings {
|
||||
proxies: HashMap<String, String>,
|
||||
bypass: Vec<String>,
|
||||
exclude_simple: bool,
|
||||
reverse_bypass: bool,
|
||||
}
|
||||
impl Settings {
|
||||
fn select(&self, url: &Url) -> Option<&str> {
|
||||
let host = url.host_str()?;
|
||||
let excluded = self.exclude_simple && !host.contains('.')
|
||||
|| self
|
||||
.bypass
|
||||
.iter()
|
||||
.any(|pattern| bypass_matches(pattern, host, url.port_or_known_default()));
|
||||
if excluded != self.reverse_bypass {
|
||||
return None;
|
||||
}
|
||||
self.proxies
|
||||
.get(url.scheme())
|
||||
.or_else(|| self.proxies.get("*"))
|
||||
.map(String::as_str)
|
||||
}
|
||||
}
|
||||
fn env_settings(mut get: impl FnMut(&str) -> Option<String>) -> Option<Settings> {
|
||||
let mut settings = Settings::default();
|
||||
for (scheme, lower, upper) in [
|
||||
("https", "https_proxy", "HTTPS_PROXY"),
|
||||
("http", "http_proxy", "HTTP_PROXY"),
|
||||
("*", "all_proxy", "ALL_PROXY"),
|
||||
] {
|
||||
if let Some(value) = get(lower).or_else(|| get(upper)) {
|
||||
settings.proxies.insert(scheme.into(), value);
|
||||
}
|
||||
}
|
||||
(!settings.proxies.is_empty()).then_some(settings)
|
||||
}
|
||||
pub fn for_url(url: &Url) -> Result<Option<ureq::Proxy>> {
|
||||
if let Ok(bypass) = std::env::var("no_proxy").or_else(|_| std::env::var("NO_PROXY")) {
|
||||
if split_bypass(&bypass).iter().any(|pattern| {
|
||||
bypass_matches(
|
||||
pattern,
|
||||
url.host_str().unwrap_or(""),
|
||||
url.port_or_known_default(),
|
||||
)
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let settings = match env_settings(|key| std::env::var(key).ok()) {
|
||||
Some(settings) => settings,
|
||||
None => system_settings()?.unwrap_or_default(),
|
||||
};
|
||||
settings
|
||||
.select(url)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(parse_proxy)
|
||||
.transpose()
|
||||
}
|
||||
fn parse_proxy(value: &str) -> Result<ureq::Proxy> {
|
||||
let value = value.trim();
|
||||
let value = if value.contains("://") {
|
||||
value.to_owned()
|
||||
} else {
|
||||
format!("http://{value}")
|
||||
};
|
||||
let value = value
|
||||
.strip_prefix("socks5h://")
|
||||
.map(|rest| format!("socks5://{rest}"))
|
||||
.unwrap_or(value);
|
||||
let parsed = Url::parse(&value)?;
|
||||
ensure!(
|
||||
matches!(
|
||||
parsed.scheme(),
|
||||
"http" | "socks" | "socks4" | "socks4a" | "socks5"
|
||||
) && parsed.host_str().is_some(),
|
||||
"系统代理地址无效或协议不受支持"
|
||||
);
|
||||
ensure!(
|
||||
!matches!(parsed.host(), Some(url::Host::Ipv6(_))),
|
||||
"当前下载客户端暂不支持 IPv6 代理地址,请使用主机名或 IPv4 地址"
|
||||
);
|
||||
ureq::Proxy::new(value).map_err(|_| anyhow::anyhow!("系统代理地址无效"))
|
||||
}
|
||||
fn split_bypass(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split([',', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
fn bypass_matches(pattern: &str, host: &str, port: Option<u16>) -> bool {
|
||||
let pattern = pattern.trim().to_ascii_lowercase();
|
||||
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
|
||||
if pattern == "<local>" {
|
||||
return !host.contains('.');
|
||||
}
|
||||
if let Some((network, bits)) = pattern.split_once('/') {
|
||||
if let (Ok(network), Ok(address), Ok(bits)) = (
|
||||
network.parse::<IpAddr>(),
|
||||
host.parse::<IpAddr>(),
|
||||
bits.parse::<u32>(),
|
||||
) {
|
||||
return match (network, address) {
|
||||
(IpAddr::V4(network), IpAddr::V4(address)) if bits <= 32 => {
|
||||
bits == 0
|
||||
|| u32::from(network) >> (32 - bits) == u32::from(address) >> (32 - bits)
|
||||
}
|
||||
(IpAddr::V6(network), IpAddr::V6(address)) if bits <= 128 => {
|
||||
bits == 0
|
||||
|| u128::from(network) >> (128 - bits)
|
||||
== u128::from(address) >> (128 - bits)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
let (pattern, required_port) = match pattern.rsplit_once(':') {
|
||||
Some((name, number)) if !name.contains(':') => (name, number.parse::<u16>().ok()),
|
||||
_ => (pattern.as_str(), None),
|
||||
};
|
||||
if required_port.is_some() && required_port != port {
|
||||
return false;
|
||||
}
|
||||
let domain = pattern.trim_start_matches('.');
|
||||
if !pattern.contains('*') {
|
||||
return host == domain || host.ends_with(&format!(".{domain}"));
|
||||
}
|
||||
// Match '*' throughout the hostname, including Windows IP wildcards.
|
||||
let (pattern, host) = (pattern.as_bytes(), host.as_bytes());
|
||||
let (mut p, mut h, mut star, mut resume) = (0, 0, None, 0);
|
||||
while h < host.len() {
|
||||
if p < pattern.len() && pattern[p] == host[h] {
|
||||
p += 1;
|
||||
h += 1;
|
||||
} else if p < pattern.len() && pattern[p] == b'*' {
|
||||
star = Some(p);
|
||||
p += 1;
|
||||
resume = h;
|
||||
} else if let Some(index) = star {
|
||||
p = index + 1;
|
||||
resume += 1;
|
||||
h = resume;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while p < pattern.len() && pattern[p] == b'*' {
|
||||
p += 1;
|
||||
}
|
||||
p == pattern.len()
|
||||
}
|
||||
const PAC_ERROR: &str = "暂不支持 PAC 自动代理脚本,请配置手动系统代理或 HTTPS_PROXY";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn system_settings() -> Result<Option<Settings>> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, KEY_READ},
|
||||
};
|
||||
let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings",
|
||||
KEY_READ,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if key.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 1 {
|
||||
if key
|
||||
.get_value::<String, _>("AutoConfigURL")
|
||||
.is_ok_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
bail!(PAC_ERROR);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(server) = key.get_value::<String, _>("ProxyServer") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut settings = parse_windows_servers(&server);
|
||||
settings.bypass = split_bypass(
|
||||
&key.get_value::<String, _>("ProxyOverride")
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
settings.exclude_simple = settings.bypass.iter().any(|value| value == "<local>");
|
||||
Ok(Some(settings))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
fn system_settings() -> Result<Option<Settings>> {
|
||||
let output = std::process::Command::new("/usr/sbin/scutil")
|
||||
.arg("--proxy")
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
parse_macos(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn parse_macos(text: &str) -> Result<Option<Settings>> {
|
||||
let mut values = HashMap::new();
|
||||
let mut settings = Settings::default();
|
||||
let (mut depth, mut exceptions) = (0usize, false);
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line == "}" {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 1 {
|
||||
exceptions = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if depth == 1 {
|
||||
if let Some((key, value)) = line.split_once(" : ") {
|
||||
if key == "ExceptionsList" {
|
||||
exceptions = true;
|
||||
} else {
|
||||
values.insert(key, value);
|
||||
}
|
||||
}
|
||||
} else if depth == 2 && exceptions {
|
||||
if let Some((_, value)) = line.split_once(" : ") {
|
||||
settings.bypass.push(value.into());
|
||||
}
|
||||
}
|
||||
if line.ends_with('{') {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
for (scheme, name, protocol) in [
|
||||
("http", "HTTP", "http"),
|
||||
("https", "HTTPS", "http"),
|
||||
("*", "SOCKS", "socks5"),
|
||||
] {
|
||||
if values.get(format!("{name}Enable").as_str()) == Some(&"1") {
|
||||
if let (Some(host), Some(port)) = (
|
||||
values.get(format!("{name}Proxy").as_str()),
|
||||
values.get(format!("{name}Port").as_str()),
|
||||
) {
|
||||
settings
|
||||
.proxies
|
||||
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
settings.exclude_simple = values.get("ExcludeSimpleHostnames") == Some(&"1");
|
||||
if settings.proxies.is_empty() && values.get("ProxyAutoConfigEnable") == Some(&"1") {
|
||||
bail!(PAC_ERROR);
|
||||
}
|
||||
Ok(Some(settings))
|
||||
}
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn parse_windows_servers(server: &str) -> Settings {
|
||||
let mut settings = Settings::default();
|
||||
for entry in server.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let (scheme, value) = entry.split_once('=').unwrap_or(("*", entry));
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if scheme == "socks" {
|
||||
let value = if value.contains("://") {
|
||||
value.into()
|
||||
} else {
|
||||
format!("socks5://{value}")
|
||||
};
|
||||
settings.proxies.insert("*".into(), value);
|
||||
} else {
|
||||
settings.proxies.insert(scheme, value.into());
|
||||
}
|
||||
}
|
||||
settings
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_settings() -> Result<Option<Settings>> {
|
||||
let kde = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains("kde");
|
||||
if kde {
|
||||
if let Some(settings) = kde_settings()? {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
}
|
||||
if let Ok(output) = std::process::Command::new("gsettings")
|
||||
.args(["list-recursively", "org.gnome.system.proxy"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
if let Some(settings) = parse_gnome(&String::from_utf8_lossy(&output.stdout))? {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !kde {
|
||||
return kde_settings();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn kde_settings() -> Result<Option<Settings>> {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(config) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
paths.push(std::path::PathBuf::from(config));
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
paths.push(std::path::PathBuf::from(home).join(".config"));
|
||||
}
|
||||
paths.extend(std::env::split_paths(
|
||||
&std::env::var_os("XDG_CONFIG_DIRS").unwrap_or_else(|| "/etc/xdg".into()),
|
||||
));
|
||||
for path in paths {
|
||||
if let Ok(text) = std::fs::read_to_string(path.join("kioslaverc")) {
|
||||
if let Some(settings) = parse_kde(&text)? {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn unquote(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
let quoted = value
|
||||
.strip_prefix('\'')
|
||||
.and_then(|s| s.strip_suffix('\''))
|
||||
.or_else(|| value.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
|
||||
.unwrap_or(value);
|
||||
quoted
|
||||
.replace("\\'", "'")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
}
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_gnome(text: &str) -> Result<Option<Settings>> {
|
||||
let mut values = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let mut fields = line.splitn(3, ' ');
|
||||
if let (Some(schema), Some(key), Some(value)) =
|
||||
(fields.next(), fields.next(), fields.next())
|
||||
{
|
||||
values.insert(format!("{schema}.{key}"), value.trim());
|
||||
}
|
||||
}
|
||||
let get = |key: &str| {
|
||||
values
|
||||
.get(&format!("org.gnome.system.proxy.{key}"))
|
||||
.copied()
|
||||
.unwrap_or("")
|
||||
};
|
||||
match unquote(get("mode")).as_str() {
|
||||
"auto" => bail!(PAC_ERROR),
|
||||
"manual" => {}
|
||||
"none" => return Ok(Some(Settings::default())),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
for (scheme, service) in [("http", "http"), ("https", "https"), ("*", "socks")] {
|
||||
let host = unquote(get(&format!("{service}.host")));
|
||||
let port = get(&format!("{service}.port")).parse::<u16>().unwrap_or(0);
|
||||
if !host.is_empty() && port > 0 {
|
||||
let protocol = if service == "socks" { "socks5" } else { "http" };
|
||||
settings
|
||||
.proxies
|
||||
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
|
||||
}
|
||||
}
|
||||
if get("use-same-proxy") == "true" {
|
||||
if let Some(http) = settings.proxies.get("http").cloned() {
|
||||
settings.proxies.insert("https".into(), http);
|
||||
}
|
||||
}
|
||||
settings.bypass = get("ignore-hosts")
|
||||
.trim_matches(['[', ']'])
|
||||
.split(',')
|
||||
.map(unquote)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(Some(settings))
|
||||
}
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_kde(text: &str) -> Result<Option<Settings>> {
|
||||
let mut values = HashMap::new();
|
||||
let mut proxy_group = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line.starts_with('[') {
|
||||
proxy_group = line == "[Proxy Settings]";
|
||||
} else if proxy_group {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
values.insert(key.trim(), value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
match values.get("ProxyType").copied() {
|
||||
Some("1") => {}
|
||||
Some("2" | "3") => bail!(PAC_ERROR),
|
||||
Some("0") => return Ok(Some(Settings::default())),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
for (scheme, key) in [
|
||||
("http", "httpProxy"),
|
||||
("https", "httpsProxy"),
|
||||
("*", "socksProxy"),
|
||||
] {
|
||||
if let Some(value) = values.get(key).filter(|value| !value.is_empty()) {
|
||||
// KDE also stores endpoints as "http://host port".
|
||||
let value = value
|
||||
.rsplit_once(' ')
|
||||
.map(|(host, port)| format!("{host}:{port}"))
|
||||
.unwrap_or_else(|| (*value).into());
|
||||
let value = if key == "socksProxy" {
|
||||
value.replace("http://", "socks5://")
|
||||
} else {
|
||||
value
|
||||
};
|
||||
settings.proxies.insert(scheme.into(), value);
|
||||
}
|
||||
}
|
||||
settings.bypass = split_bypass(values.get("NoProxyFor").copied().unwrap_or(""));
|
||||
settings.reverse_bypass = values
|
||||
.get("ReversedException")
|
||||
.is_some_and(|value| *value == "true");
|
||||
Ok(Some(settings))
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
fn system_settings() -> Result<Option<Settings>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn environment_preserves_credentials_and_prefers_scheme_over_all() {
|
||||
let vars = HashMap::from([
|
||||
("HTTPS_PROXY", "http://User:PaSS@localhost:8080"),
|
||||
("ALL_PROXY", "socks5h://localhost:1080"),
|
||||
]);
|
||||
let settings = env_settings(|key| vars.get(key).map(|s| s.to_string())).unwrap();
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://example.org").unwrap()),
|
||||
Some("http://User:PaSS@localhost:8080")
|
||||
);
|
||||
parse_proxy(
|
||||
settings
|
||||
.select(&Url::parse("https://example.org").unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
parse_proxy(
|
||||
settings
|
||||
.select(&Url::parse("http://example.org").unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(env_settings(|_| None).is_none());
|
||||
let direct = env_settings(|key| (key == "https_proxy").then(String::new)).unwrap();
|
||||
assert_eq!(
|
||||
direct.select(&Url::parse("https://example.org").unwrap()),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn bypass_matches_domains_ports_wildcards_and_subnets() {
|
||||
assert!(bypass_matches("*foo", "foofoo", Some(443)));
|
||||
for (rule, host, port) in [
|
||||
("*", "example.org", 443),
|
||||
(".example.org", "SUB.EXAMPLE.ORG", 443),
|
||||
("*.example.org:443", "sub.example.org", 443),
|
||||
("192.168.*", "192.168.5.67", 443),
|
||||
("127.0.0.0/8", "127.1.2.3", 443),
|
||||
("::1", "[::1]", 443),
|
||||
("<local>", "frame", 443),
|
||||
] {
|
||||
assert!(bypass_matches(rule, host, Some(port)), "{rule} / {host}");
|
||||
}
|
||||
assert!(!bypass_matches("example.org", "badexample.org", Some(443)));
|
||||
assert!(!bypass_matches("example.org:80", "example.org", Some(443)));
|
||||
assert!(!bypass_matches("192.168.0.0/16", "10.1.2.3", Some(443)));
|
||||
assert!(parse_proxy("file:///tmp/proxy").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn desktop_settings_select_https_or_socks_and_honor_disabled_mode() {
|
||||
let gnome = "org.gnome.system.proxy mode 'manual'\norg.gnome.system.proxy.https host '127.0.0.1'\norg.gnome.system.proxy.https port 7890\norg.gnome.system.proxy.socks host '127.0.0.1'\norg.gnome.system.proxy.socks port 7891\norg.gnome.system.proxy ignore-hosts ['localhost', '*.example.org']\n";
|
||||
let settings = parse_gnome(gnome).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://github.com").unwrap()),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
assert!(
|
||||
settings
|
||||
.select(&Url::parse("https://sub.example.org").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("http://github.com").unwrap()),
|
||||
Some("socks5://127.0.0.1:7891")
|
||||
);
|
||||
assert!(
|
||||
parse_gnome(&gnome.replace("'manual'", "'none'"))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.proxies
|
||||
.is_empty()
|
||||
);
|
||||
assert!(parse_gnome(&gnome.replace("'manual'", "'auto'")).is_err());
|
||||
let kde = "[Proxy Settings]\nProxyType=1\nhttpsProxy=http://127.0.0.1 7890\nNoProxyFor=localhost,*.example.org\n";
|
||||
let settings = parse_kde(kde).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://github.com").unwrap()),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
assert!(
|
||||
settings
|
||||
.select(&Url::parse("https://sub.example.org").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
parse_kde(&kde.replace("ProxyType=1", "ProxyType=0"))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.proxies
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn macos_and_windows_settings_preserve_protocol_and_bypass_rules() {
|
||||
let text = "<dictionary> {\n HTTPSEnable : 1\n HTTPSProxy : proxy.example.org\n HTTPSPort : 8080\n SOCKSEnable : 1\n SOCKSProxy : localhost\n SOCKSPort : 1080\n ExceptionsList : <array> {\n 0 : *.local\n }\n __SCOPED__ : <dictionary> {\n en0 : <dictionary> {\n HTTPSEnable : 0\n HTTPSProxy : wrong.example.org\n }\n }\n}";
|
||||
let settings = parse_macos(text).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://github.com").unwrap()),
|
||||
Some("http://proxy.example.org:8080")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("http://github.com").unwrap()),
|
||||
Some("socks5://localhost:1080")
|
||||
);
|
||||
assert!(
|
||||
settings
|
||||
.select(&Url::parse("https://frame.local").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(parse_macos("<dictionary> {\n ProxyAutoConfigEnable : 1\n}").is_err());
|
||||
let settings =
|
||||
parse_windows_servers("http=proxy1:8080;https=proxy2:8081;socks=localhost:1080");
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://github.com").unwrap()),
|
||||
Some("proxy2:8081")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("ftp://example.org").unwrap()),
|
||||
Some("socks5://localhost:1080")
|
||||
);
|
||||
let settings = parse_windows_servers("proxy.example.org:8080");
|
||||
assert_eq!(
|
||||
settings.select(&Url::parse("https://github.com").unwrap()),
|
||||
Some("proxy.example.org:8080")
|
||||
);
|
||||
}
|
||||
}
|
||||
+136
-8
@@ -30,18 +30,22 @@ pub fn validate_repo(repo: &str) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
fn agent(url: &url::Url) -> Result<ureq::Agent> {
|
||||
let mut builder = ureq::AgentBuilder::new()
|
||||
.try_proxy_from_env(false)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.redirects(0)
|
||||
.build()
|
||||
.redirects(0);
|
||||
if let Some(proxy) = crate::proxy::for_url(url)? {
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
fn get(url: &str) -> Result<ureq::Response> {
|
||||
let agent = agent();
|
||||
let mut url = url::Url::parse(url)?;
|
||||
for _ in 0..=5 {
|
||||
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
|
||||
let response = agent
|
||||
// Re-evaluate bypass rules for every redirect target.
|
||||
let response = agent(&url)?
|
||||
.get(url.as_str())
|
||||
.set("User-Agent", "Framely-Installer")
|
||||
.call()?;
|
||||
@@ -136,6 +140,13 @@ pub fn expected_hash(checksums: &str, name: &str) -> Result<String> {
|
||||
matched.context("校验文件中没有该压缩包的条目")
|
||||
}
|
||||
pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
|
||||
verify_with_progress(archive, checksums, &mut |_| {})
|
||||
}
|
||||
pub fn verify_with_progress(
|
||||
archive: &Path,
|
||||
checksums: &Path,
|
||||
report: &mut dyn FnMut(crate::progress::Progress),
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
archive.is_file() && checksums.is_file(),
|
||||
"请选择压缩包及外部 SHA256SUMS"
|
||||
@@ -157,17 +168,30 @@ pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
|
||||
let mut file = File::open(archive)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut block = [0; 256 * 1024];
|
||||
let mut progress =
|
||||
crate::progress::Progress::new(crate::progress::Stage::Verify, "校验本地安装包 SHA256");
|
||||
let total = file.metadata()?.len();
|
||||
progress.total = Some(total);
|
||||
report(progress.clone());
|
||||
let mut last_update = std::time::Instant::now();
|
||||
loop {
|
||||
let n = file.read(&mut block)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&block[..n]);
|
||||
progress.completed += n as u64;
|
||||
if progress.completed < total && last_update.elapsed() >= Duration::from_millis(200) {
|
||||
report(progress.clone());
|
||||
last_update = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
hex::encode(digest.finalize()) == expected,
|
||||
"SHA256 不匹配,已停止安装"
|
||||
);
|
||||
ensure!(progress.completed == total, "校验期间安装包长度发生变化");
|
||||
report(progress);
|
||||
Ok(())
|
||||
}
|
||||
pub fn download(
|
||||
@@ -199,6 +223,100 @@ pub fn download(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn https_requests_use_discovered_desktop_proxy() {
|
||||
use std::{
|
||||
io::Write,
|
||||
net::TcpListener,
|
||||
os::unix::fs::PermissionsExt,
|
||||
process::{Command, Stdio},
|
||||
time::Instant,
|
||||
};
|
||||
if std::env::var_os("FRAMELY_PROXY_TEST_CHILD").is_some() {
|
||||
assert!(get("https://example.invalid/installer-proxy-check").is_err());
|
||||
return;
|
||||
}
|
||||
let work = tempfile::tempdir().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mock = work.path().join("gsettings");
|
||||
std::fs::write(&mock, format!("#!/bin/sh\nprintf '%s\\n' \"org.gnome.system.proxy mode 'manual'\" \"org.gnome.system.proxy.https host '127.0.0.1'\" \"org.gnome.system.proxy.https port {port}\"\n")).unwrap();
|
||||
std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.args([
|
||||
"--exact",
|
||||
"release::tests::https_requests_use_discovered_desktop_proxy",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("FRAMELY_PROXY_TEST_CHILD", "1")
|
||||
.env("XDG_CURRENT_DESKTOP", "GNOME")
|
||||
.env("XDG_CONFIG_HOME", work.path())
|
||||
.env("XDG_CONFIG_DIRS", work.path())
|
||||
.env(
|
||||
"PATH",
|
||||
std::env::join_paths(std::iter::once(work.path().to_path_buf()).chain(
|
||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for key in [
|
||||
"http_proxy",
|
||||
"HTTP_PROXY",
|
||||
"https_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"all_proxy",
|
||||
"ALL_PROXY",
|
||||
"no_proxy",
|
||||
"NO_PROXY",
|
||||
] {
|
||||
command.env_remove(key);
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut connection = loop {
|
||||
if let Ok((connection, _)) = listener.accept() {
|
||||
break connection;
|
||||
}
|
||||
if Instant::now() >= deadline || child.try_wait().unwrap().is_some() {
|
||||
let _ = child.kill();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
panic!(
|
||||
"Installer did not connect through detected proxy: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
connection
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0u8; 1024];
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
match connection.read(&mut buffer) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => request.extend_from_slice(&buffer[..n]),
|
||||
}
|
||||
}
|
||||
connection
|
||||
.write_all(
|
||||
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(request.starts_with(b"CONNECT example.invalid:443 HTTP/1.1\r\n"));
|
||||
}
|
||||
#[test]
|
||||
fn checksum_rejects_ambiguity_and_tampering() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -211,9 +329,19 @@ mod tests {
|
||||
archive.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
std::fs::write(&sums, &line).unwrap();
|
||||
verify(&archive, &sums).unwrap();
|
||||
let mut progress = Vec::new();
|
||||
verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).unwrap();
|
||||
assert_eq!(progress.first().unwrap().completed, 0);
|
||||
assert_eq!(progress.last().unwrap().fraction(), Some(1.0));
|
||||
assert!(
|
||||
progress
|
||||
.iter()
|
||||
.all(|value| value.stage == crate::progress::Stage::Verify)
|
||||
);
|
||||
std::fs::write(&archive, b"changed").unwrap();
|
||||
assert!(verify(&archive, &sums).is_err());
|
||||
progress.clear();
|
||||
assert!(verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).is_err());
|
||||
assert!(!progress.iter().any(|value| value.fraction() == Some(1.0)));
|
||||
assert!(
|
||||
expected_hash(
|
||||
&(line.clone() + &line),
|
||||
|
||||
+62
-14
@@ -11,10 +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>
|
||||
@@ -40,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;
|
||||
@@ -59,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;
|
||||
@@ -99,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;
|
||||
}
|
||||
@@ -118,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){
|
||||
@@ -144,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){
|
||||
@@ -177,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;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+(page=="catalog"?"#catalog":""),true,1440,810);if(page=="catalog"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash=\"catalog\";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_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];}
|
||||
@@ -202,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.
|
||||
@@ -218,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;
|
||||
@@ -269,10 +307,20 @@ 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){
|
||||
@@ -282,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;
|
||||
}
|
||||
Generated
+27
@@ -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"
|
||||
},
|
||||
|
||||
+4
-4
@@ -4,17 +4,17 @@ 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),
|
||||
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');}
|
||||
|
||||
+111
-1
@@ -583,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)]
|
||||
@@ -603,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
|
||||
@@ -615,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();
|
||||
|
||||
+837
-45
File diff suppressed because it is too large.
Load diff
+37
-8
@@ -358,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")?;
|
||||
@@ -696,8 +699,13 @@ impl Agent {
|
||||
_ => "menu.close",
|
||||
};
|
||||
let mut command = json!({"kind":kind});
|
||||
if method == "host.manager.open" && p["page"] == "catalog" {
|
||||
command["page"] = json!("catalog");
|
||||
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))
|
||||
@@ -787,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"
|
||||
@@ -802,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"
|
||||
@@ -1115,6 +1127,7 @@ pub fn serve(
|
||||
"window.close",
|
||||
"plugin.disabled",
|
||||
"notification.changed",
|
||||
"manager.open",
|
||||
]
|
||||
.contains(&e["kind"].as_str().unwrap_or(""))
|
||||
{
|
||||
@@ -1298,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));
|
||||
});
|
||||
@@ -1325,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());
|
||||
@@ -1468,12 +1486,14 @@ mod tests {
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn manager_store_entry_preserves_catalog_target_and_rejects_arbitrary_pages() {
|
||||
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"}),
|
||||
] {
|
||||
@@ -1483,8 +1503,13 @@ mod tests {
|
||||
}
|
||||
let commands = agent.commands.lock().unwrap();
|
||||
assert_eq!(commands[0], json!({"kind":"manager.open","page":"catalog"}));
|
||||
assert_eq!(commands[1], json!({"kind":"manager.open"}));
|
||||
assert_eq!(commands[2], json!({"kind":"manager.open"}));
|
||||
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() {
|
||||
@@ -1866,7 +1891,11 @@ mod tests {
|
||||
let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap();
|
||||
let socket_worker = std::thread::spawn(move || {
|
||||
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
|
||||
for expected in ["host.ui.visibility", "agreement.status"] {
|
||||
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);
|
||||
@@ -2239,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());
|
||||
|
||||
+144
-17
@@ -89,18 +89,54 @@ pub fn create_descriptor(archive: &Path, url: &str, output: &Path, changelog: &s
|
||||
Ok(())
|
||||
}
|
||||
fn file_digest(path: &Path) -> Result<String> {
|
||||
file_digest_progress(path, |_| Ok(()))
|
||||
}
|
||||
fn file_digest_progress(
|
||||
path: &Path,
|
||||
mut progress: impl FnMut(u64) -> Result<()>,
|
||||
) -> Result<String> {
|
||||
let mut f = fs::File::open(path)?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut b = [0u8; 65536];
|
||||
let mut verified = 0;
|
||||
progress(verified)?;
|
||||
loop {
|
||||
let n = f.read(&mut b)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&b[..n]);
|
||||
verified += n as u64;
|
||||
progress(verified)?;
|
||||
}
|
||||
Ok(hex::encode(hash.finalize()))
|
||||
}
|
||||
fn verify_archive(
|
||||
archive: &Path,
|
||||
release: &Release,
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
mut progress: impl FnMut(Value) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let mut last_update = std::time::Instant::now();
|
||||
let digest = file_digest_progress(archive, |verified| {
|
||||
cancel.check()?;
|
||||
ensure!(verified <= release.size, "发行包超过声明长度");
|
||||
if verified == 0
|
||||
|| verified == release.size
|
||||
|| last_update.elapsed() >= Duration::from_millis(250)
|
||||
{
|
||||
progress(
|
||||
json!({"phase":"verifying","verified":verified,"total":release.size,"version":release.version}),
|
||||
)?;
|
||||
last_update = std::time::Instant::now();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
cancel.check()?;
|
||||
ensure!(fs::metadata(archive)?.len() == release.size, "下载未完成");
|
||||
ensure!(digest == release.sha256, "发行包 SHA256 不匹配");
|
||||
Ok(())
|
||||
}
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct GitHubAsset {
|
||||
name: String,
|
||||
@@ -114,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('-')
|
||||
@@ -296,27 +332,66 @@ impl Updater {
|
||||
.context("请先检查更新")?;
|
||||
let updater = self.clone();
|
||||
let state_path = root.join("update-status.json");
|
||||
self.jobs.task("system.download",move |cancel| {
|
||||
let _busy=busy;cancel.check()?;
|
||||
let cache=PathBuf::from("/home/.framely/update-cache");fs::create_dir_all(&cache)?;let metadata=fs::symlink_metadata(&cache)?;ensure!(metadata.is_dir()&&!metadata.file_type().is_symlink()&&metadata.uid()==0,"更新缓存权限无效");fs::set_permissions(&cache,fs::Permissions::from_mode(0o700))?;
|
||||
let stage=cache.join(format!("{:032x}",rand::random::<u128>()));fs::create_dir(&stage)?;fs::set_permissions(&stage,fs::Permissions::from_mode(0o700))?;
|
||||
let result=(||->Result<Value>{
|
||||
write_status(&state_path,json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
|
||||
let response=crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
|
||||
if let Some(length)=response.header("Content-Length"){ensure!(length.parse::<u64>()?==release.size,"下载长度与更新清单不一致");}
|
||||
let mut input=response.into_reader();let mut output=fs::File::create(stage.join("release.tar.gz"))?;let mut buffer=[0u8;262144];let mut received=0u64;let mut update_at=std::time::Instant::now();
|
||||
loop{cancel.check()?;let n=input.read(&mut buffer)?;if n==0{break;}received+=n as u64;ensure!(received<=release.size,"发行包超过声明长度");output.write_all(&buffer[..n])?;
|
||||
if update_at.elapsed()>=Duration::from_millis(250){write_status(&state_path,json!({"phase":"downloading","received":received,"total":release.size,"version":release.version}))?;update_at=std::time::Instant::now();}}
|
||||
output.sync_all()?;ensure!(received==release.size,"下载未完成");ensure!(file_digest(&stage.join("release.tar.gz"))?==release.sha256,"发行包 SHA256 不匹配");
|
||||
fs::write(stage.join("descriptor.json"),descriptor)?;
|
||||
cancel.commit(|| { *updater.prepared.lock().unwrap()=Some((stage.clone(),release.clone(),source_url));Ok(()) })?;
|
||||
write_status(&state_path,json!({"phase":"ready","version":release.version,"received":received,"total":release.size}))?;
|
||||
self.jobs.task_progress("system.download", move |cancel, progress| {
|
||||
let _busy = busy;
|
||||
cancel.check()?;
|
||||
let cache = PathBuf::from("/home/.framely/update-cache");
|
||||
fs::create_dir_all(&cache)?;
|
||||
let metadata = fs::symlink_metadata(&cache)?;
|
||||
ensure!(metadata.is_dir() && !metadata.file_type().is_symlink() && metadata.uid() == 0, "更新缓存权限无效");
|
||||
fs::set_permissions(&cache, fs::Permissions::from_mode(0o700))?;
|
||||
let stage = cache.join(format!("{:032x}", rand::random::<u128>()));
|
||||
fs::create_dir(&stage)?;
|
||||
fs::set_permissions(&stage, fs::Permissions::from_mode(0o700))?;
|
||||
let report = |value: Value| -> Result<()> {
|
||||
write_status(&state_path, value.clone())?;
|
||||
progress(value);
|
||||
Ok(())
|
||||
};
|
||||
let result = (|| -> Result<Value> {
|
||||
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
|
||||
let response = crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
|
||||
if let Some(length) = response.header("Content-Length") {
|
||||
ensure!(length.parse::<u64>()? == release.size, "下载长度与更新清单不一致");
|
||||
}
|
||||
let mut input = response.into_reader();
|
||||
let archive = stage.join("release.tar.gz");
|
||||
let mut output = fs::File::create(&archive)?;
|
||||
let mut buffer = [0u8; 262144];
|
||||
let mut received = 0u64;
|
||||
let mut update_at = std::time::Instant::now();
|
||||
loop {
|
||||
cancel.check()?;
|
||||
let n = input.read(&mut buffer)?;
|
||||
if n == 0 { break; }
|
||||
received += n as u64;
|
||||
ensure!(received <= release.size, "发行包超过声明长度");
|
||||
output.write_all(&buffer[..n])?;
|
||||
if received == release.size || update_at.elapsed() >= Duration::from_millis(250) {
|
||||
report(json!({"phase":"downloading","received":received,"total":release.size,"version":release.version}))?;
|
||||
update_at = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
output.sync_all()?;
|
||||
drop(output);
|
||||
ensure!(received == release.size, "下载未完成");
|
||||
verify_archive(&archive, &release, &cancel, report)?;
|
||||
fs::write(stage.join("descriptor.json"), descriptor)?;
|
||||
cancel.commit(|| {
|
||||
report(json!({"phase":"ready","version":release.version,"received":received,"verified":received,"total":release.size}))?;
|
||||
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(release))
|
||||
})();
|
||||
if let Err(e)=&result {let _=fs::remove_dir_all(stage);let _=write_status(&state_path,json!({"phase":"failed","error":e.to_string()}));}
|
||||
if let Err(e) = &result {
|
||||
let _ = fs::remove_dir_all(stage);
|
||||
let _ = write_status(&state_path, json!({"phase":"failed","error":e.to_string()}));
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
&self,
|
||||
root: &Path,
|
||||
@@ -488,6 +563,58 @@ pub fn apply(root: &Path, manager: u32, stage: Option<PathBuf>, rollback: bool)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn archive_verification_reports_bytes_and_rejects_tampering_or_cancellation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let archive = dir.path().join("release.tar.gz");
|
||||
let payload = vec![42u8; 150_000];
|
||||
fs::write(&archive, &payload).unwrap();
|
||||
let release = Release {
|
||||
schema_version: 1,
|
||||
version: "0.4.2".into(),
|
||||
api_version: API_VERSION,
|
||||
arch: "aarch64".into(),
|
||||
url: "https://example.org/release.tar.gz".into(),
|
||||
sha256: hex::encode(Sha256::digest(&payload)),
|
||||
size: payload.len() as u64,
|
||||
changelog: String::new(),
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
verify_archive(&archive, &release, &Default::default(), |event| {
|
||||
events.push(event);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(events.first().unwrap()["verified"], 0);
|
||||
assert_eq!(events.last().unwrap()["verified"], release.size);
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| event["phase"] == "verifying" && event["total"] == release.size));
|
||||
let cancel = crate::jobs::Cancellation::default();
|
||||
assert!(verify_archive(&archive, &release, &cancel, |event| {
|
||||
if event["verified"] == release.size {
|
||||
cancel.stop();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("取消"));
|
||||
fs::write(&archive, vec![43u8; payload.len()]).unwrap();
|
||||
assert!(
|
||||
verify_archive(&archive, &release, &Default::default(), |_| Ok(()))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("SHA256")
|
||||
);
|
||||
fs::write(&archive, b"truncated").unwrap();
|
||||
assert!(
|
||||
verify_archive(&archive, &release, &Default::default(), |_| Ok(()))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("下载未完成")
|
||||
);
|
||||
}
|
||||
fn source() -> UpdateSource {
|
||||
UpdateSource {
|
||||
url: "https://github.com/example/framely/releases/latest/download/framely-release.json"
|
||||
|
||||
@@ -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(documeLine 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));
|
||||
}
|
||||
@@ -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);}
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in menu_geometry keyboard_state notification_geometry ui_visibility; do
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
+58
-1
@@ -83,6 +83,8 @@
|
||||
"管理 {0}": "Manage {0}",
|
||||
"未找到插件": "No plugins found",
|
||||
"没有收藏的插件": "No favorite plugins",
|
||||
"没有已启用的插件": "No enabled plugins",
|
||||
"打开插件管理以启用插件。": "Open the manager to enable plugins.",
|
||||
"还没有安装插件": "No plugins installed yet",
|
||||
"换一个关键词再试。": "Try another search term.",
|
||||
"点击插件旁的星标,将它加入常用。": "Click a plugin's star to add it to your favorites.",
|
||||
@@ -292,6 +294,11 @@
|
||||
"回滚上一版本": "Roll back to previous release",
|
||||
"正在读取更新清单…": "Reading release information…",
|
||||
"正在下载并校验发行包…": "Downloading and verifying the release package…",
|
||||
"正在下载发行包…": "Downloading the release package…",
|
||||
"下载完成,正在校验发行包…": "Download complete. Verifying the release package…",
|
||||
"下载进度": "Download progress",
|
||||
"校验进度": "Verification progress",
|
||||
"已校验 {0} / {1} MiB": "Verified {0} / {1} MiB",
|
||||
"上次更新失败:": "Last update failed: ",
|
||||
"诊断日志:/var/lib/framely/logs/update.log": "Diagnostic log: /var/lib/framely/logs/update.log",
|
||||
"上次更新操作已完成。": "The last update operation completed.",
|
||||
@@ -300,6 +307,9 @@
|
||||
"发布者未提供更新说明。": "The publisher has not provided release notes.",
|
||||
"安装已校验版本": "Install verified release",
|
||||
"下载并校验": "Download and verify",
|
||||
"下载并安装": "Download and install",
|
||||
"确认下载并安装": "Confirm download and installation",
|
||||
"确认后将下载并校验发行包,校验通过后自动安装。": "After confirmation, the release package will be downloaded, verified, and installed automatically.",
|
||||
"正在切换版本。Framely 入口会在服务恢复后重新出现。": "Switching releases. The Framely entry will return when the service recovers.",
|
||||
"安装 Framely {0}?": "Install Framely {0}?",
|
||||
"回滚 Framely?": "Roll back Framely?",
|
||||
@@ -376,6 +386,53 @@
|
||||
"仅检查正式版。": "Check stable releases only.",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "Check testing releases only (Preview, Beta, RC, etc.).",
|
||||
"所选渠道暂无可用发行版本。": "No release is available in the selected channel.",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "The selected release is older than your current version. Installing will switch to that older version."
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "The selected release is older than your current version. Installing will switch to that older version.",
|
||||
"通知": "Notifications",
|
||||
"收件箱": "Inbox",
|
||||
"收件箱为空": "Your inbox is empty",
|
||||
"需要保留的通知会出现在这里。": "Notifications saved for later will appear here.",
|
||||
"移除通知": "Remove notification",
|
||||
"{0} 秒后关闭": "Closes in {0}s",
|
||||
"启用此插件后可使用通知操作。": "Enable this plugin to use notification actions.",
|
||||
"Framely {0} 可更新": "Framely {0} is available",
|
||||
"查看新版本并选择安装,或忽略本次提醒。": "Review and install the new version, or ignore this reminder.",
|
||||
"忽略": "Ignore",
|
||||
"打开更新": "Open updates",
|
||||
"自动检查更新": "Check for updates automatically",
|
||||
"检查间隔": "Check interval",
|
||||
"每 {0} 小时": "Every {0} hours",
|
||||
"发现新版本时发送通知,您可以忽略提醒或打开更新页面。": "New versions send a notification. Ignore it or open Updates when you are ready.",
|
||||
"正在自动检查更新…": "Checking for updates automatically…",
|
||||
"自动检查未完成,将在下次检查时重试。": "Automatic check failed. It will retry at the next interval.",
|
||||
"Notification has changed": "This notification has changed. Please try again.",
|
||||
"Notification inbox full": "The notification inbox is full. Remove older notifications first.",
|
||||
"自动检查间隔须为 1-168 小时": "Automatic check interval must be 1-168 hours.",
|
||||
"Update source or channel has changed": "The update source or channel has changed.",
|
||||
"Notification queue full": "The notification popup queue is full.",
|
||||
"每小时": "Every hour",
|
||||
"允许通知": "Allow notifications",
|
||||
"允许在视野中显示悬浮通知。": "Show floating notifications in your view.",
|
||||
"悬浮已关闭,新通知仅在此处查看。": "Popups are off. View new notifications here.",
|
||||
"通知设置": "Notification settings",
|
||||
"选择通知如何提醒你。": "Choose how notifications reach you.",
|
||||
"悬浮通知": "Floating notifications",
|
||||
"关闭后,新通知将保存在通知收件箱,不显示浮窗。": "When off, new notifications go to your inbox without showing a popup.",
|
||||
"启动图标角标": "Launcher badge",
|
||||
"在 Framely 启动图标上显示未读收件箱消息数。": "Show the unread inbox count on the Framely launcher icon.",
|
||||
"插件通知": "Plugin notifications",
|
||||
"搜索通知插件": "Search notification plugins",
|
||||
"全局开关优先;关闭全局开关不会改变插件偏好。": "Global switches take priority. Plugin preferences are kept when a global switch is off.",
|
||||
"角标": "Badge",
|
||||
"悬浮": "Popup",
|
||||
"{0}:角标": "{0}: badge",
|
||||
"{0}:悬浮": "{0}: popup",
|
||||
"通知设置导航": "Notifications",
|
||||
"应用通知": "App notifications",
|
||||
"搜索通知应用": "Search notification apps",
|
||||
"搜索应用": "Search apps",
|
||||
"全局开关优先;关闭全局开关不会改变应用偏好。": "Global switches take priority. App preferences are kept when a global switch is off.",
|
||||
"应用": "App",
|
||||
"未找到应用": "No apps found",
|
||||
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders."
|
||||
}
|
||||
}
|
||||
+58
-1
@@ -83,6 +83,8 @@
|
||||
"管理 {0}": "管理 {0}",
|
||||
"未找到插件": "未找到插件",
|
||||
"没有收藏的插件": "没有收藏的插件",
|
||||
"没有已启用的插件": "没有已启用的插件",
|
||||
"打开插件管理以启用插件。": "打开插件管理以启用插件。",
|
||||
"还没有安装插件": "还没有安装插件",
|
||||
"换一个关键词再试。": "换一个关键词再试。",
|
||||
"点击插件旁的星标,将它加入常用。": "点击插件旁的星标,将它加入常用。",
|
||||
@@ -292,6 +294,11 @@
|
||||
"回滚上一版本": "回滚上一版本",
|
||||
"正在读取更新清单…": "正在读取更新清单…",
|
||||
"正在下载并校验发行包…": "正在下载并校验发行包…",
|
||||
"正在下载发行包…": "正在下载发行包…",
|
||||
"下载完成,正在校验发行包…": "下载完成,正在校验发行包…",
|
||||
"下载进度": "下载进度",
|
||||
"校验进度": "校验进度",
|
||||
"已校验 {0} / {1} MiB": "已校验 {0} / {1} MiB",
|
||||
"上次更新失败:": "上次更新失败:",
|
||||
"诊断日志:/var/lib/framely/logs/update.log": "诊断日志:/var/lib/framely/logs/update.log",
|
||||
"上次更新操作已完成。": "上次更新操作已完成。",
|
||||
@@ -300,6 +307,9 @@
|
||||
"发布者未提供更新说明。": "发布者未提供更新说明。",
|
||||
"安装已校验版本": "安装已校验版本",
|
||||
"下载并校验": "下载并校验",
|
||||
"下载并安装": "下载并安装",
|
||||
"确认下载并安装": "确认下载并安装",
|
||||
"确认后将下载并校验发行包,校验通过后自动安装。": "确认后将下载并校验发行包,校验通过后自动安装。",
|
||||
"正在切换版本。Framely 入口会在服务恢复后重新出现。": "正在切换版本。Framely 入口会在服务恢复后重新出现。",
|
||||
"安装 Framely {0}?": "安装 Framely {0}?",
|
||||
"回滚 Framely?": "回滚 Framely?",
|
||||
@@ -376,6 +386,53 @@
|
||||
"仅检查正式版。": "仅检查正式版。",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "仅检查测试版(Preview、Beta、RC 等)。",
|
||||
"所选渠道暂无可用发行版本。": "所选渠道暂无可用发行版本。",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "所选版本早于当前版本,安装将切换到较旧版本。"
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "所选版本早于当前版本,安装将切换到较旧版本。",
|
||||
"通知": "通知",
|
||||
"收件箱": "收件箱",
|
||||
"收件箱为空": "收件箱为空",
|
||||
"需要保留的通知会出现在这里。": "需要保留的通知会出现在这里。",
|
||||
"移除通知": "移除通知",
|
||||
"{0} 秒后关闭": "{0} 秒后关闭",
|
||||
"启用此插件后可使用通知操作。": "启用此插件后可使用通知操作。",
|
||||
"Framely {0} 可更新": "Framely {0} 可更新",
|
||||
"查看新版本并选择安装,或忽略本次提醒。": "查看新版本并选择安装,或忽略本次提醒。",
|
||||
"忽略": "忽略",
|
||||
"打开更新": "打开更新",
|
||||
"自动检查更新": "自动检查更新",
|
||||
"检查间隔": "检查间隔",
|
||||
"每 {0} 小时": "每 {0} 小时",
|
||||
"发现新版本时发送通知,您可以忽略提醒或打开更新页面。": "发现新版本时发送通知,您可以忽略提醒或打开更新页面。",
|
||||
"正在自动检查更新…": "正在自动检查更新…",
|
||||
"自动检查未完成,将在下次检查时重试。": "自动检查未完成,将在下次检查时重试。",
|
||||
"Notification has changed": "通知已更新,请重试。",
|
||||
"Notification inbox full": "通知收件箱已满,请先移除旧通知。",
|
||||
"自动检查间隔须为 1-168 小时": "自动检查间隔须为 1-168 小时",
|
||||
"Update source or channel has changed": "更新源或渠道已变更。",
|
||||
"Notification queue full": "浮动通知队列已满。",
|
||||
"每小时": "每小时",
|
||||
"允许通知": "允许通知",
|
||||
"允许在视野中显示悬浮通知。": "允许在视野中显示悬浮通知。",
|
||||
"悬浮已关闭,新通知仅在此处查看。": "悬浮已关闭,新通知仅在此处查看。",
|
||||
"通知设置": "通知设置",
|
||||
"选择通知如何提醒你。": "选择通知如何提醒你。",
|
||||
"悬浮通知": "悬浮通知",
|
||||
"关闭后,新通知将保存在通知收件箱,不显示浮窗。": "关闭后,新通知将保存在通知收件箱,不显示浮窗。",
|
||||
"启动图标角标": "启动图标角标",
|
||||
"在 Framely 启动图标上显示未读收件箱消息数。": "在 Framely 启动图标上显示未读收件箱消息数。",
|
||||
"插件通知": "插件通知",
|
||||
"搜索通知插件": "搜索通知插件",
|
||||
"全局开关优先;关闭全局开关不会改变插件偏好。": "全局开关优先;关闭全局开关不会改变插件偏好。",
|
||||
"角标": "角标",
|
||||
"悬浮": "悬浮",
|
||||
"{0}:角标": "{0}:角标",
|
||||
"{0}:悬浮": "{0}:悬浮",
|
||||
"通知设置导航": "通知设置",
|
||||
"应用通知": "应用通知",
|
||||
"搜索通知应用": "搜索通知应用",
|
||||
"搜索应用": "搜索应用",
|
||||
"全局开关优先;关闭全局开关不会改变应用偏好。": "全局开关优先;关闭全局开关不会改变应用偏好。",
|
||||
"应用": "应用",
|
||||
"未找到应用": "未找到应用",
|
||||
"本体通知,包括检查更新提醒。": "本体通知,包括检查更新提醒。"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,12 +2,12 @@ import React,{useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {Select} from './localized-select';
|
||||
import {t,languages,resolveLanguage,LanguagePack} from './i18n';
|
||||
export function LanguageSettings({value,packs,invalidFiles,refresh}:{value:string;packs:LanguagePack[];invalidFiles:string[];refresh:()=>Promise<void>}){
|
||||
export function LanguageSettings({value,packs,invalidFiles,refresh,selectionOnly=false}:{value:string;packs:LanguagePack[];invalidFiles:string[];refresh:()=>Promise<void>;selectionOnly?:boolean}){
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
async function select(language:string){setBusy(true);setError('');try{await api('language.save',{language});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function install(file:File){setBusy(true);setError('');try{if(file.size>1024*1024)throw new Error(t('语言文件超过 1 MiB'));const pack=JSON.parse(await file.text());await api('language.install',{pack});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
|
||||
return <section className="settings-card language-settings"><h2>{t('语言')}</h2><div className="row"><LanguagePicker value={value} packs={packs} disabled={busy} onChange={select}/><label className="language-import">{t('安装语言文件')}<input aria-label={t('安装语言文件')} disabled={busy} type="file" accept=".json,application/json" onChange={e=>{const file=e.target.files?.[0];e.target.value='';if(file)void install(file);}}/></label><a href="/assets/locales/en-US.json" download="framely-language-template.json">{t('下载语言文件模板')}</a></div>{invalidFiles.length>0&&<p className="error">{t('无法读取语言文件:{0}',{0:invalidFiles.join(', ')})}</p>}{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
return <section className="settings-card language-settings"><h2>{t('语言')}</h2><div className="row"><LanguagePicker value={value} packs={packs} disabled={busy} onChange={select}/>{!selectionOnly&&<><label className="language-import">{t('安装语言文件')}<input aria-label={t('安装语言文件')} disabled={busy} type="file" accept=".json,application/json" onChange={e=>{const file=e.target.files?.[0];e.target.value='';if(file)void install(file);}}/></label><a href="/assets/locales/en-US.json" download="framely-language-template.json">{t('下载语言文件模板')}</a></>}</div>{invalidFiles.length>0&&<p className="error">{t('无法读取语言文件:{0}',{0:invalidFiles.join(', ')})}</p>}{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
}
|
||||
|
||||
export function LanguagePicker({value,packs,disabled,onChange}:{value:string;packs:LanguagePack[];disabled?:boolean;onChange:(language:string)=>void}){
|
||||
|
||||
+24
-18
@@ -1,3 +1,6 @@
|
||||
import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings';
|
||||
import {NotificationCenter,type NotificationEntry} from './notifications';
|
||||
import {IconBell} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import {uploadFile} from './upload';
|
||||
import {LanguageSettings} from './language';
|
||||
@@ -15,7 +18,7 @@ import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'rea
|
||||
type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];ui:{quickPage?:string;windows:Record<string,unknown>}};
|
||||
type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
|
||||
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:any[];runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
@@ -26,12 +29,18 @@ function PluginFrame({plugin,entry='quick'}:{plugin:string;entry?:string}){
|
||||
const [attempt,setAttempt]=useState(0);
|
||||
useEffect(()=>{
|
||||
let live=true;setError('');setSource(null);
|
||||
const methods:Record<string,string>={call:'plugin.call',dependencies:'plugin.dependencies','window.open':'window.open','window.close':'window.close','notification.send':'notification.send','notification.remove':'notification.remove'};
|
||||
const methods:Record<string,string>={call:'plugin.call',dependencies:'plugin.dependencies','window.open':'window.open','window.close':'window.close','notification.send':'notification.send','notification.remove':'notification.remove','notification.dismiss':'notification.dismiss'};
|
||||
async function message(e:MessageEvent){
|
||||
if(e.source!==ref.current?.contentWindow||e.data?.channel!=='framely.plugin')return;
|
||||
const {id,op,params}=e.data;if(!Number.isSafeInteger(id)||typeof op!=='string')return;
|
||||
try{let result;
|
||||
if(op==='language.get')result={preference:pluginLanguagePreference,language:currentLanguage()};
|
||||
else if(op==='ui.close'){
|
||||
const view=viewKey();
|
||||
if(view==='menu')result=await api('host.menu.close');
|
||||
else if(view==='framely.manager')result=await api('host.manager.close');
|
||||
else result=await api('window.close',{plugin,window:entry});
|
||||
}
|
||||
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
|
||||
else if(op==='haptic')result=await api('host.haptic',{view:viewKey()});
|
||||
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
|
||||
@@ -60,9 +69,10 @@ function PluginFrame({plugin,entry='quick'}:{plugin:string;entry?:string}){
|
||||
return error?<div><p className="error">{error}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div>:source?<iframe title={t('插件页面')} ref={ref} sandbox={sandbox} src={source} allow="clipboard-read; clipboard-write; fullscreen"/>:<p>{t('正在加载…')}</p>;
|
||||
}
|
||||
function App(){
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':'installed'):'favorites'),[page,setPage]=useState<string|null>(null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]);
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]);
|
||||
useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]);
|
||||
const localePacks=useRef<LanguagePack[]|null>(null);const localePreference=useRef<string|null>(null);
|
||||
const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh);
|
||||
async function refreshLanguages(){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);await refresh();}
|
||||
|
||||
useEffect(()=>{
|
||||
@@ -86,9 +96,11 @@ function App(){
|
||||
else{const plan=await api('plugin.disable.preview',{plugin:id});if(plan.dependents.length){setRelationAction({title:kind==='uninstall'?t("确认卸载及依赖停用"):t("确认停用计划"),enable:[],disable:plan.dependents,method:kind==='uninstall'?'plugin.uninstall':'plugin.enable',params:{plugin:id,enabled:false,purge:false,...extra,approveDependents:true,fingerprint:plan.fingerprint}});return;}await api(kind==='uninstall'?'plugin.uninstall':'plugin.enable',{plugin:id,enabled:false,purge:false,...extra});}
|
||||
setManage(null);
|
||||
}
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(location.hash==='#catalog'){setPage(null);setCatalogTarget(null);setTab('catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
async function loadCatalog(){setCatalogTarget(null);setTab('catalog');}
|
||||
const plugins=Object.values(status.database.plugins).sort((a,b)=>a.order-b.order);
|
||||
const listedPlugins=plugins.filter(p=>(tab==='installed'||p.favorite)&&(managerMode||tab!=='installed'||p.enabled)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const onlyDisabledInstalled=!managerMode&&tab==='installed'&&plugins.length>0&&listedPlugins.length===0&&!query;
|
||||
const selected=page?status.database.plugins[page]:null;
|
||||
const installedVersion=install?.info.installedVersion;
|
||||
const installDifference=installedVersion?compareVersions(install.info.manifest.version,installedVersion):null;
|
||||
@@ -99,22 +111,24 @@ function App(){
|
||||
if(parts[1]==='notifications'&&!status.agreement.accepted)return null;
|
||||
if(!status.agreement.accepted)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="required" initialDeclined={agreementDeclined} agreement={status.agreement} refresh={refresh}/>;
|
||||
if(reviewAgreement)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="review" agreement={status.agreement} refresh={refresh} onClose={()=>setReviewAgreement(false)} onRevoked={closed=>{setAgreementDeclined(!closed);setReviewAgreement(false);setPage(null);setManage(null);}}/>;
|
||||
if(parts[1]==='notifications')return <Notifications status={status} error={error} action={action}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app"><header className="shell-header"><strong>{p?.manifest.name??t("插件窗口")}</strong><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></header>{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<p className="empty">{t("插件已停用或正在启动。")}</p>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}><Icon name={icon as any}/><span>{label}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['notification-settings',t("通知设置导航"),'bell'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{plugins.filter(p=>(tab==='installed'||p.favorite)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase())).map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
|
||||
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
|
||||
<button className="plugin-main" onClick={()=>setPage(p.manifest.id)}><PluginImage src={installedIcon(p)} name={p.manifest.name} size={48}/><span className="plugin-copy"><b>{p.manifest.name}</b>{managerMode&&<small>{p.manifest.description}</small>}<span className="plugin-meta"><i className={`status-dot ${p.error?'failed':!p.enabled?'off':''}`}/>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{!managerMode&&<span className="version">{p.manifest.version}</span>}</span></span></button>
|
||||
{managerMode&&<><span className="row-state">{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}</span><span className="row-version">{p.manifest.version}</span><button className={`plugin-switch ${p.enabled?'on':''}`} role="switch" aria-checked={p.enabled} aria-label={t("启用 {0}", {"0": p.manifest.name})} disabled={busy} onClick={()=>void action(()=>changePlugin(p,'enable'))}><span/></button></>}<button className={`icon-button favorite ${p.favorite?'selected':''}`} aria-label={t("收藏 {0}", {"0": p.manifest.name})} onClick={()=>void action(()=>api('plugin.favorite',{plugin:p.manifest.id,favorite:!p.favorite}))}><Icon name="star" size={23} filled={p.favorite}/></button>
|
||||
{managerMode&&<button className="icon-button" aria-label={t("管理 {0}", {"0": p.manifest.name})} onClick={()=>setManage(p)}><Icon name="more"/></button>}
|
||||
</article>)}</div>
|
||||
{plugins.filter(p=>(tab==='installed'||p.favorite)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase())).length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
</>}
|
||||
{tab==='notifications'&&!managerMode&&<><NotificationCenter inbox items={status.inbox??[]} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh} controls={<PopupPermission config={status.database.notificationSettings??defaultNotificationConfig} refresh={refresh}/>}/>{readError&&<p className="error" role="alert">{readError}</p>}</>}
|
||||
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
|
||||
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
|
||||
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><SystemUpdates status={status} refresh={refresh}/><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><button className="setting-toggle" onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}><span><b>{t("安全模式")}</b><small>{t("暂停所有第三方插件")}</small></span><span className={`toggle ${status.database.safeMode?'on':''}`}/></button><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/></section><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><div className="row"><button onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}>{status.database.safeMode?t("退出安全模式"):t("进入安全模式")}</button><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button></div></section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><button className="setting-toggle" onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}><span><b>{t("安全模式")}</b><small>{t("暂停所有第三方插件")}</small></span><span className={`toggle ${status.database.safeMode?'on':''}`}/></button></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><div className="row"><button onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}>{status.database.safeMode?t("退出安全模式"):t("进入安全模式")}</button><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button></div></section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
|
||||
</main></div>}
|
||||
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
|
||||
@@ -127,13 +141,5 @@ function App(){
|
||||
</div>;
|
||||
}
|
||||
function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise<void>;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState<number|null>(null);const abort=useRef<AbortController|null>(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return <section><h3>{t("导入插件包")}</h3>{fileError&&<p className="error">{fileError}</p>}<input type="file" accept=".framely" disabled={busy||uploadProgress!==null} onChange={e=>{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&<p role="status">{t("正在上传…")} {uploadProgress}% <button onClick={()=>abort.current?.abort()}>{t("取消")}</button></p>}<input placeholder={t("插件包下载 URL")} value={url} onChange={e=>setUrl(e.target.value)}/><label><input type="checkbox" checked={http} onChange={e=>setHttp(e.target.checked)}/>{t("允许测试 HTTP 地址")}</label><button disabled={busy||uploadProgress!==null||!url} onClick={()=>void inspect({url,allowHttp:http})}>{t("检查下载包")}</button><small>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}</small></section>}
|
||||
function Notifications({status,error,action}:{status:Status;error:string;action:(f:()=>Promise<unknown>)=>Promise<void>}){
|
||||
const now=Date.now();const items=status.notifications.filter(n=>n.expiresAt>now).sort((a,b)=>b.expiresAt-a.expiresAt);
|
||||
return <div className="toasts">{error&&<p className="error">{error}</p>}{items.map(n=><article className="toast" key={`${n.plugin}:${n.notification.id}`}>
|
||||
<header className="toast-heading"><Brand/><div><small>{n.pluginName}</small><h2>{n.notification.title}</h2></div><button className="toast-close" aria-label={t("关闭通知")} onClick={()=>void action(()=>api('notification.remove',{plugin:n.plugin,id:n.notification.id}))}><Icon name="close" size={18}/></button></header>
|
||||
<div className="toast-content">{n.notification.image&&<img src={n.notification.image} alt={t("通知图片")} onError={e=>{e.currentTarget.style.display='none';}} onLoad={e=>{if(e.currentTarget.naturalWidth<=1&&e.currentTarget.naturalHeight<=1)e.currentTarget.style.display='none';}}/>}<p>{n.notification.body}</p></div>
|
||||
{!!n.notification.actions.length&&<div className="toast-actions">{n.notification.actions.map((a:any)=><button title={a.label} aria-label={a.label} key={a.id} onClick={()=>void action(()=>api('notification.action',{plugin:n.plugin,id:n.notification.id,action:a.id}))}>{a.icon}</button>)}</div>}
|
||||
</article>)}</div>;
|
||||
}
|
||||
class Boundary extends React.Component<React.PropsWithChildren,{error:string|null}>{state={error:null as string|null};static getDerivedStateFromError(e:Error){return {error:e.message}}render(){return this.state.error?<div className="app"><h1>{t("界面发生错误")}</h1><p>{this.state.error}</p><button onClick={()=>location.reload()}>{t("重新加载")}</button></div>:this.props.children;}}
|
||||
createRoot(document.getElementById('root')!).render(<Boundary><App/></Boundary>);console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,71 @@
|
||||
import React,{useState,useEffect} from 'react';
|
||||
import {IconBell,IconSettings,IconSearch} from '@tabler/icons-react';
|
||||
import {api,viewKey} from './api';
|
||||
import {t} from './i18n';
|
||||
import {PluginImage,installedIcon} from './store';
|
||||
import type {NotificationEntry} from './notifications';
|
||||
|
||||
export type NotificationPreference={popups:boolean;badge:boolean};
|
||||
export type NotificationConfig=NotificationPreference&{plugins:Record<string,NotificationPreference>};
|
||||
export const defaultNotificationConfig:NotificationConfig={popups:true,badge:true,plugins:{}};
|
||||
type Installed={manifest:{id:string;name:string;description?:string;icon?:string|null;version:string};enabled:boolean;order:number};
|
||||
function Switch({checked,label,disabled,onChange}:{checked:boolean;label:string;disabled:boolean;onChange:()=>void}){
|
||||
return <button className={`notification-switch ${checked?'on':''}`} role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={onChange}><span/></button>;
|
||||
}
|
||||
function useSave(refresh:()=>Promise<void>){
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
async function run(task:()=>Promise<unknown>){if(busy)return;setBusy(true);setError('');try{await task();await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
const save=(patch:Partial<NotificationPreference>&{plugin?:string})=>run(()=>api('notification.settings.save',patch));
|
||||
const open=()=>run(()=>api('host.manager.open',{page:'notification-settings'}));
|
||||
return {busy,error,save,open};
|
||||
}
|
||||
export function PopupPermission({config,refresh}:{config:NotificationConfig;refresh:()=>Promise<void>}){
|
||||
const{busy,error,save,open}=useSave(refresh);
|
||||
return <div className="inbox-permission"><div className="notification-option"><IconBell size={23} stroke={1.8}/><div><b>{t('允许通知')}</b><p>{config.popups?t('允许在视野中显示悬浮通知。'):t('悬浮已关闭,新通知仅在此处查看。')}</p></div><Switch checked={config.popups} label={t('允许通知')} disabled={busy} onChange={()=>void save({popups:!config.popups})}/><button className="notification-settings-link" aria-label={t('通知设置')} disabled={busy} onClick={()=>void open()}><IconSettings size={23} stroke={1.8}/></button></div>{error&&<p className="error" role="alert">{error}</p>}</div>;
|
||||
}
|
||||
export function NotificationSettings({config,plugins,refresh}:{config:NotificationConfig;plugins:Record<string,Installed>;refresh:()=>Promise<void>}){
|
||||
const{busy,error,save}=useSave(refresh),[query,setQuery]=useState('');
|
||||
const installed:Installed[]=[{manifest:{id:'',name:'Framely',description:t('本体通知,包括检查更新提醒。'),version:''},enabled:true,order:-1},...Object.values(plugins).sort((a,b)=>a.order-b.order)],filtered=installed.filter(p=>`${p.manifest.name} ${p.manifest.id} ${p.manifest.description??''}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
return <section className="notification-settings">
|
||||
<div className="list-heading"><div><h1>{t('通知设置')}</h1><p>{t('选择通知如何提醒你。')}</p></div></div>
|
||||
<div className="notification-global-options">
|
||||
<div className="notification-option"><div><b>{t('悬浮通知')}</b><p>{t('关闭后,新通知将保存在通知收件箱,不显示浮窗。')}</p></div><Switch checked={config.popups} label={t('悬浮通知')} disabled={busy} onChange={()=>void save({popups:!config.popups})}/></div>
|
||||
<div className="notification-option"><div><b>{t('启动图标角标')}</b><p>{t('在 Framely 启动图标上显示未读收件箱消息数。')}</p></div><Switch checked={config.badge} label={t('启动图标角标')} disabled={busy} onChange={()=>void save({badge:!config.badge})}/></div>
|
||||
</div>
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
<div className="notification-plugin-heading"><h2>{t('应用通知')}</h2><div className="search-field"><IconSearch size={22} stroke={1.8}/><input aria-label={t('搜索通知应用')} placeholder={t('搜索应用')} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>
|
||||
<p className="notification-settings-help">{t('全局开关优先;关闭全局开关不会改变应用偏好。')}</p>
|
||||
<div className="notification-plugin-columns"><span>{t('应用')}</span><span>{t('角标')}</span><span>{t('悬浮')}</span></div>
|
||||
<div className="notification-plugin-list">{filtered.map(plugin=>{
|
||||
const preference=config.plugins[plugin.manifest.id]??{popups:true,badge:true};
|
||||
return <article className="notification-plugin-row" key={plugin.manifest.id}><div className="notification-plugin-name"><PluginImage src={plugin.manifest.id?installedIcon(plugin):'/assets/branding/framely-app-icon.svg'} name={plugin.manifest.name} size={40}/><div><b>{plugin.manifest.name}</b><small>{!plugin.manifest.id?plugin.manifest.description:plugin.enabled?plugin.manifest.id:t('已停用')}</small></div></div><Switch checked={preference.badge} label={t('{0}:角标',{0:plugin.manifest.name})} disabled={busy} onChange={()=>void save({plugin:plugin.manifest.id,badge:!preference.badge})}/><Switch checked={preference.popups} label={t('{0}:悬浮',{0:plugin.manifest.name})} disabled={busy} onChange={()=>void save({plugin:plugin.manifest.id,popups:!preference.popups})}/></article>;
|
||||
})}</div>
|
||||
{!filtered.length&&<p className="empty">{t('未找到应用')}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
// A hidden native quick panel must not silently clear the Dock badge.
|
||||
export function useReadInbox(enabled:boolean,items:NotificationEntry[],refresh:()=>Promise<void>){
|
||||
const[error,setError]=useState('');
|
||||
const unread=items.filter(n=>n.inInbox&&!n.read).map(n=>({plugin:n.plugin,id:n.notification.id,createdAt:n.createdAt}));
|
||||
const snapshot=JSON.stringify(unread);
|
||||
useEffect(()=>{
|
||||
if(!enabled||!unread.length)return;
|
||||
let live=true,busy=false;
|
||||
async function read(){
|
||||
if(!live||busy||document.visibilityState==='hidden')return;busy=true;
|
||||
try{
|
||||
if(['localhost','127.0.0.1','[::1]'].includes(location.hostname)){
|
||||
const visibility=await api('ui.visibility.get',{view:viewKey()});
|
||||
if(visibility.known&&!visibility.pageVisible)return;
|
||||
}
|
||||
if(!live)return;
|
||||
await api('notification.read',{entries:JSON.parse(snapshot)});
|
||||
if(live){setError('');await refresh();}
|
||||
}catch(e){if(live)setError(String(e));}finally{busy=false;}
|
||||
}
|
||||
void read();const timer=setInterval(()=>void read(),1000);
|
||||
return()=>{live=false;clearInterval(timer);};
|
||||
},[enabled,snapshot]);
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {IconBell,IconInbox,IconX,IconTrash,IconArrowUpRight} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {t,currentLanguage} from './i18n';
|
||||
import {Brand} from './icons';
|
||||
import type {Notification} from '@framely/sdk';
|
||||
export type NotificationEntry={plugin:string;pluginName:string;createdAt:number;expiresAt:number|null;toast:boolean;inInbox:boolean;read?:boolean;notification:Notification;update?:{version:string}|null};
|
||||
function copy(entry:NotificationEntry){return entry.update?{title:t('Framely {0} 可更新',{0:entry.update.version}),body:t('查看新版本并选择安装,或忽略本次提醒。')}:entry.notification;}
|
||||
function NotificationCard({entry,inbox,now,refresh,available}:{entry:NotificationEntry;inbox:boolean;now:number;refresh:()=>Promise<void>;available:boolean}){
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState(''),[imageFailed,setImageFailed]=useState(false);
|
||||
const message=copy(entry),remaining=entry.expiresAt===null?null:Math.max(0,Math.ceil(Math.min(entry.notification.durationMs??8000,entry.expiresAt-now)/1000));
|
||||
useEffect(()=>setImageFailed(false),[entry.notification.image]);
|
||||
async function perform(method:string,action?:string){
|
||||
if(busy)return;setBusy(true);setError('');
|
||||
try{
|
||||
const result=await api(method,{plugin:entry.plugin,id:entry.notification.id,createdAt:entry.createdAt,...(action?{action}:{})});
|
||||
await refresh();
|
||||
if(entry.update&&result?.openUpdates&&location.pathname!=='/notifications'){
|
||||
if(location.pathname==='/manager'){location.hash='updates';window.dispatchEvent(new Event('hashchange'));}
|
||||
else if(!['localhost','127.0.0.1','[::1]'].includes(location.hostname))location.assign('/manager#updates');
|
||||
}
|
||||
}catch(e){setError(String(e));}finally{setBusy(false);}
|
||||
}
|
||||
return <article className={`notification-card ${inbox?'inbox-message':'toast'}`}>
|
||||
<header className="notification-heading">
|
||||
<span className="notification-source-icon">{entry.update?<Brand/>:<IconBell size={22} stroke={1.8}/>}</span>
|
||||
<div className="notification-origin"><b>{entry.pluginName}</b>{inbox?<time dateTime={new Date(entry.createdAt).toISOString()}>{new Intl.DateTimeFormat(currentLanguage(),{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}).format(entry.createdAt)}</time>:remaining!==null&&<span className="notification-countdown" role="timer">{t('{0} 秒后关闭',{0:remaining})}</span>}</div>
|
||||
<button className="notification-dismiss" disabled={busy} aria-label={inbox?t('移除通知'):t('关闭通知')} onClick={()=>void perform(inbox?'notification.remove':'notification.dismiss')}>{inbox?<IconTrash size={21} stroke={1.8}/>:<IconX size={23} stroke={1.8}/>}</button>
|
||||
</header>
|
||||
<div className="notification-message"><h2>{message.title}</h2><p>{message.body}</p>{entry.notification.image&&!imageFailed&&<img className="notification-image" src={entry.notification.image} alt={t('通知图片')} onError={()=>setImageFailed(true)} onLoad={e=>{if(e.currentTarget.naturalWidth<=1&&e.currentTarget.naturalHeight<=1)setImageFailed(true);}}/>}</div>
|
||||
{!available&&<p className="notification-unavailable">{t('启用此插件后可使用通知操作。')}</p>}
|
||||
{!!entry.notification.actions?.length&&<footer className="toast-actions">{entry.notification.actions.map(a=>{
|
||||
const label=entry.update?(a.id==='ignore'?t('忽略'):t('打开更新')):a.label;
|
||||
return <button key={a.id} className={entry.update&&a.id==='open'?'notification-primary':''} aria-label={label} title={label} disabled={busy||!available} onClick={()=>void perform('notification.action',a.id)}><span className="notification-action-label">{label}</span>{entry.update&&a.id==='open'&&<IconArrowUpRight size={19} stroke={1.8}/>}</button>;
|
||||
})}</footer>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
</article>;
|
||||
}
|
||||
export function NotificationCenter({items,inbox=false,refresh,plugins={},safeMode=false,controls}:{items:NotificationEntry[];inbox?:boolean;refresh:()=>Promise<void>;plugins?:Record<string,{enabled:boolean}>;safeMode?:boolean;controls?:React.ReactNode}){
|
||||
const[now,setNow]=useState(Date.now());
|
||||
useEffect(()=>{if(inbox)return;const timer=setInterval(()=>setNow(Date.now()),250);return()=>clearInterval(timer);},[inbox]);
|
||||
const visible=items.filter(n=>inbox?n.inInbox:n.toast&&(n.expiresAt===null||n.expiresAt>now)).sort((a,b)=>b.createdAt-a.createdAt);
|
||||
return <section className={inbox?'notification-inbox':'toasts'} aria-label={t('通知')}>
|
||||
{inbox&&<header className="inbox-heading"><div><h1>{t('通知')}</h1><p>{t('收件箱')}<span>{visible.length}</span></p></div><IconInbox size={30} stroke={1.8}/></header>}
|
||||
{inbox&&controls}
|
||||
{visible.map(entry=><NotificationCard key={`${entry.plugin}:${entry.notification.id}:${entry.createdAt}`} entry={entry} inbox={inbox} now={now} refresh={refresh} available={!!entry.update||!!plugins[entry.plugin]?.enabled&&!safeMode}/>)}
|
||||
{inbox&&!visible.length&&<div className="inbox-empty"><IconInbox size={44} stroke={1.5}/><h2>{t('收件箱为空')}</h2><p>{t('需要保留的通知会出现在这里。')}</p></div>}
|
||||
</section>;
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ export type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:b
|
||||
export type Presentation={authorUrl?:string|null;documentationUrl?:string|null;homepage?:string|null;icon?:string|null;details?:string;tags?:string[];screenshots?:string[];changelog?:string;dependencies?:Record<string,string|{version:string;source:string}>;optionalDependencies?:Record<string,string|{version:string;source:string}>;conflicts?:Record<string,string>;exclusiveResources?:string[]};
|
||||
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;url:string;sha256:string;runAs?:RuntimeUser|null};
|
||||
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};error?:string;cached?:boolean;fetchedAt?:number};
|
||||
export type Job={id:string;kind:string;phase:string;received:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
|
||||
export type Job={id:string;kind:string;phase:string;received:number;verified?:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
|
||||
export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
|
||||
const started=await api<{job:string}>(method,params);
|
||||
const deadline=Date.now()+35*60*1000;
|
||||
|
||||
@@ -9,6 +9,7 @@ input:not([type=checkbox]):not([type=file]),textarea,select{width:100%;padding:1
|
||||
.settings-card{border-top:1px solid var(--line);padding:26px 0;margin:0 0 16px}.settings-card h2{color:#e5e7eb;font-size:24px;font-weight:550}.settings-card>p,.source-form,.settings-card>label{max-width:860px}.settings-card .sub{line-height:1.6}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:16px}.row h2{flex:1}.source-row{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--line)}.source-row>div:first-child{flex:1;min-width:0}.source-row .row{margin-top:0;max-width:470px}.source-row b{margin-right:14px;font-weight:550}.source-row p{margin:8px 0}.source-row small{font-size:16px}.source-url{display:block;margin:8px 0;color:var(--muted);font-size:17px;overflow-wrap:anywhere}.source-form{padding-top:28px}.confirm-box,.release-card{padding:22px;border:1px solid #565d68;background:#202328;margin-top:24px;border-radius:4px}.confirm-box p{color:#c9cfd9}.settings-card details{font-size:18px;margin-top:10px}.settings-card summary{cursor:pointer;padding:8px 0}.plugin{padding:18px 0;border-bottom:1px solid var(--line)}.plugin>small{display:block;overflow-wrap:anywhere}
|
||||
.empty-state{min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:38px;text-align:center;gap:16px}.empty-state h2{font-size:26px;margin:0}.empty-state p{color:var(--muted);font-size:19px;max-width:520px;margin:0}.empty-symbol{color:#808994}.empty{padding:36px;text-align:center;color:var(--muted)}.page-title{padding:16px 24px;display:flex;align-items:center;gap:12px;flex:none;border-bottom:1px solid var(--line)}.page-title strong{flex:1;font-size:24px}.quick:has(.page-title) .shell-header{display:none}iframe{width:100%;flex:1;min-height:0;border:0;background:var(--bg)}.error,.banner{padding:14px 16px;margin:12px 0;border-radius:3px;line-height:1.5;overflow-wrap:anywhere;font-size:18px;flex:none;border-left:3px solid #929ca9;background:#252930;color:#d5dbe4}.error{border-color:#db9797;background:#3a292d;color:#f1b8b8}.app>.error,.app>.banner{margin:12px 26px}.error button{float:right;min-height:30px;padding:0 8px;background:transparent;border:0}.banner p{margin:8px 0}
|
||||
.modal{position:absolute;top:50%;bottom:auto;transform:translateY(-50%);max-height:calc(100% - 56px);left:max(24px,calc((100% - 780px)/2));right:max(24px,calc((100% - 780px)/2));padding:30px;background:#202328;border:1px solid #626974;border-radius:6px;overflow:auto;box-shadow:0 0 0 2000px #000a;z-index:20}.modal h2{font-size:28px;margin-bottom:16px}.modal h3{margin-top:24px}.modal button{margin:8px 8px 8px 0}.modal .source-row{display:block}.modal .row{margin:12px 0}.modal code{display:block;font-size:17px;overflow-wrap:anywhere;background:#141619;padding:16px;border-radius:3px}.modal pre{font:17px/1.6 monospace;white-space:pre-wrap;overflow-wrap:anywhere}.modal-close{float:right}.transfer{z-index:30;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px}.transfer>p{margin:0}progress{width:100%;height:10px;accent-color:var(--blue)}.download-progress{margin:24px 0}.system-updates>h2{display:none}.system-updates .error small{display:block;margin-top:8px;color:inherit}.manage-dialog>button{min-width:150px}.manage-dialog>.danger{display:block}
|
||||
.update-confirmation{position:fixed;margin:0;width:auto;color:var(--text);box-shadow:none}.update-confirmation::backdrop{background:#000a}
|
||||
html.notification-view,html.notification-view body{background:transparent}.toasts{height:100%;overflow:auto;padding:8px;background:transparent;font-size:18px}.toast{padding:16px;background:#202328;border:1px solid #454b55;border-radius:8px;margin-bottom:8px}.toast-heading{display:flex;align-items:center;gap:10px}.toast-heading>div{flex:1;min-width:0}.toast-heading small{font-size:14px}.toast h2{font-size:21px;line-height:1.3;margin:2px 0 0;overflow-wrap:anywhere}.toast-close{min-width:36px;min-height:36px;padding:6px;border:0;background:transparent;color:var(--muted)}.toast-content{display:flex;align-items:flex-start;gap:12px;margin-top:12px}.toast-content img{width:80px;height:64px;object-fit:contain;flex:none}.toast-content p{margin:0;line-height:1.45;max-height:104px;overflow:auto;overflow-wrap:anywhere;flex:1}.toast-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}.toast-actions button{min-width:44px;min-height:40px;padding:6px 10px;font-size:20px;border-radius:4px}
|
||||
@media(max-width:1000px){.manager .workspace{grid-template-columns:186px minmax(0,1fr)}.manager nav{padding:20px 8px}.manager nav button{padding:12px 10px;font-size:18px}.content{padding:24px}.row-state{width:90px}.row-version{width:85px}.list-columns{grid-template-columns:minmax(0,1fr) 90px 85px 184px}.list-heading{flex-wrap:wrap}.manager .plugin-copy small{white-space:normal}.source-row{flex-direction:column}.store-version{display:none}}
|
||||
@media(max-width:700px){.manager .workspace{display:flex}.manager nav{flex-direction:row;padding:8px;overflow:auto;border-right:0;border-bottom:1px solid var(--line)}.manager nav button{white-space:nowrap}.nav-foot{display:none}.shell-header{padding:14px 18px}.header-label{display:none}.shell-header .primary{font-size:18px}.content{padding:18px}.list-columns,.row-state,.row-version,.manager .plugin-switch{display:none}.manager .plugin-meta{display:flex}.list-heading .search-field{width:100%}.store-card{gap:14px;padding:18px 0}.store-card>.badge{min-width:65px;font-size:15px}.store-card>.plugin-avatar{width:64px!important;height:64px!important}.store-card>svg{display:none}.store-copy p{font-size:17px}.store-copy h2{font-size:22px}.store-filters .search-field{min-width:100%}.detail-heading{flex-wrap:wrap}.detail-heading .primary{width:100%}.modal{left:14px;right:14px;max-height:calc(100% - 28px);padding:22px}}
|
||||
@@ -121,3 +122,52 @@ button{overflow-wrap:anywhere}
|
||||
.store-title-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.store-title-row .store-title{min-width:0;overflow-wrap:anywhere}.store-detail>.run-user-notice{margin:20px 0 24px}.store-title-row .run-user-badge{font-size:15px}
|
||||
|
||||
.quick-library-button{flex-direction:column;gap:2px;padding:4px 8px}.quick-library-button span{font-size:12px;line-height:16px;font-weight:400;white-space:nowrap}
|
||||
|
||||
/* Notification redesign preserves Framely's charcoal / blue palette and 8px surface, 5px control radii. */
|
||||
.notification-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;margin-left:6px;border-radius:5px;background:var(--blue);color:#17212a;font-size:12px;font-weight:650}
|
||||
.notification-card{padding:18px;background:var(--surface);border:1px solid #454b55;border-radius:8px;color:var(--text)}
|
||||
.notification-heading{display:flex;align-items:center;gap:12px;margin-bottom:14px}.notification-source-icon{display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;color:var(--blue)}.notification-origin{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px}.notification-origin b{font-size:16px;font-weight:600;overflow-wrap:anywhere}.notification-origin time,.notification-countdown{color:var(--muted);font-size:14px;font-variant-numeric:tabular-nums}.notification-dismiss{flex:none;width:44px;height:44px;min-height:44px;padding:10px;border:0;background:transparent;color:#bec5d0}.notification-dismiss:hover:not(:disabled){background:#34383f;color:var(--text)}
|
||||
.notification-message{min-height:0;overflow:auto}.notification-message h2{font-size:23px;line-height:1.3;margin:0 0 8px;font-weight:600;overflow-wrap:anywhere}.notification-message p{font-size:18px;line-height:1.55;color:#c6cbd4;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.notification-image{display:block;max-width:100%;max-height:180px;object-fit:contain;border-radius:5px;margin-top:14px}.notification-card .toast-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px;justify-content:flex-start}.notification-card .toast-actions button{margin:0;min-height:44px;font-size:17px;font-weight:550;padding:9px 14px;color:#e3e9f0;border:1px solid #535b66;border-radius:5px;background:#2b3037;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}.notification-card .toast-actions .notification-primary{background:var(--blue);border-color:var(--blue);color:#17212a}.notification-card .toast-actions .notification-primary:hover:not(:disabled){background:#b2d8f4}.notification-card button:active:not(:disabled){transform:scale(.98)}.notification-unavailable{font-size:16px;color:var(--muted);margin:12px 0 0}
|
||||
.toasts{padding:8px;height:100%;overflow:auto;background:transparent}.toasts .toast{display:flex;flex-direction:column;max-height:344px;margin:0 0 8px}.toasts .notification-heading,.toasts .toast-actions{flex:none}.toasts .notification-message h2{font-size:22px}.toasts .notification-message p{font-size:18px;line-height:1.4}.toasts .notification-card .error{font-size:15px;margin-bottom:0}
|
||||
.notification-inbox{padding:12px 4px 4px}.inbox-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 8px 20px;color:var(--blue)}.inbox-heading h1{font-size:27px;color:var(--text);letter-spacing:-.4px}.inbox-heading p{display:flex;align-items:center;gap:10px;font-size:16px;color:var(--muted);margin:4px 0 0}.inbox-heading p span{font-variant-numeric:tabular-nums;color:#c6cbd4}.notification-inbox .notification-card{margin-bottom:12px}.inbox-empty{min-height:300px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:28px 16px;text-align:center;color:var(--muted)}.inbox-empty h2{color:var(--text);font-size:24px;margin:0}.inbox-empty p{font-size:18px;margin:0;max-width:28ch}
|
||||
.automatic-update-settings{margin:24px 0;padding:18px 0;border-block:1px solid var(--line)}.automatic-update-settings>label{display:flex;align-items:center;gap:12px;margin:0 0 12px}.automatic-update-settings input[type=checkbox]{margin:0;flex:none}.automatic-update-settings .framely-select{max-width:350px}.automatic-update-settings .sub{margin:12px 0 0}
|
||||
@media(max-width:700px){.notification-card{padding:16px}.notification-card .toast-actions{gap:8px}.notification-card .toast-actions button{flex:1}.notification-origin{align-items:flex-start;flex-direction:column;gap:2px}.notification-inbox{padding:10px 0}.quick nav button{font-size:16px;padding:10px 4px}.quick nav button>span{white-space:nowrap}.automatic-update-settings .framely-select{max-width:none}}
|
||||
|
||||
.notification-action-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* Headset popups stay compact; the inbox keeps the full reading layout. */
|
||||
.toasts .notification-card{padding:10px 12px;max-height:184px;flex-shrink:0}
|
||||
.toasts .notification-heading{gap:8px;margin-bottom:6px;min-height:32px}
|
||||
.toasts .notification-source-icon{width:20px;height:20px}
|
||||
.toasts .notification-source-icon>svg,.toasts .notification-source-icon .brand-mark,.toasts .notification-source-icon .brand-mark img{width:20px;height:20px}
|
||||
.toasts .notification-origin{flex-direction:row;flex-wrap:nowrap;align-items:center;gap:8px}
|
||||
.toasts .notification-origin b{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.toasts .notification-countdown{font-size:12px;flex:none;white-space:nowrap}
|
||||
.toasts .notification-dismiss{width:40px;height:40px;min-height:40px;padding:10px}
|
||||
.toasts .notification-dismiss svg{width:20px;height:20px}
|
||||
.toasts .notification-message{max-height:76px;overflow:auto}
|
||||
.toasts .notification-message h2{font-size:18px;line-height:1.25;margin:0 0 4px}
|
||||
.toasts .notification-message p{font-size:15px;line-height:1.35}
|
||||
.toasts .notification-image{max-height:56px;margin-top:6px}
|
||||
.toasts .notification-card .toast-actions{gap:6px;margin-top:10px}
|
||||
.toasts .notification-card .toast-actions button{flex:0 1 auto;min-width:64px;min-height:44px;padding:8px 12px;font-size:14px}
|
||||
.toasts .notification-card .error,.toasts .notification-unavailable{font-size:13px;margin:6px 0 0;max-height:34px;overflow:auto;flex-shrink:0}
|
||||
|
||||
/* Notification controls share the existing palette; rows keep policy columns aligned. */
|
||||
.notification-option{display:flex;align-items:center;gap:14px;padding:18px 0}
|
||||
.manager nav button{font-size:18px;white-space:nowrap}
|
||||
.notification-option>div{flex:1;min-width:0}.notification-option b{font-size:19px;font-weight:550}.notification-option p{font-size:16px;line-height:1.5;color:var(--muted);margin:5px 0 0}.notification-option>svg{flex:none;color:var(--blue)}
|
||||
.notification-global-options{width:100%;border-block:1px solid var(--line);margin-bottom:30px}.notification-global-options>.notification-option+.notification-option{border-top:1px solid var(--line)}
|
||||
.notification-switch{display:flex;align-items:center;justify-content:center;flex:none;width:60px;min-width:60px;min-height:48px;padding:6px 4px;background:transparent;border:0}
|
||||
.notification-switch>span{position:relative;display:block;width:48px;height:28px;border-radius:16px;background:#2a2e34;border:1px solid #626770}.notification-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:20px;height:20px;border-radius:50%;background:#adb4bf}.notification-switch.on>span{background:#445b69;border-color:#7697aa}.notification-switch.on>span:after{left:23px;background:#e5f0f8}.notification-switch:hover:not(:disabled){background:#252a30}.notification-switch:active:not(:disabled){transform:scale(.98)}
|
||||
.notification-settings{width:100%;max-width:1050px}.notification-plugin-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:12px}.notification-plugin-heading h2{font-size:23px;margin:0}.notification-plugin-heading .search-field{width:320px}.notification-settings-help{font-size:16px;color:var(--muted);line-height:1.5;margin:0 0 20px}
|
||||
.notification-plugin-columns,.notification-plugin-row{display:grid;grid-template-columns:minmax(0,1fr) 80px 80px;align-items:center;gap:12px}.notification-plugin-columns{font-size:15px;color:var(--muted);padding:0 8px 10px}.notification-plugin-columns>span:not(:first-child){text-align:center}.notification-plugin-row{min-height:80px;padding:12px 8px;border-radius:5px}.notification-plugin-row:nth-child(odd){background:#1c1f23}.notification-plugin-row>.notification-switch{justify-self:center}.notification-plugin-name{display:flex;align-items:center;gap:12px;min-width:0}.notification-plugin-name>div{min-width:0}.notification-plugin-name b{display:block;font-size:18px;font-weight:550;overflow-wrap:anywhere}.notification-plugin-name small{display:block;font-size:14px;color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
|
||||
.inbox-permission{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:4px 12px;margin:0 0 18px}.inbox-permission .notification-option{gap:8px;padding:6px 0}.inbox-permission .notification-option b{font-size:17px}.inbox-permission .notification-option p{font-size:14px}.notification-settings-link{width:40px;min-height:44px;flex:none;padding:8px;border:0;background:transparent;color:#bec5d0}.notification-settings-link:hover{background:#34383f}
|
||||
@media(max-width:700px){.notification-plugin-heading{flex-wrap:wrap;gap:12px}.notification-plugin-heading .search-field{width:100%}.notification-plugin-columns,.notification-plugin-row{grid-template-columns:minmax(0,1fr) 60px 60px;gap:4px}.notification-plugin-name{gap:8px}.notification-plugin-name b{font-size:16px}.notification-plugin-name .plugin-avatar{width:32px!important;height:32px!important}.notification-option{gap:8px}.notification-option p{font-size:15px}.notification-global-options{margin-bottom:24px}}
|
||||
|
||||
.quick-update-entry{margin-top:10px}
|
||||
|
||||
.notification-global-options>.notification-option{display:grid;grid-template-columns:minmax(0,1fr) 80px;gap:12px;padding:18px 8px}.notification-global-options .notification-switch{justify-self:center}
|
||||
@media(max-width:700px){.notification-global-options>.notification-option{grid-template-columns:minmax(0,1fr) 60px;gap:4px}}
|
||||
|
||||
.toasts .notification-card button:active:not(:disabled){transform:none}
|
||||
+54
-11
@@ -1,5 +1,5 @@
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import React,{useEffect,useId,useLayoutEffect,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {runJob,Job,compareVersions} from './store';
|
||||
import {Select} from './localized-select';
|
||||
@@ -8,10 +8,18 @@ type UpdateSource={url:string};
|
||||
export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<void>}){
|
||||
const source:UpdateSource|null=status.database.updateSource??null;
|
||||
const channel:UpdateChannel=status.database.updateChannel??'stable';
|
||||
const automatic=status.database.updateCheck??{enabled:true,intervalHours:6};
|
||||
const[error,setError]=useState(''),[busy,setBusy]=useState(false),[job,setJob]=useState<Job|null>(null),[release,setRelease]=useState<any>(null),[ready,setReady]=useState(false),[checked,setChecked]=useState(false),[confirm,setConfirm]=useState<'apply'|'rollback'|null>(null),[installing,setInstalling]=useState(false);
|
||||
const confirmation=useRef<HTMLDialogElement>(null),cancelConfirmation=useRef<HTMLButtonElement>(null),confirmationTitle=useId(),confirmationDescription=useId();
|
||||
useLayoutEffect(()=>{
|
||||
const dialog=confirmation.current;if(!confirm||!dialog)return;
|
||||
const previous=document.activeElement as HTMLElement|null;
|
||||
dialog.showModal();cancelConfirmation.current?.focus();
|
||||
return()=>{dialog.close();if(previous?.isConnected)previous.focus();};
|
||||
},[confirm]);
|
||||
function reset(){setRelease(null);setReady(false);setChecked(false);setConfirm(null);}
|
||||
useEffect(reset,[source?.url,channel]);
|
||||
const progress=status.systemUpdate;
|
||||
const progress=job?.kind==='system.download'?job:status.systemUpdate;
|
||||
const older=release&&compareVersions(release.targetVersion??release.version,status.version)===-1;
|
||||
async function switchChannel(value:string){
|
||||
if(!source||busy||installing||value===channel)return;
|
||||
@@ -20,12 +28,36 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
catch(e){setError(String(e));}
|
||||
finally{await refresh();setBusy(false);}
|
||||
}
|
||||
async function saveAutomatic(settings:{enabled:boolean;intervalHours:number}){
|
||||
if(busy||installing)return;setBusy(true);setError('');
|
||||
try{await api('system.check.settings.save',{settings});await refresh();}
|
||||
catch(e){setError(String(e));}finally{setBusy(false);}
|
||||
}
|
||||
async function run(method:string,params:unknown={},onDone?:(value:any)=>void){
|
||||
setBusy(true);setError('');
|
||||
try{const v=await runJob(method,params,setJob,'system.job.status');onDone?.(v.result);}
|
||||
catch(e){setError(String(e));}
|
||||
finally{setBusy(false);setJob(null);await refresh();}
|
||||
}
|
||||
async function confirmUpdate(){
|
||||
if(!confirm||busy||installing)return;
|
||||
const action=confirm,version=release?.version;
|
||||
setConfirm(null);setBusy(true);setError('');
|
||||
let started=false;
|
||||
try{
|
||||
if(action==='apply'&&!ready){
|
||||
await runJob('system.download.start',{},setJob,'system.job.status');
|
||||
setReady(true);setJob(null);
|
||||
}
|
||||
setInstalling(true);
|
||||
await api(action==='apply'?'system.apply':'system.rollback',{approve:true,version});
|
||||
started=true;
|
||||
}catch(e){setError(String(e));setInstalling(false);}
|
||||
finally{
|
||||
setBusy(false);setJob(null);
|
||||
if(!started)await refresh().catch(()=>{});
|
||||
}
|
||||
}
|
||||
return <section className="settings-card system-updates">
|
||||
<h2>{t('Framely 更新')}</h2>
|
||||
<p>{t('当前版本:')}<b>{status.build??status.version}</b></p>
|
||||
@@ -37,9 +69,17 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);});}}>{busy?t('处理中…'):t('检查更新')}</button>
|
||||
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
|
||||
</div>
|
||||
<div className="automatic-update-settings">
|
||||
<label><input type="checkbox" role="switch" checked={automatic.enabled} disabled={busy||installing||!source} onChange={e=>void saveAutomatic({...automatic,enabled:e.target.checked})}/><span>{t('自动检查更新')}</span></label>
|
||||
<label>{t('检查间隔')}</label><Select label={t('检查间隔')} value={String(automatic.intervalHours)} disabled={busy||installing||!source||!automatic.enabled} onChange={value=>void saveAutomatic({...automatic,intervalHours:Number(value)})} options={[...new Set([1,6,12,24,automatic.intervalHours])].sort((a,b)=>a-b).map(hours=>({value:String(hours),label:hours===1?t('每小时'):t('每 {0} 小时',{0:hours})}))}/>
|
||||
<p className="sub">{t('发现新版本时发送通知,您可以忽略提醒或打开更新页面。')}</p>
|
||||
{status.automaticUpdate?.checking&&<p className="sub" role="status">{t('正在自动检查更新…')}</p>}
|
||||
{status.automaticUpdate?.error&&<p className="error">{t('自动检查未完成,将在下次检查时重试。')} {t(status.automaticUpdate.error)}</p>}
|
||||
</div>
|
||||
{checked&&!release&&<p className="banner" role="status">{t('所选渠道暂无可用发行版本。')}</p>}
|
||||
{job&&<p className="banner">{job.kind==='system.check'?t('正在读取更新清单…'):t('正在下载并校验发行包…')}</p>}
|
||||
{progress?.phase==='downloading'&&<div className="download-progress"><progress max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
|
||||
{job&&<p className="banner" role="status">{job.kind==='system.check'?t('正在读取更新清单…'):job.phase==='verifying'?t('下载完成,正在校验发行包…'):t('正在下载发行包…')}</p>}
|
||||
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
|
||||
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
|
||||
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
|
||||
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}
|
||||
{release&&<div className="release-card">
|
||||
@@ -47,18 +87,21 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
<p>{release.version===(status.build??status.version)?t('当前已是此发行版本'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
|
||||
{older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
|
||||
<p className="prose">{release.changelog||t('发布者未提供更新说明。')}</p>
|
||||
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>ready?setConfirm('apply'):void run('system.download.start',{},()=>setReady(true))}>{ready?t('安装已校验版本'):t('下载并校验')}</button>}
|
||||
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
|
||||
</div>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
{installing&&<p className="banner">{t('正在切换版本。Framely 入口会在服务恢复后重新出现。')}</p>}
|
||||
{confirm&&<div className="confirm-box">
|
||||
<h3>{confirm==='apply'?t('安装 Framely {0}?',{'0':release.version}):t('回滚 Framely?')}</h3>
|
||||
{confirm&&<dialog ref={confirmation} className="modal update-confirmation" aria-labelledby={confirmationTitle} aria-describedby={confirmationDescription} onCancel={e=>{e.preventDefault();setConfirm(null);}} onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setConfirm(null);}}}>
|
||||
<h2 id={confirmationTitle}>{confirm==='apply'?t('安装 Framely {0}?',{'0':release.version}):t('回滚 Framely?')}</h2>
|
||||
<div id={confirmationDescription}>
|
||||
{confirm==='apply'&&!ready&&<p>{t('确认后将下载并校验发行包,校验通过后自动安装。')}</p>}
|
||||
<p>{t('插件和数据会保留,Framely 界面会暂时关闭。SteamVR 不会被重启。')}</p>
|
||||
{confirm==='apply'&&older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
|
||||
<div className="row">
|
||||
<button className="primary" disabled={installing||busy} onClick={()=>void(async()=>{setInstalling(true);setError('');try{await api(confirm==='apply'?'system.apply':'system.rollback',{approve:true,version:release?.version});setConfirm(null);}catch(e){setError(String(e));setInstalling(false);}})()}>{confirm==='apply'?t('确认安装'):t('确认回滚')}</button>
|
||||
<button disabled={installing} onClick={()=>setConfirm(null)}>{t('取消')}</button>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="row">
|
||||
<button className="primary" disabled={installing||busy} onClick={()=>void confirmUpdate()}>{confirm==='apply'?(ready?t('确认安装'):t('确认下载并安装')):t('确认回滚')}</button>
|
||||
<button ref={cancelConfirmation} disabled={installing} onClick={()=>setConfirm(null)}>{t('取消')}</button>
|
||||
</div>
|
||||
</dialog>}
|
||||
</section>;
|
||||
}
|
||||
Reference in new issue
Block a user