Apply unified branding and configurable plugin memory limits
Prepare Framely 0.4.2-preview.6 and Installer 0.4.1-preview.4 with shared SVG artwork, platform icons, release packaging, and bilingual documentation.
No files matched your search
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.5"
|
||||
version = "0.4.2-preview.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.2-preview.5"
|
||||
version = "0.4.2-preview.6"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Framely
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/branding/framely-logo-light.svg">
|
||||
<img src="assets/branding/framely-logo.svg" alt="Framely" width="320" height="77">
|
||||
</picture>
|
||||
|
||||
[简体中文](README.md)
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Framely
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/branding/framely-logo-light.svg">
|
||||
<img src="assets/branding/framely-logo.svg" alt="Framely" width="320" height="77">
|
||||
</picture>
|
||||
|
||||
[English](README.en.md)
|
||||
|
||||
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Framely 品牌资源 / Branding assets
|
||||
|
||||
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为浅蓝底应用图标。字标已转成路径,无需安装字体。
|
||||
|
||||
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` is the application tile on sky blue. The wordmark uses vector paths and needs no font installation.
|
||||
|
||||
修改 SVG 后,在仓库根目录重新生成 PNG、Windows ICO 和原生入口图形,并一同提交生成文件:
|
||||
|
||||
After editing the SVGs, regenerate PNGs, the Windows ICO and native Dock artwork from the repository root, then commit the generated files together:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run branding:build
|
||||
```
|
||||
|
||||
macOS 打包时使用系统 `iconutil` 从 PNG 自动生成 `.icns`。运行时无需 Node.js;品牌资源采用仓库的 AGPL-3.0-only 许可证。
|
||||
|
||||
macOS packaging uses the system `iconutil` to generate `.icns` from the PNGs. Runtime does not require Node.js. These brand assets use the repository's AGPL-3.0-only license.
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" role="img" aria-labelledby="title desc" color="#17191c">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Charcoal pixel window sprite on a sky blue application tile.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="32" height="32" rx="3" fill="#93c5ed" shape-rendering="geometricPrecision"/>
|
||||
<path fill="#17191c" fill-rule="evenodd" transform="translate(3.4 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="384" viewBox="0 0 100 24" role="img" aria-labelledby="title desc" color="#93c5ed">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Pixel window sprite with the framely wordmark drawn as vector pixels.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
<path fill="currentColor" transform="translate(29 5.25) scale(1.5)" d="M2 0h3v1h-3Z M1 1h2v1h-2Z M1 2h2v1h-2Z M0 3h4v1h-4Z M1 4h2v1h-2Z M1 5h2v1h-2Z M1 6h2v1h-2Z M7 2h2v1h-2Z M10 2h2v1h-2Z M7 3h3v1h-3Z M7 4h2v1h-2Z M7 5h2v1h-2Z M7 6h2v1h-2Z M15 2h3v1h-3Z M17 3h2v1h-2Z M15 4h4v1h-4Z M14 5h2v1h-2Z M17 5h2v1h-2Z M15 6h4v1h-4Z M21 2h2v1h-2Z M24 2h1v1h-1Z M21 3h5v1h-5Z M21 4h2v1h-2Z M24 4h2v1h-2Z M21 5h2v1h-2Z M24 5h2v1h-2Z M21 6h2v1h-2Z M24 6h2v1h-2Z M29 2h3v1h-3Z M28 3h2v1h-2Z M31 3h2v1h-2Z M28 4h5v1h-5Z M28 5h2v1h-2Z M29 6h4v1h-4Z M35 0h2v1h-2Z M35 1h2v1h-2Z M35 2h2v1h-2Z M35 3h2v1h-2Z M35 4h2v1h-2Z M35 5h2v1h-2Z M36 6h3v1h-3Z M42 2h2v1h-2Z M45 2h2v1h-2Z M42 3h2v1h-2Z M45 3h2v1h-2Z M42 4h2v1h-2Z M45 4h2v1h-2Z M43 5h4v1h-4Z M45 6h2v1h-2Z M44 7h2v1h-2Z M42 8h3v1h-3Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="384" viewBox="0 0 100 24" role="img" aria-labelledby="title desc" color="#17191c">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Pixel window sprite with the framely wordmark drawn as vector pixels.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
<path fill="currentColor" transform="translate(29 5.25) scale(1.5)" d="M2 0h3v1h-3Z M1 1h2v1h-2Z M1 2h2v1h-2Z M0 3h4v1h-4Z M1 4h2v1h-2Z M1 5h2v1h-2Z M1 6h2v1h-2Z M7 2h2v1h-2Z M10 2h2v1h-2Z M7 3h3v1h-3Z M7 4h2v1h-2Z M7 5h2v1h-2Z M7 6h2v1h-2Z M15 2h3v1h-3Z M17 3h2v1h-2Z M15 4h4v1h-4Z M14 5h2v1h-2Z M17 5h2v1h-2Z M15 6h4v1h-4Z M21 2h2v1h-2Z M24 2h1v1h-1Z M21 3h5v1h-5Z M21 4h2v1h-2Z M24 4h2v1h-2Z M21 5h2v1h-2Z M24 5h2v1h-2Z M21 6h2v1h-2Z M24 6h2v1h-2Z M29 2h3v1h-3Z M28 3h2v1h-2Z M31 3h2v1h-2Z M28 4h5v1h-5Z M28 5h2v1h-2Z M29 6h4v1h-4Z M35 0h2v1h-2Z M35 1h2v1h-2Z M35 2h2v1h-2Z M35 3h2v1h-2Z M35 4h2v1h-2Z M35 5h2v1h-2Z M36 6h3v1h-3Z M42 2h2v1h-2Z M45 2h2v1h-2Z M42 3h2v1h-2Z M45 3h2v1h-2Z M42 4h2v1h-2Z M45 4h2v1h-2Z M43 5h4v1h-4Z M45 6h2v1h-2Z M44 7h2v1h-2Z M42 8h3v1h-3Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384" viewBox="0 0 24 24" role="img" aria-labelledby="title desc" color="#93c5ed">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Sky blue pixel window sprite for dark backgrounds.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 548 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384" viewBox="0 0 24 24" role="img" aria-labelledby="title desc" color="#17191c">
|
||||
<title id="title">Framely</title>
|
||||
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
|
||||
<g shape-rendering="crispEdges">
|
||||
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 482 B |
@@ -57,9 +57,18 @@
|
||||
| `autostart` | 默认 false,按需启动;true 表示启动时常驻 |
|
||||
| `restart` | `on-failure`(默认)或 `never` |
|
||||
| `restartLimit` | 连续失败阈值 1–10,默认 3 |
|
||||
| `memoryLimitMiB` | 内存上限,默认 512 MiB;1–4294967295 的整数,不能用 0 或 null 取消限制 |
|
||||
|
||||
省略 backend 可制作纯 UI 插件。系统/Python 第三方依赖不会自动安装。运行用户变化需用户确认,数据目录按运行身份区分,不自动迁移。
|
||||
|
||||
`backend.memoryLimitMiB` 映射到 systemd 的 `MemoryMax`,限制后端及其所有子进程的合计内存;它是上限,不会预分配内存。独立生命周期钩子沿用该值,没有后端的钩子使用 512 MiB。安装计划、CLI 和已安装插件管理页展示上限。省略或填写 512 时打包省略该字段,保持旧包兼容;使用其他值需要支持此字段的新版宿主及插件数据库。
|
||||
|
||||
例如,为较长回放声明 2 GiB:
|
||||
|
||||
```json
|
||||
{"backend": {"entry": "backend", "memoryLimitMiB": 2048}}
|
||||
```
|
||||
|
||||
## `ui`
|
||||
|
||||
`quickPage` 是可选包内页面 bundle 路径;`windows` 默认空对象,最多 8 个窗口,key 必须是合法 ID。每个窗口:
|
||||
|
||||
@@ -57,9 +57,18 @@ Legacy `category` is accepted only for compatibility and no longer exported; use
|
||||
| `autostart` | false by default for on-demand startup; true for resident startup |
|
||||
| `restart` | `on-failure` (default) or `never` |
|
||||
| `restartLimit` | Consecutive failure threshold 1–10, default 3 |
|
||||
| `memoryLimitMiB` | Memory limit, default 512 MiB; integer 1–4294967295, no 0/null for unlimited memory |
|
||||
|
||||
Omit backend for UI-only plugins. System/third-party Python dependencies are not installed automatically. Runtime-user changes require confirmation and use separate data directories without automatic migration.
|
||||
|
||||
`backend.memoryLimitMiB` maps to systemd `MemoryMax` for the combined memory of the backend and all its child processes. It is a ceiling, not a memory reservation. Independent lifecycle hooks inherit this limit; hooks without a backend use 512 MiB. The install plan, CLI and installed-plugin management view display the limit. Omitted or explicit 512 values are omitted when packing to preserve compatibility with older hosts; other values require a host and plugin database supporting this field.
|
||||
|
||||
For example, declare 2 GiB for a longer replay buffer:
|
||||
|
||||
```json
|
||||
{"backend": {"entry": "backend", "memoryLimitMiB": 2048}}
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
Optional `quickPage` points to a payload bundle. `windows` defaults to an empty map with at most eight windows; keys must be valid IDs:
|
||||
|
||||
@@ -36,8 +36,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.5`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.3`), `.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.6`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.4`), `.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.
|
||||
|
||||
@@ -45,11 +45,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.5
|
||||
git push origin v0.4.2-preview.5
|
||||
git tag v0.4.2-preview.6
|
||||
git push origin v0.4.2-preview.6
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.3
|
||||
git push origin installer-v0.4.1-preview.3
|
||||
git tag installer-v0.4.1-preview.4
|
||||
git push origin installer-v0.4.1-preview.4
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -18,6 +18,8 @@ Connect your computer and Frame to the same network. Enable developer mode and S
|
||||
4. Enable testing releases to select a Preview. Installer and device versions are independent.
|
||||
5. Confirm the device, version and operation on the maintenance page. Alternatively, select a local device archive and its external `SHA256SUMS`.
|
||||
|
||||
On Linux, run `bash install-desktop-entry.sh` from the extracted installer directory to add an entry with the Framely icon to your user application menu. The entry points to that directory; run the script again after moving it.
|
||||
|
||||
Downloads are checked locally and again on the device. Passwords are not saved in configuration. Observe startup and rendering on Frame after installation.
|
||||
|
||||
## Option 2: install from Frame's SSH terminal
|
||||
|
||||
@@ -40,8 +40,8 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.5`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.3`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.6`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.4`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
|
||||
@@ -51,12 +51,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.5
|
||||
git push origin v0.4.2-preview.5
|
||||
git tag v0.4.2-preview.6
|
||||
git push origin v0.4.2-preview.6
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.3
|
||||
git push origin installer-v0.4.1-preview.3
|
||||
git tag installer-v0.4.1-preview.4
|
||||
git push origin installer-v0.4.1-preview.4
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
4. 如果要安装 Preview,开启显示测试版;安装器版本与设备版版本相互独立。
|
||||
5. 在“安装与维护”确认设备、目标版本及操作,等待完成。也可选择本地运行包和对应的外部 `SHA256SUMS`。
|
||||
|
||||
Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`,把带 Framely 图标的入口添加到当前用户的应用菜单。入口指向解压目录;移动目录后需重新执行脚本。
|
||||
|
||||
安装器会校验下载内容,并在设备端再次校验。密码不保存到配置文件。连接后仍需在实际设备上观察 Framely 的启动和显示。
|
||||
|
||||
## 方式二:在 Frame 的 SSH 终端安装
|
||||
|
||||
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.3"
|
||||
version = "0.4.1-preview.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -2003,6 +2003,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"url",
|
||||
"winresource",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -8755,6 +8756,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||
dependencies = [
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wio"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.3"
|
||||
version = "0.4.1-preview.4"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -33,6 +33,9 @@ tempfile = "3"
|
||||
zeroize = "1"
|
||||
directories = "6"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "=0.1.31"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -56,10 +56,12 @@ 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.3`) 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.4`) 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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -56,10 +56,12 @@ 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.3`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.4`)触发 `.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`,把带图标的入口注册到当前用户的应用菜单;入口指向该目录,移动后需重新执行脚本。
|
||||
|
||||
所有自动测试和 Actions 构建只能验证软件及产物;仍需在真实电脑和 Frame 上完成扫描、登录、权限、升级回滚及卸载验收。
|
||||
|
||||
扫描会直接查询 `frame.local` 的 mDNS 地址记录,不要求设备广播 SSH 服务,也不依赖电脑的系统 DNS 支持 `.local`。可单独运行与界面相同的发现逻辑进行诊断:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../assets/branding/framely.ico");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
winresource::WindowsResource::new()
|
||||
.set_icon("../assets/branding/framely.ico")
|
||||
.set("ProductName", "Framely Installer")
|
||||
.set("FileDescription", "Framely Installer")
|
||||
.compile()
|
||||
.expect("Could not embed Framely Windows application icon");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Register the extracted Linux installer with its matching application icon.
|
||||
set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
python3 - "$base" <<'PY'
|
||||
import os, pathlib, shutil
|
||||
base = pathlib.Path(__import__('sys').argv[1])
|
||||
binary = base / 'framely-installer'
|
||||
if not binary.is_file():
|
||||
raise SystemExit('Run this script from an extracted Framely Linux installer archive.')
|
||||
data = pathlib.Path(os.environ.get('XDG_DATA_HOME') or pathlib.Path.home() / '.local/share')
|
||||
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')
|
||||
# Desktop entries parse backslash escapes before parsing the quoted Exec value.
|
||||
quoted = str(binary).replace('%', '%%').replace('\\', '\\\\')
|
||||
for character in ('"', '`', '$'):
|
||||
quoted = quoted.replace(character, '\\' + character)
|
||||
quoted = quoted.replace('\\', '\\\\')
|
||||
entry = apps / 'org.framely.installer.desktop'
|
||||
entry.write_text(f'''[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Framely Installer
|
||||
Name[zh_CN]=Framely 安装器
|
||||
Comment=Install and manage Framely on Steam Frame
|
||||
Comment[zh_CN]=安装和维护 Steam Frame 上的 Framely
|
||||
Exec=/usr/bin/env "{quoted}"
|
||||
Icon=org.framely.installer
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupWMClass=org.framely.installer
|
||||
''')
|
||||
print(f'Registered Framely Installer: {entry}')
|
||||
PY
|
||||
@@ -839,6 +839,7 @@ fn main() {
|
||||
gpui_kit::application()
|
||||
.with_assets(gpui_kit::assets::AllAssets)
|
||||
.run(|cx| {
|
||||
cx.set_app_identity("org.framely.installer", "Framely Installer");
|
||||
initialize_theme(cx);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
@@ -847,6 +848,7 @@ fn main() {
|
||||
})
|
||||
.detach();
|
||||
let options = WindowOptions {
|
||||
app_id: Some("org.framely.installer".into()),
|
||||
window_min_size: Some(size(px(980.), px(720.))),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Framely Installer".into()),
|
||||
|
||||
@@ -1160,24 +1160,12 @@ impl Render for Installer {
|
||||
.h_full()
|
||||
.px_5()
|
||||
.child(
|
||||
div()
|
||||
.w(px(26.))
|
||||
.h(px(26.))
|
||||
.border_2()
|
||||
.border_color(rgb(BLUE))
|
||||
.rounded(px(5.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div().w(px(10.)).h(px(10.)).bg(rgb(BLUE)).rounded(px(2.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_lg()
|
||||
.child("Framely"),
|
||||
svg()
|
||||
.data(include_bytes!("../../assets/branding/framely-logo.svg"))
|
||||
.w(px(150.))
|
||||
.h(px(36.))
|
||||
.flex_none()
|
||||
.text_color(rgb(BLUE)),
|
||||
)
|
||||
.child(div().text_sm().text_color(rgb(MUTED)).child("安装器"))
|
||||
.child(div().flex_1())
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Generated by tools/build-branding.mjs from assets/branding/framely-mark.svg.
|
||||
#pragma once
|
||||
namespace framely_brand {
|
||||
inline constexpr unsigned char mask[48 * 48] = {
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "notification_geometry.h"
|
||||
#include "keyboard_state.h"
|
||||
#include "keyboard_input.h"
|
||||
#include "brand_mask.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
#include <arpa/inet.h>
|
||||
@@ -73,17 +74,12 @@ static void execute(cef_frame_t* f,const std::string& code){if(!f||!f->is_valid(
|
||||
static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
std::vector<uint8_t> p(128*128*4);
|
||||
auto rounded=[](float x,float y,float half,float radius){float a=std::abs(x)-half+radius,b=std::abs(y)-half+radius;return std::hypot(std::max(a,0.f),std::max(b,0.f))+std::min(std::max(a,b),0.f)-radius;};
|
||||
auto circle=[](float x,float y,float cx,float cy){return std::hypot(x-cx,y-cy)-10.f;};
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
|
||||
float coverage=std::clamp(.5f-rounded(x-63.5f,y-63.5f,64.f,9.6f),0.f,1.f);
|
||||
float gx=(x-63.5f)*1.15f+71.f,gy=(y-63.5f)*1.15f+57.f;
|
||||
float qx=std::abs(gx-64.f)-23.f,qy=std::abs(gy-64.f)-23.f;
|
||||
float d=std::hypot(std::max(qx,0.f),std::max(qy,0.f))+std::min(std::max(qx,qy),0.f)-3.f;
|
||||
d=std::min(d,std::min(circle(gx,gy,64,34),circle(gx,gy,94,64)));
|
||||
d=std::max(d,std::max(-circle(gx,gy,38,64),-circle(gx,gy,64,90)));
|
||||
float mark=std::clamp(2.7f-std::abs(d),0.f,1.f);
|
||||
int gx=std::min(47,int((x+.5f)*48/128)),gy=std::min(47,int((y+.5f)*48/128));
|
||||
float mark=framely_brand::mask[gy*48+gx]/255.f;
|
||||
int i=(y*128+x)*4;
|
||||
const int normal[3]={14,20,27},highlight[3]={61,68,80},muted[3]={139,146,154},bright[3]={220,222,223};
|
||||
const int normal[3]={23,25,28},highlight[3]={61,68,80},muted[3]={147,197,237},bright[3]={240,241,243};
|
||||
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];float fg=(hover||active)?bright[c]:muted[c];p[i+c]=uint8_t(bg+(fg-bg)*mark);}
|
||||
// 32px-wide, 6px-high tab indicator is clipped to its upper 3px by the BarSurface.
|
||||
if(active){float qx=std::abs(x-63.5f)-20.8f,qy=std::abs(y-129.6f);float line=std::clamp(5.3f-std::hypot(std::max(qx,0.f),qy),0.f,1.f);const int blue[3]={26,159,255};for(int c=0;c<3;c++)p[i+c]=uint8_t(p[i+c]+(blue[c]-p[i+c])*line);}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"license": "AGPL-3.0-only",
|
||||
"name": "framely-workspace",
|
||||
"license": "AGPL-3.0-only",
|
||||
"workspaces": [
|
||||
"sdk",
|
||||
"ui",
|
||||
@@ -16,6 +16,7 @@
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"esbuild": "0.25.10",
|
||||
@@ -23,9 +24,9 @@
|
||||
}
|
||||
},
|
||||
"examples/showcase": {
|
||||
"license": "AGPL-3.0-only",
|
||||
"name": "@framely/showcase",
|
||||
"version": "0.4.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@framely/sdk": "0.4.0"
|
||||
}
|
||||
@@ -484,6 +485,234 @@
|
||||
"resolved": "ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
||||
"@resvg/resvg-js-android-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
||||
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-x64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
||||
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
||||
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
|
||||
@@ -595,18 +824,18 @@
|
||||
}
|
||||
},
|
||||
"sdk": {
|
||||
"license": "AGPL-3.0-only",
|
||||
"name": "@framely/sdk",
|
||||
"version": "0.4.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"peerDependencies": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"license": "AGPL-3.0-only",
|
||||
"name": "@framely/ui",
|
||||
"version": "0.4.0"
|
||||
"version": "0.4.0",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
{
|
||||
"license":"AGPL-3.0-only",
|
||||
"name":"framely-workspace","private":true,"type":"module",
|
||||
"workspaces":["sdk","ui","examples/showcase"],
|
||||
"scripts":{"build":"node tools/build-ui.mjs","typecheck":"tsc --noEmit","plugin:init":"node tools/plugin-dev.mjs init"},
|
||||
"dependencies":{"react":"19.2.0","react-dom":"19.2.0"},
|
||||
"devDependencies":{"@types/react":"19.2.0","@types/react-dom":"19.2.0","typescript":"5.9.3","esbuild":"0.25.10"}
|
||||
"license": "AGPL-3.0-only",
|
||||
"name": "framely-workspace",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"sdk",
|
||||
"ui",
|
||||
"examples/showcase"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node tools/build-ui.mjs",
|
||||
"branding:build": "node tools/build-branding.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"plugin:init": "node tools/plugin-dev.mjs init"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"esbuild": "0.25.10",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ fn run() -> Result<()> {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let items:Vec<_>=prepared.plan.items.iter().map(|i|json!({"id":i.manifest.id,"name":i.manifest.name,"version":i.manifest.version,"action":i.action,"runAs":i.manifest.run_as(),"source":i.source,"runAsChanged":i.run_as_changed})).collect();
|
||||
let items:Vec<_>=prepared.plan.items.iter().map(|i|json!({"id":i.manifest.id,"name":i.manifest.name,"version":i.manifest.version,"action":i.action,"runAs":i.manifest.run_as(),"memoryLimitMiB":i.manifest.memory_limit_mib(),"source":i.source,"runAsChanged":i.run_as_changed})).collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(
|
||||
|
||||
@@ -27,6 +27,19 @@ pub struct Backend {
|
||||
pub restart: RestartPolicy,
|
||||
#[serde(default = "restart_limit")]
|
||||
pub restart_limit: u32,
|
||||
#[serde(
|
||||
rename = "memoryLimitMiB",
|
||||
default = "default_memory_limit_mib",
|
||||
skip_serializing_if = "is_default_memory_limit_mib"
|
||||
)]
|
||||
pub memory_limit_mib: u32,
|
||||
}
|
||||
pub const DEFAULT_MEMORY_LIMIT_MIB: u32 = 512;
|
||||
fn default_memory_limit_mib() -> u32 {
|
||||
DEFAULT_MEMORY_LIMIT_MIB
|
||||
}
|
||||
fn is_default_memory_limit_mib(value: &u32) -> bool {
|
||||
*value == DEFAULT_MEMORY_LIMIT_MIB
|
||||
}
|
||||
fn restart_limit() -> u32 {
|
||||
3
|
||||
@@ -252,6 +265,10 @@ impl Manifest {
|
||||
}
|
||||
if let Some(b) = &self.backend {
|
||||
check(&b.entry)?;
|
||||
ensure!(
|
||||
b.memory_limit_mib > 0,
|
||||
"Backend memoryLimitMiB must be a positive integer"
|
||||
);
|
||||
ensure!((1..=10).contains(&b.restart_limit), "Invalid restart limit");
|
||||
ensure!(
|
||||
b.args.len() <= 64 && b.args.iter().all(|a| a.len() <= 4096 && !a.contains('\0')),
|
||||
@@ -317,6 +334,12 @@ impl Manifest {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn memory_limit_mib(&self) -> u32 {
|
||||
self.backend
|
||||
.as_ref()
|
||||
.map(|b| b.memory_limit_mib)
|
||||
.unwrap_or(DEFAULT_MEMORY_LIMIT_MIB)
|
||||
}
|
||||
pub fn run_as(&self) -> RunAs {
|
||||
self.backend
|
||||
.as_ref()
|
||||
|
||||
@@ -82,7 +82,7 @@ fn command(m: &Manifest, payload: &Path, manager: u32, logs: &Path, unit: &str)
|
||||
"KillMode=control-group".into(),
|
||||
"TimeoutStopSec=5".into(),
|
||||
"TasksMax=128".into(),
|
||||
"MemoryMax=512M".into(),
|
||||
format!("MemoryMax={}M", b.memory_limit_mib),
|
||||
"NoNewPrivileges=yes".into(),
|
||||
] {
|
||||
cmd.arg(format!("--property={p}"));
|
||||
@@ -145,6 +145,7 @@ pub fn hook(
|
||||
autostart: false,
|
||||
restart: RestartPolicy::Never,
|
||||
restart_limit: 3,
|
||||
memory_limit_mib: m.memory_limit_mib(),
|
||||
});
|
||||
let unit = format!("framely-hook-{}-{:032x}", m.id, rand::random::<u128>());
|
||||
let cmd = command(&launch, payload, manager, logs, &unit)?;
|
||||
|
||||
@@ -177,6 +177,41 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Public, compile-time branding only. No other assets bypass network login.
|
||||
let branding: Option<(&[u8], &str)> = match path.as_str() {
|
||||
"/assets/branding/framely-mark-light.svg" => Some((
|
||||
include_bytes!("../assets/branding/framely-mark-light.svg"),
|
||||
"image/svg+xml",
|
||||
)),
|
||||
"/assets/branding/framely-logo-light.svg" => Some((
|
||||
include_bytes!("../assets/branding/framely-logo-light.svg"),
|
||||
"image/svg+xml",
|
||||
)),
|
||||
"/assets/branding/framely-app-icon.svg" => Some((
|
||||
include_bytes!("../assets/branding/framely-app-icon.svg"),
|
||||
"image/svg+xml",
|
||||
)),
|
||||
"/assets/branding/framely-app-icon.png" => Some((
|
||||
include_bytes!("../assets/branding/framely-app-icon.png"),
|
||||
"image/png",
|
||||
)),
|
||||
"/assets/branding/framely.ico" | "/favicon.ico" => Some((
|
||||
include_bytes!("../assets/branding/framely.ico"),
|
||||
"image/x-icon",
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if r.method() == &Method::Get {
|
||||
if let Some((data, mime)) = branding {
|
||||
r.respond(
|
||||
Response::from_data(data)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("X-Content-Type-Options", "nosniff"))
|
||||
.with_header(header("Cache-Control", "no-cache")),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let configured = remote && self.core("network.password.configured", json!({}))? == true;
|
||||
if remote && (!configured || !self.network_authorized(&r)) {
|
||||
let status = self.core("status", json!({}))?;
|
||||
@@ -291,7 +326,7 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
if r.method() == &Method::Get && (path == "/" || path == "/manager") {
|
||||
r.respond(Response::from_string(if configured { NETWORK_LOGIN } else { NETWORK_SETUP }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
|
||||
r.respond(Response::from_string(if configured { NETWORK_LOGIN } else { NETWORK_SETUP }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
|
||||
} else {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(401))
|
||||
@@ -1138,8 +1173,8 @@ fn network_ipv4_addresses() -> Vec<String> {
|
||||
addresses.into_iter().collect()
|
||||
}
|
||||
|
||||
const NETWORK_SETUP: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely · 设置访问密码</title><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}</style><main><h1>设置访问密码</h1><p>首次访问网络管理面板,请设置至少 8 个字符的密码。保存后使用该密码登录。</p><form method="post" action="/setup"><label>访问密码<input name="password" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><label>确认密码<input name="confirmPassword" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><button>保存访问密码</button></form></main></html>"#;
|
||||
const NETWORK_LOGIN: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely</title><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}</style><main><h1>Framely</h1><p>输入你在设备设置中配置的访问密码。</p><form method="post" action="/login"><label>访问密码<input name="password" type="password" required autocomplete="current-password" maxlength="512"></label><button>打开管理面板</button></form></main></html>"#;
|
||||
const NETWORK_SETUP: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely · 设置访问密码</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}.brand{display:block;width:200px;height:48px;margin-bottom:24px;image-rendering:pixelated}</style><main><img class="brand" src="/assets/branding/framely-logo-light.svg" alt="Framely"><h1>设置访问密码</h1><p>首次访问网络管理面板,请设置至少 8 个字符的密码。保存后使用该密码登录。</p><form method="post" action="/setup"><label>访问密码<input name="password" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><label>确认密码<input name="confirmPassword" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><button>保存访问密码</button></form></main></html>"#;
|
||||
const NETWORK_LOGIN: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}.brand{display:block;width:200px;height:48px;margin-bottom:24px;image-rendering:pixelated}</style><main><h1><img class="brand" src="/assets/branding/framely-logo-light.svg" alt="Framely"></h1><p>输入你在设备设置中配置的访问密码。</p><form method="post" action="/login"><label>访问密码<input name="password" type="password" required autocomplete="current-password" maxlength="512"></label><button>打开管理面板</button></form></main></html>"#;
|
||||
|
||||
fn network_listener(agent: Arc<Agent>, keep_running: impl Fn() -> bool) {
|
||||
let mut server: Option<Server> = None;
|
||||
@@ -1607,6 +1642,28 @@ mod tests {
|
||||
let page = ureq::get(&origin).call().unwrap().into_string().unwrap();
|
||||
assert!(page.contains("设置访问密码"));
|
||||
assert!(page.contains("action=\"/setup\""));
|
||||
assert!(page.contains("/assets/branding/framely-logo-light.svg"));
|
||||
let logo = ureq::get(&format!("{origin}/assets/branding/framely-logo-light.svg"))
|
||||
.call()
|
||||
.unwrap();
|
||||
assert_eq!(logo.header("Content-Type"), Some("image/svg+xml"));
|
||||
assert_eq!(logo.header("X-Content-Type-Options"), Some("nosniff"));
|
||||
assert_eq!(
|
||||
logo.into_string().unwrap(),
|
||||
include_str!("../assets/branding/framely-logo-light.svg")
|
||||
);
|
||||
let icon = ureq::get(&format!("{origin}/favicon.ico")).call().unwrap();
|
||||
assert_eq!(icon.header("Content-Type"), Some("image/x-icon"));
|
||||
for path in [
|
||||
"/assets/app.js",
|
||||
"/assets/branding/../app.js",
|
||||
"/assets/branding/unknown.svg",
|
||||
] {
|
||||
assert!(matches!(
|
||||
ureq::get(&format!("{origin}{path}")).call(),
|
||||
Err(ureq::Error::Status(401, _))
|
||||
));
|
||||
}
|
||||
assert!(!page.contains(password.as_str()));
|
||||
assert!(ureq::post(&format!("{origin}/api"))
|
||||
.set("Origin", &origin)
|
||||
|
||||
@@ -164,6 +164,48 @@ fn malicious_archive_paths_links_and_duplicates_are_rejected() {
|
||||
.unwrap();
|
||||
assert!(package::verify(&zip.finish().unwrap().into_inner()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn backend_memory_limit_defaults_validation_and_round_trip() {
|
||||
let base = json!({"schemaVersion":1,"apiVersion":1,"id":"test.memory","name":"Memory","author":"Dev","version":"1","backend":{"entry":"backend"},"files":{"backend":package::digest(b"x")}});
|
||||
let manifest: Manifest = serde_json::from_value(base.clone()).unwrap();
|
||||
manifest.validate().unwrap();
|
||||
assert_eq!(manifest.memory_limit_mib(), 512);
|
||||
// Preserve the old serialized shape when the default is used.
|
||||
assert!(serde_json::to_value(&manifest).unwrap()["backend"]
|
||||
.get("memoryLimitMiB")
|
||||
.is_none());
|
||||
for limit in [1, 512, 2048, u32::MAX] {
|
||||
let mut value = base.clone();
|
||||
value["backend"]["memoryLimitMiB"] = json!(limit);
|
||||
let manifest: Manifest = serde_json::from_value(value).unwrap();
|
||||
manifest.validate().unwrap();
|
||||
let packed = serde_json::to_value(&manifest).unwrap();
|
||||
let restored: Manifest = serde_json::from_value(packed).unwrap();
|
||||
assert_eq!(restored.memory_limit_mib(), limit);
|
||||
}
|
||||
for limit in [
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!("2048"),
|
||||
json!(true),
|
||||
json!(null),
|
||||
json!(4294967296u64),
|
||||
] {
|
||||
let mut value = base.clone();
|
||||
value["backend"]["memoryLimitMiB"] = limit;
|
||||
assert!(serde_json::from_value::<Manifest>(value).map_or(true, |m| m.validate().is_err()));
|
||||
}
|
||||
let mut ui_only = base;
|
||||
ui_only.as_object_mut().unwrap().remove("backend");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Manifest>(ui_only)
|
||||
.unwrap()
|
||||
.memory_limit_mib(),
|
||||
512
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_identity_and_invalid_identities() {
|
||||
let m = json!({"schemaVersion":1,"apiVersion":1,"id":"test","name":"Test","author":"Dev","version":"1","backend":{"entry":"backend"},"ui":{},"files":{"backend":package::digest(b"x")}});
|
||||
@@ -469,7 +511,7 @@ for line in sys.stdin:
|
||||
fs::write(payload.join("backend.py"), backend).unwrap();
|
||||
fs::write(payload.join("page.js"), "test").unwrap();
|
||||
let hook = json!({"entry":"backend.py"});
|
||||
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"test.lifecycle","name":"Lifecycle","author":"Test","version":version,"backend":{"entry":"backend.py","runAs":"steamos","autostart":version!="badrollback","restart":restart,"restartLimit":3},"lifecycle":{"onInstall":hook,"onUpdate":hook,"onUninstall":hook,"onCrashCleanup":hook,"onStart":true,"onStop":true,"timeoutSeconds":1},"ui":{"quickPage":"page.js"},"files":{}});
|
||||
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"test.lifecycle","name":"Lifecycle","author":"Test","version":version,"backend":{"entry":"backend.py","runAs":"steamos","autostart":version!="badrollback","restart":restart,"restartLimit":3,"memoryLimitMiB":if version=="1" {2048} else {512}},"lifecycle":{"onInstall":hook,"onUpdate":hook,"onUninstall":hook,"onCrashCleanup":hook,"onStart":true,"onStop":true,"timeoutSeconds":1},"ui":{"quickPage":"page.js"},"files":{}});
|
||||
let path = temp.path().join(format!("manifest-{version}.json"));
|
||||
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
let out = temp.path().join(format!("{version}.framely"));
|
||||
@@ -639,6 +681,25 @@ for line in sys.stdin:
|
||||
assert!(arguments.contains(&format!("User={}", unsafe { libc::geteuid() })));
|
||||
assert!(!arguments.contains("PrivateNetwork=yes"));
|
||||
assert!(arguments.contains("NoNewPrivileges=yes"));
|
||||
let launches: Vec<Vec<String>> = arguments
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
for launch in &launches {
|
||||
let expected = if launch
|
||||
.iter()
|
||||
.any(|arg| arg == "--setenv=FRAMELY_PLUGIN_VERSION=1")
|
||||
{
|
||||
"--property=MemoryMax=2048M"
|
||||
} else {
|
||||
"--property=MemoryMax=512M"
|
||||
};
|
||||
assert!(launch.iter().any(|arg| arg == expected));
|
||||
}
|
||||
assert!(launches.iter().any(|args| args
|
||||
.iter()
|
||||
.any(|arg| arg == "--setenv=FRAMELY_LIFECYCLE=onInstall")
|
||||
&& args.iter().any(|arg| arg == "--property=MemoryMax=2048M")));
|
||||
assert!(arguments.contains("RuntimeMaxSec=1"));
|
||||
core.shutdown();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../ui/src/style.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=(ms=600)=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const plugin={id:'tooru.termix',name:'Termix',author:'tooru',version:'0.1.7',description:'通过内网浏览器管理 Frame,提供终端、文件管理与远程桌面功能。',details:'所有设置位于快捷面板。打开大窗口可显示 Termix;内网浏览器仍需账号密码。Web 账号与系统账号分离。',homepage:'https://github.com/Termix-SSH/Termix',backend:{runAs:'steamos'}};
|
||||
const camera={id:'framely.framexr-camera',name:'FrameXR Camera',author:'tooru',version:'0.1.9',description:'在 VR 中显示设备相机画面,并控制相机采集与显示设置。',backend:{runAs:'root'}};
|
||||
const camera={id:'framely.framexr-camera',name:'FrameXR Camera',author:'tooru',version:'0.1.9',description:'在 VR 中显示设备相机画面,并控制相机采集与显示设置。',backend:{runAs:'root',memoryLimitMiB:2048}};
|
||||
function info(manifest:any,dependencies:any[]=[]){return {manifest,installedVersion:manifest===plugin?'0.1.6':undefined,plan:{items:[{manifest,action:manifest===plugin?'update':'install'},...dependencies.map(manifest=>({manifest,action:'install'}))],addSources:[],disable:[],enable:[manifest.id],affected:[manifest.id]}};}
|
||||
let installed=0,cancelled=0;
|
||||
async function show(value:any,overrides:any={}) {
|
||||
@@ -26,6 +26,7 @@ async function show(value:any,overrides:any={}) {
|
||||
let modal=await show(info(plugin));
|
||||
if(!modal.textContent?.includes(plugin.description)||!modal.querySelector('.plugin-links'))throw Error('plugin description or links missing');
|
||||
if(modal.querySelector('.is-root'))throw Error('steamos shows root warning');
|
||||
if(!modal.textContent?.includes('512 MiB'))throw Error('default memory limit missing');
|
||||
if(modal.textContent?.includes('启用依赖链:')||modal.textContent?.includes('其他受影响的插件:'))throw Error('redundant single-plugin effects');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_STEAMOS');
|
||||
(modal.querySelector('button.primary') as HTMLButtonElement).click();
|
||||
@@ -36,6 +37,7 @@ async function show(value:any,overrides:any={}) {
|
||||
modal=await show(info(camera));
|
||||
if(!modal.querySelector('.install-run-user.is-root')||!modal.textContent?.includes('请确认你信任此插件'))throw Error('root warning missing');
|
||||
if(modal.querySelector('.install-run-user')!.getBoundingClientRect().height>130&&innerWidth>700)throw Error('root warning occupies too much space');
|
||||
if(!modal.textContent?.includes('2048 MiB'))throw Error('custom memory limit missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_ROOT');
|
||||
modal=await show(info(plugin,[camera]));
|
||||
if(!modal.querySelector('.install-root-dependencies')||!modal.querySelector('.run-user-badge.is-root'))throw Error('root dependency hidden');
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Generate platform artwork from the approved SVGs. Commit these outputs so
|
||||
// native/installer builds do not need Node.js or an image tool at build time.
|
||||
import {readFile, writeFile, mkdir} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {Resvg} from '@resvg/resvg-js';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const branding = new URL('assets/branding/', root);
|
||||
const app = await readFile(new URL('framely-app-icon.svg', branding));
|
||||
const mark = await readFile(new URL('framely-mark.svg', branding));
|
||||
const logo = await readFile(new URL('framely-logo.svg', branding), 'utf8');
|
||||
await writeFile(new URL('framely-logo-light.svg', branding), logo.replace('color="#17191c"', 'color="#93c5ed"'));
|
||||
const sizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
|
||||
const images = new Map();
|
||||
await mkdir(new URL('icons/', branding), {recursive:true});
|
||||
for (const size of sizes) {
|
||||
const png = new Resvg(app, {fitTo:{mode:'width', value:size}}).render().asPng();
|
||||
images.set(size, png);
|
||||
await writeFile(new URL(`icons/${size}.png`, branding), png);
|
||||
}
|
||||
await writeFile(new URL('framely-app-icon.png', branding), images.get(512));
|
||||
|
||||
// ICO entries contain PNG images, supported by the targeted Windows versions.
|
||||
const icoSizes = sizes.filter(size => size <= 256);
|
||||
const directory = Buffer.alloc(6 + 16 * icoSizes.length);
|
||||
directory.writeUInt16LE(1, 2);
|
||||
directory.writeUInt16LE(icoSizes.length, 4);
|
||||
let offset = directory.length;
|
||||
for (const [index, size] of icoSizes.entries()) {
|
||||
const entry = 6 + index * 16;
|
||||
directory[entry] = directory[entry + 1] = size === 256 ? 0 : size;
|
||||
directory.writeUInt16LE(1, entry + 4);
|
||||
directory.writeUInt16LE(32, entry + 6);
|
||||
directory.writeUInt32LE(images.get(size).length, entry + 8);
|
||||
directory.writeUInt32LE(offset, entry + 12);
|
||||
offset += images.get(size).length;
|
||||
}
|
||||
await writeFile(new URL('framely.ico', branding), Buffer.concat([directory, ...icoSizes.map(size => images.get(size))]));
|
||||
|
||||
// A small alpha mask is enough for the native pixel-art Dock renderer. This
|
||||
// keeps the same SVG geometry without adding a runtime SVG/PNG dependency.
|
||||
const rendered = new Resvg(mark, {fitTo:{mode:'width', value:48}}).render();
|
||||
const mask = Array.from({length:48}, (_, y) => ' ' + Array.from({length:48}, (_, x) => rendered.pixels[(y * 48 + x) * 4 + 3]).join(',')).join(',\n');
|
||||
await mkdir(new URL('native/', root), {recursive:true});
|
||||
await writeFile(new URL('native/brand_mask.h', root), `// Generated by tools/build-branding.mjs from assets/branding/framely-mark.svg.\n#pragma once\nnamespace framely_brand {\ninline constexpr unsigned char mask[48 * 48] = {\n${mask}\n};\n}\n`);
|
||||
console.log(`Generated platform icons and native mask from ${fileURLToPath(branding)}`);
|
||||
@@ -5,6 +5,7 @@ await build({entryPoints:['ui/src/main.tsx'],bundle:true,minify:true,outfile:'ui
|
||||
await build({entryPoints:['sdk/src/bootstrap.ts'],bundle:true,minify:true,outfile:'ui/dist/assets/plugin-bootstrap.js'});
|
||||
await copyFile('ui/index.html','ui/dist/index.html');
|
||||
await cp('ui/locales','ui/dist/assets/locales',{recursive:true});
|
||||
await cp('assets/branding','ui/dist/assets/branding',{recursive:true});
|
||||
await mkdir('examples/showcase/payload',{recursive:true});
|
||||
await build({entryPoints:['examples/showcase/page.tsx'],bundle:true,minify:true,outfile:'examples/showcase/payload/page.js',define:{'process.env.NODE_ENV':'"production"'}});
|
||||
await copyFile('examples/showcase/backend.py','examples/showcase/payload/backend.py');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package the native installer; no external packaging tools needed."""
|
||||
"""Package the native installer, including platform application icons."""
|
||||
import os
|
||||
import pathlib
|
||||
import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tomllib
|
||||
import zipfile
|
||||
@@ -21,15 +22,30 @@ with __import__('tempfile').TemporaryDirectory() as temp:
|
||||
if platform.startswith('macos'):
|
||||
bundle = stage / 'Framely Installer.app/Contents'
|
||||
(bundle / 'MacOS').mkdir(parents=True)
|
||||
(bundle / 'Resources').mkdir()
|
||||
iconset = pathlib.Path(temp) / 'Framely.iconset'
|
||||
iconset.mkdir()
|
||||
for size in (16, 32, 128, 256, 512):
|
||||
for scale in (1, 2):
|
||||
suffix = '@2x' if scale == 2 else ''
|
||||
shutil.copy2(root / f'assets/branding/icons/{size * scale}.png', iconset / f'icon_{size}x{size}{suffix}.png')
|
||||
subprocess.run(['iconutil', '-c', 'icns', '-o', str(bundle / 'Resources/Framely.icns'), str(iconset)], check=True)
|
||||
shutil.rmtree(iconset)
|
||||
shutil.copy2(binary, bundle / 'MacOS/framely-installer')
|
||||
(bundle / 'MacOS/framely-installer').chmod(0o755)
|
||||
with (bundle / 'Info.plist').open('wb') as output:
|
||||
plistlib.dump({'CFBundleName': 'Framely Installer', 'CFBundleDisplayName': 'Framely Installer', 'CFBundleIdentifier': 'org.framely.installer', 'CFBundleExecutable': 'framely-installer', 'CFBundlePackageType': 'APPL', 'CFBundleVersion': version, 'CFBundleShortVersionString': version, 'NSHighResolutionCapable': True, 'LSMinimumSystemVersion': '11.0'}, output)
|
||||
plistlib.dump({'CFBundleName': 'Framely Installer', 'CFBundleDisplayName': 'Framely Installer', 'CFBundleIdentifier': 'org.framely.installer', 'CFBundleExecutable': 'framely-installer', 'CFBundleIconFile': 'Framely.icns', 'CFBundlePackageType': 'APPL', 'CFBundleVersion': version, 'CFBundleShortVersionString': version, 'NSHighResolutionCapable': True, 'LSMinimumSystemVersion': '11.0'}, output)
|
||||
else:
|
||||
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')
|
||||
(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.en.md', stage / 'README.en.md')
|
||||
shutil.copy2(root / 'LICENSE', stage / 'LICENSE')
|
||||
name = f'framely-installer-{version}-{platform}'
|
||||
if platform.startswith('linux'):
|
||||
|
||||
@@ -11,7 +11,7 @@ npm run build
|
||||
bash tools/build-native.sh "$cef"
|
||||
binary=${CARGO_TARGET_DIR:-target}/release/framely
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs README.md README.en.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding README.md README.en.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
stage="$base/release/framely-$build_id"
|
||||
[[ ! -e $stage ]] || { echo 'Release already exists.' >&2; exit 1; }
|
||||
mkdir -p "$stage"/{bin,lib/cef,lib/openvr,share/ui,share/licenses,tools}
|
||||
@@ -30,15 +30,19 @@ cp native/vendor/openvr/LICENSE "$stage/share/licenses/openvr.txt"
|
||||
cp native/vendor/json.hpp "$stage/share/licenses/nlohmann-json.hpp"
|
||||
cp packaging/*.sh "$stage/"
|
||||
cp README.md README.en.md "$stage/"
|
||||
mkdir -p "$stage/assets"
|
||||
cp -a assets/branding "$stage/assets/"
|
||||
cp LICENSE "$stage/LICENSE"
|
||||
mkdir -p "$stage/tools/source/examples" "$stage/tools/source/tools" "$stage/tools/source/ui"
|
||||
cp package.json package-lock.json tsconfig.json LICENSE "$stage/tools/source/"
|
||||
cp -a sdk "$stage/tools/source/"
|
||||
mkdir -p "$stage/tools/source/templates"
|
||||
cp -a templates/plugin "$stage/tools/source/templates/"
|
||||
mkdir -p "$stage/tools/source/assets"
|
||||
cp -a assets/branding "$stage/tools/source/assets/"
|
||||
cp -a examples/showcase "$stage/tools/source/examples/"
|
||||
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
|
||||
cp tools/build-ui.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py "$stage/tools/"
|
||||
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
||||
cp -a docs "$stage/share/"
|
||||
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely</title><link rel="stylesheet" href="/assets/app.css"></head><body><div id="root"></div><script src="/assets/app.js"></script></body></html>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><link rel="stylesheet" href="/assets/app.css"></head><body><div id="root"></div><script src="/assets/app.js"></script></body></html>
|
||||
@@ -3,6 +3,7 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"后端内存上限:{0} MiB(含子进程)": "Backend memory limit: {0} MiB (including child processes)",
|
||||
"请先阅读并同意使用协议": "Please read and agree to the terms of use first",
|
||||
"查看使用协议": "View terms of use",
|
||||
"使用 Framely 前": "Before using Framely",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"后端内存上限:{0} MiB(含子进程)": "后端内存上限:{0} MiB(含子进程)",
|
||||
"请先阅读并同意使用协议": "请先阅读并同意使用协议",
|
||||
"查看使用协议": "查看使用协议",
|
||||
"使用 Framely 前": "使用 Framely 前",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function AgreementGate({agreement,refresh,onClose,onRevoked,initialDeclin
|
||||
async function decline(){if(agreement.accepted){setConfirmRevoke(true);return;}setBusy(true);setError('');try{const result=await api('agreement.decline',{view:viewKey()});if(!result.closed)setDeclined(true);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
|
||||
async function selectLanguage(value:string){setBusy(true);setError('');try{await api('language.save',{language:value});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const header=<header className="shell-header"><Brand/><strong>Framely</strong><LanguagePicker value={language.value} packs={language.packs} disabled={busy} onChange={value=>void selectLanguage(value)}/></header>;
|
||||
const header=<header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong><LanguagePicker value={language.value} packs={language.packs} disabled={busy} onChange={value=>void selectLanguage(value)}/></header>;
|
||||
|
||||
if(declined)return <div className="app agreement-app">{header}<main className="empty-state"><h1>{t("请先阅读并同意使用协议")}</h1><button onClick={()=>{setTerms(false);setPrivacy(false);setDeclined(false);}}>{t("查看使用协议")}</button></main></div>;
|
||||
return <div className="app agreement-app">{header}<main className="agreement-body"><h1>{t("使用 Framely 前")}</h1><div className="agreement-documents"><section><h2>{t("用户协议")}</h2><p>{t("Framely 提供插件的集中安装、管理、运行和更新等功能。第三方插件由各自开发者提供,插件的功能、内容及运行行为由插件开发者负责。")}</p><p><strong>{t("Framely 不保证第三方插件的安全性、可靠性或兼容性。插件商店收录、下载校验或权限提示不代表对插件安全性的认可或担保。")}</strong></p><p>{t("请自行甄别插件来源、安全性及所需权限,谨慎决定是否安装、授权或运行,并备份重要数据。插件可能读取或修改数据、访问网络、调整设备设置;授予较高权限可能影响系统和设备。")}</p><p><strong>{t("在适用法律允许的范围内,因第三方插件的安装、授权、运行、更新或卸载造成的数据丢失、隐私泄露、设备故障或其他损失,Framely 及其开发者不承担责任。")}</strong>{' '}{t("本协议不排除或限制法律规定不得免除或限制的责任及用户权利。")}</p><p>{t("不同意本协议时,请停止使用 Framely。协议发生变更时,需要重新确认。")}</p></section><section><h2>{t("隐私声明")}</h2><p>{t("Framely 本身不会主动收集、上传或出售用户个人信息,不内置用户行为跟踪或遥测上报。")}</p><p>{t("为提供管理功能,Framely 会在设备本地保存插件清单、配置、运行日志、协议同意记录及其他必要状态。访问密码仅保存加盐哈希值。")}</p><p>{t("获取插件目录、刷新订阅、下载插件和检查更新时,Framely 会按配置访问相应服务器;使用代理时,请求会经过相应代理服务。这些服务可能获知 IP 地址及请求信息,其数据处理由各自运营者负责。")}</p><p><strong>{t("第三方插件可能自行收集、读取或上传用户信息。Framely 不保证插件不会收集信息,也不保证插件的数据处理行为符合你的隐私期望。")}</strong>{' '}{t("请查看插件的权限、说明及隐私政策,自行决定是否使用。")}</p></section></div>{error&&<p className="error" role="alert">{error}</p>}</main><footer className="agreement-footer">{confirmRevoke?<div className="agreement-revoke" role="alert"><strong>{t("拒绝协议将停用全部插件")}</strong><p>{t("插件窗口将关闭。重新同意后,需自行启用插件。")}</p><div className="agreement-actions"><button disabled={busy} onClick={()=>setConfirmRevoke(false)}>{t("取消")}</button><button className="danger" disabled={busy} onClick={()=>void revoke()}>{busy?t("正在停用…"):t("确认拒绝并停用全部插件")}</button></div></div>:agreement.accepted?<><span>{t("用户协议与隐私声明")}</span><div className="agreement-actions"><button disabled={busy} onClick={onClose}>{t("返回关于")}</button><button disabled={busy} onClick={()=>void decline()}>{t("拒绝协议")}</button></div></>:<><div><label><input type="checkbox" checked={terms} onChange={e=>setTerms(e.target.checked)}/>{t("我已阅读并同意《用户协议》")}</label><label><input type="checkbox" checked={privacy} onChange={e=>setPrivacy(e.target.checked)}/>{t("我已阅读并同意《隐私声明》")}</label></div><div className="agreement-actions"><button disabled={busy} onClick={()=>void decline()}>{t("不同意并退出")}</button><button className="primary" disabled={!terms||!privacy||busy} onClick={()=>void accept()}>{busy?t("保存中…"):t("同意并继续")}</button></div></>}</footer></div>;
|
||||
|
||||
@@ -16,4 +16,4 @@ const paths:Record<IconName,React.ReactNode>={
|
||||
refresh:<><path d="M20 7v5h-5M4 17v-5h5"/><path d="M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1"/></>,
|
||||
};
|
||||
export function Icon({name,size=24,filled=false}:{name:IconName;size?:number;filled?:boolean}){return <svg width={size} height={size} viewBox="0 0 24 24" fill={filled?'currentColor':'none'} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>}
|
||||
export function Brand(){return <span className="brand-mark"><Icon name="plugins" size={27}/></span>}
|
||||
export function Brand({wordmark=false}:{wordmark?:boolean}={}){return <span className={`brand-mark${wordmark?' brand-wordmark':''}`}><img src={`/assets/branding/framely-${wordmark?'logo':'mark'}-light.svg`} alt={wordmark?'Framely':''} aria-hidden={wordmark?undefined:true}/></span>}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {t} from './i18n';
|
||||
import {InstallPlan} from './relations';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
import {PluginAuthor,PluginLinks} from './store';
|
||||
import {runUser,RunUserNotice} from './run-user';
|
||||
|
||||
@@ -24,6 +25,7 @@ export function InstallReview({title,info,busy,error,confirmed,onConfirmChange,o
|
||||
<PluginLinks plugin={manifest}/>
|
||||
{manifest.details&&manifest.details!==manifest.description&&<details className="install-details"><summary>{t('完整介绍')}</summary><p>{manifest.details}</p></details>}
|
||||
</section>
|
||||
{!info.plan&&<MemoryLimit manifest={manifest}/>}
|
||||
<InstallPlan info={info} choose={onChoose}/>
|
||||
{info.runAsChanged&&<label className="install-user-confirm"><input type="checkbox" checked={confirmed} onChange={e=>onConfirmChange(e.target.checked)}/><span>{t('我确认变更运行用户')}</span></label>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
|
||||
@@ -8,10 +8,11 @@ import {NetworkSettings,NetworkPanel} from './network';
|
||||
import {Subscriptions,Subscription} from './subscriptions';
|
||||
import {RelationsInfo} from './relations';
|
||||
import {InstallReview} from './install-review';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
import {installScrollbars} from '../../sdk/src/scrollbars';
|
||||
installScrollbars();
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean};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 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};notifications:any[];runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
@@ -98,7 +99,7 @@ function App(){
|
||||
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>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><Brand/><strong>Framely</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" 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>}
|
||||
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" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件商店"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}><Icon name={icon as any}/><span>{label}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
@@ -115,7 +116,7 @@ function App(){
|
||||
{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>}
|
||||
{manage&&<div className="modal manage-dialog" role="dialog" aria-modal="true" aria-label={t("管理插件")}><h2>{manage.manifest.name}</h2><PluginLinks plugin={manage.manifest}/><p><PluginAuthor plugin={manage.manifest}/> · {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}</p>{status.database.plugins[manage.manifest.id]?.error&&<p className="error">{status.database.plugins[manage.manifest.id].error}</p>}<details className="plugin-relations"><summary>{t("插件依赖")}</summary><RelationsInfo plugin={manage.manifest}/></details><div className="row"><button onClick={()=>move(manage,-1)}>{t("上移")}</button><button onClick={()=>move(manage,1)}>{t("下移")}</button></div><div className="manage-grid"><button disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'enable');})}>{manage.enabled?t("停用"):t("启用")}</button><button disabled={busy||!manage.enabled} onClick={()=>void action(()=>api('plugin.restart',{plugin:manage.manifest.id}))}>{t("重启后端")}</button><button disabled={busy} onClick={()=>{setCatalogTarget({plugin:manage.manifest.id,source:manage.source});setManage(null);setPage(null);setTab('catalog');}}>{t("可选安装版本")}</button><button onClick={()=>void action(async()=>setLogs(await api('logs',{plugin:manage.manifest.id})))}>{t("查看日志")}</button><button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall');if(page===manage.manifest.id)setPage(null);})}>{t("卸载")}</button>{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&<button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall',{force:true});})}>{t("强制卸载(跳过清理失败)")}</button>}</div><div className="dialog-footer"><button onClick={()=>setManage(null)}>{t("关闭")}</button></div></div>}
|
||||
{manage&&<div className="modal manage-dialog" role="dialog" aria-modal="true" aria-label={t("管理插件")}><h2>{manage.manifest.name}</h2><PluginLinks plugin={manage.manifest}/><p><PluginAuthor plugin={manage.manifest}/> · {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}</p><MemoryLimit manifest={manage.manifest}/>{status.database.plugins[manage.manifest.id]?.error&&<p className="error">{status.database.plugins[manage.manifest.id].error}</p>}<details className="plugin-relations"><summary>{t("插件依赖")}</summary><RelationsInfo plugin={manage.manifest}/></details><div className="row"><button onClick={()=>move(manage,-1)}>{t("上移")}</button><button onClick={()=>move(manage,1)}>{t("下移")}</button></div><div className="manage-grid"><button disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'enable');})}>{manage.enabled?t("停用"):t("启用")}</button><button disabled={busy||!manage.enabled} onClick={()=>void action(()=>api('plugin.restart',{plugin:manage.manifest.id}))}>{t("重启后端")}</button><button disabled={busy} onClick={()=>{setCatalogTarget({plugin:manage.manifest.id,source:manage.source});setManage(null);setPage(null);setTab('catalog');}}>{t("可选安装版本")}</button><button onClick={()=>void action(async()=>setLogs(await api('logs',{plugin:manage.manifest.id})))}>{t("查看日志")}</button><button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall');if(page===manage.manifest.id)setPage(null);})}>{t("卸载")}</button>{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&<button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall',{force:true});})}>{t("强制卸载(跳过清理失败)")}</button>}</div><div className="dialog-footer"><button onClick={()=>setManage(null)}>{t("关闭")}</button></div></div>}
|
||||
{install&&<InstallReview title={installTitle} info={install.info} busy={busy} error={error} confirmed={confirmRunAs} onConfirmChange={setConfirmRunAs} onChoose={(id,url)=>void resolveChoice(id,url)} onCancel={()=>setInstall(null)} onInstall={()=>void action(async()=>{await runJob('install.start',{ticket:install.ticket,approve:true,approveRunAs:confirmRunAs},setTransfer);setInstall(null);setTransfer(null);})}/>}
|
||||
|
||||
{transfer&&<Transfer job={transfer} retry={downloadRequest&&transfer.kind==='inspect'?()=>void inspect(downloadRequest):undefined} cancel={()=>void api('job.cancel',{job:transfer.id}).then(()=>setTransfer(v=>v?{...v,phase:'cancelled'}:null))} close={()=>setTransfer(null)}/>}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import {t} from './i18n';
|
||||
export function MemoryLimit({manifest}:{manifest:any}) {
|
||||
if(!manifest.backend&&!manifest.lifecycle)return null;
|
||||
return <small className="install-item-source">{t('后端内存上限:{0} MiB(含子进程)',{0:manifest.backend?.memoryLimitMiB??512})}</small>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useState} from 'react';import {api} from './api';import {Select} from './localized-select';
|
||||
import {RunUserBadge} from './run-user';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
export function RelationsInfo({plugin}:{plugin:any}){
|
||||
const [dependencies,setDependencies]=useState<any[]>([]),[dependents,setDependents]=useState<string[]>([]),[error,setError]=useState('');
|
||||
useEffect(()=>{let live=true;async function load(){try{const [deps,users]=await Promise.all([api('plugin.dependencies',{plugin:plugin.id}),api('plugin.dependents',{plugin:plugin.id})]);if(live){setDependencies(deps);setDependents(users);setError('');}}catch(e){if(live)setError(String(e));}}void load();const timer=setInterval(()=>void load(),2000);return()=>{live=false;clearInterval(timer);};},[plugin.id]);
|
||||
@@ -10,5 +11,5 @@ export function InstallPlan({info,choose}:{info:any;choose:(id:string,url:string
|
||||
const choice=info.choiceRequired;const[value,setValue]=useState('');useEffect(()=>setValue(choice?.candidates?.[0]?.url??''),[choice?.dependency]);
|
||||
if(choice)return <section><h3>{t("选择依赖来源")}</h3><p>{choice.dependency}{t("在其他来源中可用,请明确选择。")}</p><Select label={t("依赖来源")} value={value} onChange={setValue} options={choice.candidates.map((s:any)=>({value:s.url,label:`${s.name} · ${s.url}`}))}/><button disabled={!value} onClick={()=>choose(choice.dependency,value)}>{t("使用所选来源并继续检查")}</button></section>;
|
||||
if(!info.plan)return null;const plan=info.plan;const name=(id:string)=>plan.items.find((i:any)=>i.manifest.id===id)?.manifest.name??id;
|
||||
return <section className="install-plan"><h3>{t("安装计划")}</h3><div className="install-plan-items">{plan.items.map((item:any)=><article className="install-plan-item" key={item.manifest.id}><div className="install-item-heading"><b>{item.manifest.name}</b><RunUserBadge manifest={item.manifest}/></div><div className="install-item-version"><span>{item.action==='reuse'?t("复用已安装"):item.action==='update'?t("安装所选版本"):item.action==='reinstall'?t("重新安装"):t("安装")}</span><span>{item.manifest.version}</span></div><small className="install-item-source">{t("来源:")}{item.source??t("本地安装包")}{item.runAsChanged&&<span className="install-user-change">{t("运行用户变化")}</span>}</small></article>)}</div>{!!plan.addSources.length&&<><h3>{t("同时添加以下插件源")}</h3>{plan.addSources.map((s:any)=><p key={s.id}>{s.name}<small className="source-url">{s.url}</small></p>)}</>}{!!plan.disable.length&&<p className="banner">{t("因冲突停用:")}{plan.disable.map(name).join('、')}</p>}{(plan.enable.length>1||plan.enable.some((id:string)=>id!==info.manifest.id))&&<p className="install-plan-effect"><span>{t("启用依赖链:")}</span>{plan.enable.map(name).join(' → ')}</p>}{!!plan.affected.filter((id:string)=>id!==info.manifest.id).length&&<p className="install-plan-effect"><span>{t("其他受影响的插件:")}</span>{plan.affected.filter((id:string)=>id!==info.manifest.id).map(name).join('、')}</p>}</section>
|
||||
return <section className="install-plan"><h3>{t("安装计划")}</h3><div className="install-plan-items">{plan.items.map((item:any)=><article className="install-plan-item" key={item.manifest.id}><div className="install-item-heading"><b>{item.manifest.name}</b><RunUserBadge manifest={item.manifest}/></div><div className="install-item-version"><span>{item.action==='reuse'?t("复用已安装"):item.action==='update'?t("安装所选版本"):item.action==='reinstall'?t("重新安装"):t("安装")}</span><span>{item.manifest.version}</span></div><small className="install-item-source">{t("来源:")}{item.source??t("本地安装包")}{item.runAsChanged&&<span className="install-user-change">{t("运行用户变化")}</span>}</small><MemoryLimit manifest={item.manifest}/></article>)}</div>{!!plan.addSources.length&&<><h3>{t("同时添加以下插件源")}</h3>{plan.addSources.map((s:any)=><p key={s.id}>{s.name}<small className="source-url">{s.url}</small></p>)}</>}{!!plan.disable.length&&<p className="banner">{t("因冲突停用:")}{plan.disable.map(name).join('、')}</p>}{(plan.enable.length>1||plan.enable.some((id:string)=>id!==info.manifest.id))&&<p className="install-plan-effect"><span>{t("启用依赖链:")}</span>{plan.enable.map(name).join(' → ')}</p>}{!!plan.affected.filter((id:string)=>id!==info.manifest.id).length&&<p className="install-plan-effect"><span>{t("其他受影响的插件:")}</span>{plan.affected.filter((id:string)=>id!==info.manifest.id).map(name).join('、')}</p>}</section>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
:root{color-scheme:dark;--bg:#17191c;--surface:#202226;--line:#35383e;--muted:#a4a8b0;--blue:#93c5ed;--text:#f0f1f3}
|
||||
*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{color:var(--text);background:transparent;font:21px/1.45 system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}svg{flex:none;display:block}button,input,textarea,select{font:inherit;color:inherit}button{display:inline-flex;align-items:center;justify-content:center;gap:10px;min-height:52px;padding:11px 18px;border:1px solid var(--line);border-radius:5px;background:#292c31;cursor:pointer}button:hover:not(:disabled){background:#363a41}button:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible,select:focus-visible{outline:3px solid var(--blue);outline-offset:2px}h1,h2,h3,p{margin:0}h1{font-size:32px;font-weight:600;letter-spacing:-.6px}h2{font-size:25px;font-weight:600;margin-bottom:12px}h3{font-size:22px;font-weight:550;margin-bottom:10px}p{margin-bottom:14px}small,.sub{color:var(--muted)}small{font-size:17px}.sub{font-size:18px;overflow-wrap:anywhere}.primary{background:#dce5ec;color:#192129;border-color:#dce5ec;font-weight:600}.primary:hover:not(:disabled){background:#fff}.danger{background:transparent;color:#efa9a8;border-color:#674647}
|
||||
input:not([type=checkbox]):not([type=file]),textarea,select{width:100%;padding:12px 15px;border:1px solid #494d54;border-radius:4px;background:#131518;min-height:52px;margin:8px 0 14px}input[type=checkbox]{width:24px;height:24px;accent-color:var(--blue);vertical-align:middle;margin:8px 12px 8px 0}label{display:block;font-size:19px;margin:10px 0}input[type=file]{width:100%;padding:18px;border:1px dashed #565c66;border-radius:4px}input[type=file]::file-selector-button{font:inherit;padding:10px 14px;margin-right:14px;background:#30343a;color:var(--text);border:0;border-radius:4px}
|
||||
.app{height:100%;display:flex;flex-direction:column;position:relative;background:var(--bg);border-radius:8px;clip-path:inset(0 round 8px);overflow:hidden}.shell-header{display:flex;align-items:center;gap:14px;padding:18px 26px;border-bottom:1px solid var(--line);flex:none;min-height:90px}.shell-header strong{flex:1;font-size:28px;font-weight:600;letter-spacing:-.7px}.header-label{font-size:18px;font-weight:400;color:var(--muted);margin-left:18px;letter-spacing:0}.brand-mark{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:#dce2e8}.brand-mark svg{width:28px;height:28px}.icon-button{min-width:52px;min-height:52px;padding:12px;border-color:transparent;background:transparent;color:var(--muted)}.icon-button:hover:not(:disabled){background:#30343a;color:#fff}.favorite.selected{color:#e2c783}
|
||||
.app{height:100%;display:flex;flex-direction:column;position:relative;background:var(--bg);border-radius:8px;clip-path:inset(0 round 8px);overflow:hidden}.shell-header{display:flex;align-items:center;gap:14px;padding:18px 26px;border-bottom:1px solid var(--line);flex:none;min-height:90px}.shell-header strong{flex:1;font-size:28px;font-weight:600;letter-spacing:-.7px}.header-label{font-size:18px;font-weight:400;color:var(--muted);margin-left:18px;letter-spacing:0}.brand-mark{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:#dce2e8}.brand-mark img{width:32px;height:32px;image-rendering:pixelated}.brand-wordmark{width:200px;height:48px;max-width:100%}.brand-wordmark img{width:100%;height:auto}.shell-header .brand-title{min-width:0;line-height:0}.icon-button{min-width:52px;min-height:52px;padding:12px;border-color:transparent;background:transparent;color:var(--muted)}.icon-button:hover:not(:disabled){background:#30343a;color:#fff}.favorite.selected{color:#e2c783}
|
||||
.workspace{flex:1;min-height:0;display:flex;flex-direction:column}.manager .workspace{display:grid;grid-template-columns:222px minmax(0,1fr)}nav{display:flex;gap:6px;flex:none}nav button{border-color:transparent;background:transparent;color:var(--muted)}nav button.active{background:#30343a;color:#fff}.manager nav{flex-direction:column;background:#131518;border-right:1px solid var(--line);padding:26px 14px;gap:6px}.manager nav button{justify-content:flex-start;text-align:left;padding:14px 16px;min-height:60px}.manager nav button.active{border-left:3px solid var(--blue);padding-left:14px;border-radius:3px}.nav-foot{margin-top:auto;padding:24px 16px 0;color:var(--muted);font-size:16px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px}.nav-foot small{font-size:15px}.content{min-height:0;overflow:auto;flex:1;padding:28px 32px 36px}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.list-heading p{font-size:18px;color:var(--muted);margin:5px 0 0}.search-field{display:flex;align-items:center;gap:12px;min-width:250px;width:360px;background:#131518;border:1px solid #41464f;border-radius:4px;padding:0 14px;color:var(--muted)}.search-field input{background:transparent!important;border:0!important;padding:12px 0!important;margin:0!important;min-width:0;font-size:19px;outline:none}.search-field:focus-within{outline:2px solid var(--blue)}
|
||||
.list-columns{display:grid;grid-template-columns:minmax(0,1fr) 125px 110px 184px;gap:14px;padding:0 12px 14px;border-bottom:1px solid var(--line);font-size:16px;color:var(--muted)}.list-columns span:last-child{text-align:right;padding-right:20px}.plugin-list{display:flex;flex-direction:column}.plugin-row{display:flex;align-items:center;gap:14px;min-height:102px;padding:14px 10px;border-bottom:1px solid var(--line)}.plugin-row:hover{background:#202327}.plugin-main{flex:1;min-width:0;display:flex;gap:18px;justify-content:flex-start;text-align:left;border:0;background:transparent;padding:8px 0;min-height:64px}.plugin-main:hover:not(:disabled){background:transparent}.plugin-avatar{flex:none;display:flex;align-items:center;justify-content:center;background:#2c3036;color:#aeb6c2;border-radius:5px}.plugin-avatar img{width:100%;height:100%;object-fit:contain;border-radius:5px}.plugin-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.plugin-copy b{font-size:23px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-copy small{font-size:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-meta{display:flex;align-items:center;gap:8px;font-size:16px;color:var(--muted)}.manager .plugin-meta{display:none}.status-dot{width:6px;height:6px;border-radius:50%;background:#a5cdb2}.status-dot.off{background:#747982}.status-dot.failed{background:#edaaaa}.version{color:#949ba6;margin-left:8px}.row-state{width:125px;font-size:18px;color:#c3c8cf;flex:none}.row-version{width:110px;font:17px/1.45 system-ui;color:var(--muted);flex:none;overflow-wrap:anywhere}.plugin-switch{width:52px;min-height:52px;height:52px;border:0;display:block;padding:11px 0;flex:none;background:transparent}.plugin-switch span{display:block;width:52px;height:30px;border:1px solid #626770;background:#2a2e34;border-radius:18px;position:relative}.plugin-switch span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#a4aab3;border-radius:50%}.plugin-switch.on span{background:#445b69;border-color:#7697aa}.plugin-switch.on span:after{left:25px;background:#e5f0f8}.disabled .plugin-copy b{color:#aaaeb5}.disabled .plugin-avatar{opacity:.6}
|
||||
.quick{border-radius:8px}.quick .shell-header{padding:18px 24px;min-height:80px}.quick .workspace{display:flex}.quick nav{order:2;margin-top:auto;background:#131518;border-top:1px solid var(--line);padding:8px;gap:4px}.quick nav button{flex:1;flex-direction:column;gap:5px;padding:12px 8px;min-height:82px;font-size:18px}.quick .content{padding:10px 18px 24px}.quick .plugin-row{gap:6px;padding:10px 6px;min-height:93px}.quick .plugin-copy b{font-size:22px}.quick .plugin-copy{gap:7px}.quick .plugin-avatar{width:44px!important;height:44px!important}.quick .plugin-main{gap:14px}.quick .icon-button{min-width:48px}.quick .empty-state{min-height:280px}.quick .settings-card{padding:24px 0}.quick-settings{padding:10px 4px}.setting-entry{width:100%;background:transparent;border:0;padding:14px 0;justify-content:flex-start;text-align:left;gap:16px}.setting-entry>span:nth-child(2){flex:1}.setting-entry b,.setting-toggle b{display:block;font-size:23px;font-weight:550}.setting-entry small,.setting-toggle small{display:block;margin-top:5px;font-size:17px}.setting-symbol{color:#adb7c4}.forward{font-size:32px;color:var(--muted)}.setting-toggle{width:100%;text-align:left;background:transparent;border:0;justify-content:space-between;padding:0}.toggle{width:54px;height:30px;border-radius:20px;background:#3e444e;position:relative;flex:none}.toggle:after{content:'';position:absolute;left:4px;top:4px;width:22px;height:22px;border-radius:50%;background:#bbc2cc}.toggle.on{background:#526d80}.toggle.on:after{left:28px;background:#e6f1f9}.about{display:flex;align-items:center;gap:10px;padding:24px 0 0;font-size:18px;color:var(--muted)}.about small{margin-left:auto;font-size:15px;overflow-wrap:anywhere}
|
||||
|
||||