From e3d0e8b3f76c45f1154900900d8d219022865027 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:36:19 -0700 Subject: [PATCH] Add Rinx system app catalog and isolate app host boundaries --- .github/workflows/main.yml | 2 + Cargo.lock | 10 + Cargo.toml | 3 + README.md | 6 + README.zh-CN.md | 13 + apps/README.md | 29 +++ apps/README.zh-CN.md | 27 ++ apps/article-editor/bundle/assets/icon.svg | 3 + apps/article-editor/bundle/manifest.json | 15 ++ .../article-editor/native}/backend.rs | 2 +- .../article-editor/native}/document.rs | 0 .../article-editor/native}/host.rs | 0 .../article-editor/native}/mod.rs | 0 .../article-editor/native}/model.rs | 2 +- .../article-editor/native}/preview.rs | 2 +- .../article-editor/native}/remote_images.rs | 0 .../article-editor/native}/storage.rs | 0 .../article-editor/native}/table_picker.rs | 0 .../article-editor/native}/ui.rs | 0 .../article-editor/native}/window.rs | 0 build.rs | 7 + crates/system-apps/Cargo.lock | 186 ++++++++++++++ crates/system-apps/Cargo.toml | 10 + crates/system-apps/src/lib.rs | 241 ++++++++++++++++++ crates/system-apps/src/main.rs | 11 + crates/system-apps/tests/catalog.rs | 138 ++++++++++ .../adr/0002-octoscript-mini-app-authority.md | 2 +- docs/adr/0003-shared-article-components.md | 2 +- docs/adr/0004-independent-makepad-html.md | 2 +- .../0005-octoscript-miniapps-matrix-octos.md | 2 +- docs/adr/0007-host-owned-octos-app-peers.md | 8 +- docs/adr/0008-rinx-system-app-catalog.md | 91 +++++++ docs/adr/README.md | 1 + examples/system_app_catalog.rs | 82 ++++++ resources/i18n/en.json | 4 +- resources/i18n/zh-CN.json | 4 +- src/app.rs | 2 +- src/home/mobile.rs | 2 +- src/home/navigation_tab_bar.rs | 2 +- src/home/room_screen.rs | 2 +- .../matrix/account.rs | 0 .../matrix/membership.rs | 0 src/{octoscript_apps => host}/matrix/mod.rs | 0 .../matrix/policy.rs | 0 src/{octoscript_apps => host}/matrix/room.rs | 0 src/{octoscript_apps => host}/matrix/rooms.rs | 0 src/{octoscript_apps => host}/matrix/send.rs | 0 .../matrix/spaces.rs | 0 src/host/mod.rs | 3 + src/{octoscript_apps => host}/octos.rs | 0 src/{octos_service.rs => host/service.rs} | 0 src/lib.rs | 6 + src/{octoscript_apps => miniapps}/mod.rs | 8 +- src/{octoscript_apps => miniapps}/package.rs | 69 +++++ src/{octoscript_apps => miniapps}/ui.rs | 133 ++++++++-- src/system_apps.rs | 28 ++ system-apps.json | 11 + tools/package-system-apps/check.sh | 4 + tools/wechat-ux/check_i18n.py | 3 +- tools/wechat-ux/live/native_system_apps.py | 75 ++++++ 60 files changed, 1212 insertions(+), 41 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 apps/README.md create mode 100644 apps/README.zh-CN.md create mode 100644 apps/article-editor/bundle/assets/icon.svg create mode 100644 apps/article-editor/bundle/manifest.json rename {src/article_app => apps/article-editor/native}/backend.rs (99%) rename {src/article_app => apps/article-editor/native}/document.rs (100%) rename {src/article_app => apps/article-editor/native}/host.rs (100%) rename {src/article_app => apps/article-editor/native}/mod.rs (100%) rename {src/article_app => apps/article-editor/native}/model.rs (98%) rename {src/article_app => apps/article-editor/native}/preview.rs (99%) rename {src/article_app => apps/article-editor/native}/remote_images.rs (100%) rename {src/article_app => apps/article-editor/native}/storage.rs (100%) rename {src/article_app => apps/article-editor/native}/table_picker.rs (100%) rename {src/article_app => apps/article-editor/native}/ui.rs (100%) rename {src/article_app => apps/article-editor/native}/window.rs (100%) create mode 100644 crates/system-apps/Cargo.lock create mode 100644 crates/system-apps/Cargo.toml create mode 100644 crates/system-apps/src/lib.rs create mode 100644 crates/system-apps/src/main.rs create mode 100644 crates/system-apps/tests/catalog.rs create mode 100644 docs/adr/0008-rinx-system-app-catalog.md create mode 100644 examples/system_app_catalog.rs rename src/{octoscript_apps => host}/matrix/account.rs (100%) rename src/{octoscript_apps => host}/matrix/membership.rs (100%) rename src/{octoscript_apps => host}/matrix/mod.rs (100%) rename src/{octoscript_apps => host}/matrix/policy.rs (100%) rename src/{octoscript_apps => host}/matrix/room.rs (100%) rename src/{octoscript_apps => host}/matrix/rooms.rs (100%) rename src/{octoscript_apps => host}/matrix/send.rs (100%) rename src/{octoscript_apps => host}/matrix/spaces.rs (100%) create mode 100644 src/host/mod.rs rename src/{octoscript_apps => host}/octos.rs (100%) rename src/{octos_service.rs => host/service.rs} (100%) rename src/{octoscript_apps => miniapps}/mod.rs (95%) rename src/{octoscript_apps => miniapps}/package.rs (76%) rename src/{octoscript_apps => miniapps}/ui.rs (82%) create mode 100644 src/system_apps.rs create mode 100644 system-apps.json create mode 100644 tools/package-system-apps/check.sh create mode 100644 tools/wechat-ux/live/native_system_apps.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8b0df3cd..b5b5e266 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,8 @@ jobs: toolchain: 1.98.0 - uses: Swatinem/rust-cache@v2 - run: brew install cmake + - run: cargo test --locked --manifest-path crates/system-apps/Cargo.toml + - run: bash tools/package-system-apps/check.sh - run: python3 tools/test_package_octos.py - run: cargo test --locked --features agent_chat --lib - run: cargo build --locked --features agent_chat diff --git a/Cargo.lock b/Cargo.lock index ffb11747..7fe104f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9328,6 +9328,7 @@ dependencies = [ "reqwest 0.12.28", "rfd", "rinx-miniapp-core", + "rinx-system-apps", "robius-directories", "robius-file-picker", "robius-location", @@ -9361,6 +9362,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rinx-system-apps" +version = "0.1.0" +dependencies = [ + "octosense-app-policy", + "serde", + "serde_json", +] + [[package]] name = "rmcp" version = "1.8.0" diff --git a/Cargo.toml b/Cargo.toml index 9512508a..00ceab84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ version = "1.0.0-beta.1" metadata.makepad-auto-version = "zqpv-Yj-K7WNVK2I8h5Okhho46Q=" [dependencies] +rinx-system-apps = { path = "crates/system-apps" } article-core = { path = "crates/article-core", features = ["l0"] } rinx-miniapp-core = { path = "crates/miniapp-core" } uuid = { version = "1", features = ["v4"] } @@ -239,6 +240,8 @@ askar-storage = { git = "https://github.com/openwallet-foundation/askar.git" } ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" } [build-dependencies] +rinx-system-apps = { path = "crates/system-apps" } +serde_json = "1" toml = "1" ## On Windows, use the `winresource` crate to embed the app's icon and metadata into the executable. diff --git a/README.md b/README.md index 25d1f33d..2c23853d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Rinx +English | [简体中文](README.zh-CN.md) + A native Matrix messenger from [Upstream Labs](https://github.com/upstreamlabs), with a WeChat-style interface, English/Chinese support, Moments, and scoped mini apps. Rinx is an independent continuation of [`OctoSense-org/robrix2`'s `wechat-ui` branch](https://github.com/OctoSense-org/robrix2/tree/wechat-ui), starting at `16913e0c6f0397b4ed1272b7dcd421935521d5f3`. That branch's commit history is preserved here. Development now lives on this repository's **`main`** branch. @@ -15,6 +17,10 @@ Rinx is an independent continuation of [`OctoSense-org/robrix2`'s `wechat-ui` br The renderer remains experimental: grayscale/sepia filters have known failures; arbitrary WeChat HTML import and complete WeChat compatibility are not claimed. See the [integration evidence](lab/article-html-integration/README.md) and [mini-app authority design](docs/adr/0002-octoscript-mini-app-authority.md). +## Built-in apps + +Rinx bundles native and OctoScript apps through `system-apps.json`. The native article editor lives under `apps/article-editor/`; both deployments share its catalog and host-service contracts. See the [app development guide](apps/README.md) and [ADR 0008](docs/adr/0008-rinx-system-app-catalog.md). + ## Build and run Install Rust and CMake. The repository pins Rust 1.98.0. On macOS: diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..bde7c1ec --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,13 @@ +# Rinx + +[English](README.md) | 简体中文 + +Rinx 是原生 Matrix 客户端,包含聊天、联系人、发现、朋友圈和文章编辑器,支持独立运行及作为 OctoSense 原生模块运行。完整构建与兼容性说明见 [英文文档](README.md)。 + +## 内置应用 + +`system-apps.json` 定义随 Rinx 发布的原生和 OctoScript 应用。原生文章编辑器位于 `apps/article-editor/`;共享文档和 Makepad 控件库保留在 `crates/`。 + +独立版由 Rinx 管理 Octos 运行时和提供商配置;托管版使用 OctoSense 注入的服务、共享内核与配置。小程序通过受限的宿主接口访问 Matrix 和 Octos,不启动自己的内核。 + +应用目录构建验证、开发和发布流程见 [应用开发指南](apps/README.zh-CN.md);架构决策见 [ADR 0008](docs/adr/0008-rinx-system-app-catalog.md)。 diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000..b33316bd --- /dev/null +++ b/apps/README.md @@ -0,0 +1,29 @@ +# Rinx system apps + +English | [简体中文](README.zh-CN.md) + +Apps listed in the root `system-apps.json` ship with both standalone and hosted +Rinx. The catalog is compiled into Rinx; a local import cannot change it. + +- `article-editor/bundle/` contains the canonical App Hub manifest and catalog assets. +- `article-editor/native/` contains the native Makepad editor and publication workflows. +- Shared document/rendering code stays in `crates/article-core` and `crates/article-makepad`. +- Host Matrix and Octos adapters live in `src/host/`; grants remain account- and instance-scoped. + +For a script app, create `apps//bundle/manifest.json`, `main.splash` and its +assets, then add `{"directory":""}` to the catalog. Declare exact service +capabilities. Do not collect Matrix credentials or AI provider keys. A native +app additionally needs a compiled registry variant; adding a JSON field alone +cannot introduce native code. Preserve existing app IDs when moving code. + +Run from the repository root: + +```sh +bash tools/package-system-apps/check.sh +cargo test --locked --manifest-path crates/system-apps/Cargo.toml +``` + +The check validates and computes digests without rewriting source manifests. +Normal Rinx builds embed the validated bytes automatically. Review source, +manifest permissions and tests in one Rinx PR; release built-ins with Rinx. +External apps remain App Hub packages. See [ADR 0008](../docs/adr/0008-rinx-system-app-catalog.md). diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md new file mode 100644 index 00000000..b6ad1756 --- /dev/null +++ b/apps/README.zh-CN.md @@ -0,0 +1,27 @@ +# Rinx 内置应用 + +[English](README.md) | 简体中文 + +根目录 `system-apps.json` 中列出的应用随独立版和 OctoSense 托管版 Rinx 一同发布。 +目录编译进 Rinx,本地导入包不能修改它。 + +- `article-editor/bundle/` 保存 App Hub 标准清单及目录资源。 +- `article-editor/native/` 保存原生 Makepad 编辑器和发布流程。 +- 共享文档、渲染代码保留在 `crates/article-core` 和 `crates/article-makepad`。 +- Matrix 和 Octos 宿主适配器位于 `src/host/`;授权仍绑定账号和应用实例。 + +新增脚本应用时,创建 `apps//bundle/manifest.json`、`main.splash` 及资源, +并在目录中添加 `{"directory":""}`。清单必须声明准确的服务权限。 +应用不收集 Matrix 凭据或 AI 提供商密钥。原生应用还需要编译进宿主的注册项, +只修改 JSON 不能引入原生代码。移动代码时保留已有应用 ID。 + +在仓库根目录运行: + +```sh +bash tools/package-system-apps/check.sh +cargo test --locked --manifest-path crates/system-apps/Cargo.toml +``` + +检查会验证清单并计算摘要,不改写源码清单。正常构建会自动嵌入已验证的内容。 +应用代码、权限和测试在同一个 Rinx PR 中审查,内置应用随 Rinx 发布;外部应用继续 +使用 App Hub 包格式和发布流程。参见 [ADR 0008](../docs/adr/0008-rinx-system-app-catalog.md)。 diff --git a/apps/article-editor/bundle/assets/icon.svg b/apps/article-editor/bundle/assets/icon.svg new file mode 100644 index 00000000..9ca6f571 --- /dev/null +++ b/apps/article-editor/bundle/assets/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/article-editor/bundle/manifest.json b/apps/article-editor/bundle/manifest.json new file mode 100644 index 00000000..163be42b --- /dev/null +++ b/apps/article-editor/bundle/manifest.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "id": "org.octosense.article-editor", + "version": "2.0.0", + "name": "Article editor", + "integrity": { + "bundle_blake3": "" + }, + "capabilities": [ + "storage", + "images", + "matrix.event", + "matrix.send_message" + ] +} diff --git a/src/article_app/backend.rs b/apps/article-editor/native/backend.rs similarity index 99% rename from src/article_app/backend.rs rename to apps/article-editor/native/backend.rs index e70c1e12..c168b655 100644 --- a/src/article_app/backend.rs +++ b/apps/article-editor/native/backend.rs @@ -808,7 +808,7 @@ mod tests { #[tokio::test] async fn editor_markdown_fits_after_matrix_encryption() { - let source = include_str!("../../lab/article-editor/render-comparison/source.md"); + let source = include_str!("../../../lab/article-editor/render-comparison/source.md"); let doc = Document::from_markdown("Markdown 格式测试", source).unwrap(); let stored = serde_json::to_vec(&doc).unwrap(); let transaction = "r".repeat(100); diff --git a/src/article_app/document.rs b/apps/article-editor/native/document.rs similarity index 100% rename from src/article_app/document.rs rename to apps/article-editor/native/document.rs diff --git a/src/article_app/host.rs b/apps/article-editor/native/host.rs similarity index 100% rename from src/article_app/host.rs rename to apps/article-editor/native/host.rs diff --git a/src/article_app/mod.rs b/apps/article-editor/native/mod.rs similarity index 100% rename from src/article_app/mod.rs rename to apps/article-editor/native/mod.rs diff --git a/src/article_app/model.rs b/apps/article-editor/native/model.rs similarity index 98% rename from src/article_app/model.rs rename to apps/article-editor/native/model.rs index 561e394d..5ca706c2 100644 --- a/src/article_app/model.rs +++ b/apps/article-editor/native/model.rs @@ -8,7 +8,7 @@ use ruma::{ use serde::{Deserialize, Serialize}; pub const MSGTYPE: &str = "rs.robius.robrix.article_app"; -const APP_ID: &str = "org.octosense.article-editor"; +const APP_ID: &str = crate::system_apps::ARTICLE_ID; pub use article_core::bindings::SOURCE; pub fn invalidate_sessions() { AUTHORITY.invalidate(); } diff --git a/src/article_app/preview.rs b/apps/article-editor/native/preview.rs similarity index 99% rename from src/article_app/preview.rs rename to apps/article-editor/native/preview.rs index 2e5f8edc..7f02b27e 100644 --- a/src/article_app/preview.rs +++ b/apps/article-editor/native/preview.rs @@ -365,7 +365,7 @@ mod tests { } #[test] fn editor_md_math_preview_grants_only_generated_images_and_preserves_source() { - let source = include_str!("../../lab/article-editor/render-comparison/evidence/09-math/source.md"); + let source = include_str!("../../../lab/article-editor/render-comparison/evidence/09-math/source.md"); let doc = Document::from_markdown("Math fixture", source).unwrap(); let before = serde_json::to_string(&doc).unwrap(); let (html, resources) = bundle(&doc, &Default::default(), |_| panic!("No imported assets expected")).unwrap(); diff --git a/src/article_app/remote_images.rs b/apps/article-editor/native/remote_images.rs similarity index 100% rename from src/article_app/remote_images.rs rename to apps/article-editor/native/remote_images.rs diff --git a/src/article_app/storage.rs b/apps/article-editor/native/storage.rs similarity index 100% rename from src/article_app/storage.rs rename to apps/article-editor/native/storage.rs diff --git a/src/article_app/table_picker.rs b/apps/article-editor/native/table_picker.rs similarity index 100% rename from src/article_app/table_picker.rs rename to apps/article-editor/native/table_picker.rs diff --git a/src/article_app/ui.rs b/apps/article-editor/native/ui.rs similarity index 100% rename from src/article_app/ui.rs rename to apps/article-editor/native/ui.rs diff --git a/src/article_app/window.rs b/apps/article-editor/native/window.rs similarity index 100% rename from src/article_app/window.rs rename to apps/article-editor/native/window.rs diff --git a/build.rs b/build.rs index 5d8ba5f9..058fee5c 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,11 @@ fn main() { + println!("cargo:rerun-if-changed=system-apps.json"); + println!("cargo:rerun-if-changed=apps"); + let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let catalog = rinx_system_apps::pack(&root).expect("validate Rinx system apps"); + let catalog_path = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("system-apps.json"); + std::fs::write(catalog_path, serde_json::to_vec(&catalog).unwrap()).expect("embed Rinx system apps"); + // cargo-makepad discovers resources of Git/registry dependencies through // a package-name.path file beside the compiled libraries. Rinx is also a // hosted dependency, so publish its resource root like makepad-widgets. diff --git a/crates/system-apps/Cargo.lock b/crates/system-apps/Cargo.lock new file mode 100644 index 00000000..48429122 --- /dev/null +++ b/crates/system-apps/Cargo.lock @@ -0,0 +1,186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "octosense-app-policy" +version = "0.1.0" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=46d67e51b62827a1224b1aacddc2a7b9e69185fc#46d67e51b62827a1224b1aacddc2a7b9e69185fc" +dependencies = [ + "blake3", + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rinx-system-apps" +version = "0.1.0" +dependencies = [ + "octosense-app-policy", + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/system-apps/Cargo.toml b/crates/system-apps/Cargo.toml new file mode 100644 index 00000000..6aaf1506 --- /dev/null +++ b/crates/system-apps/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rinx-system-apps" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +[workspace] +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "46d67e51b62827a1224b1aacddc2a7b9e69185fc" } diff --git a/crates/system-apps/src/lib.rs b/crates/system-apps/src/lib.rs new file mode 100644 index 00000000..8f1cbea8 --- /dev/null +++ b/crates/system-apps/src/lib.rs @@ -0,0 +1,241 @@ +//! Build-owned catalogs. A downloaded manifest cannot register a native app. +use octosense_app_policy::{AppManifest, HostLimits}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs, + path::{Component, Path}, +}; + +pub const ARTICLE_ID: &str = "org.octosense.article-editor"; +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum NativeApp { + ArticleEditor, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Catalog { + schema: u32, + source: String, + repository: String, + apps: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + directory: String, + native: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PackedCatalog { + pub repository: String, + pub apps: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PackedApp { + pub directory: String, + pub native: Option, + pub manifest: AppManifest, + pub files: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BundleFile { + pub path: String, + pub bytes: Vec, +} + +fn relative(path: &str) -> Result<(), String> { + if path.is_empty() + || path.contains('\\') + || !Path::new(path) + .components() + .all(|p| matches!(p, Component::Normal(_))) + { + return Err(format!("Unsafe bundle path: {path}")); + } + Ok(()) +} +fn directory(path: &Path) -> Result<(), String> { + let m = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !m.is_dir() || m.file_type().is_symlink() { + return Err("Catalog directories must not be symlinks".into()); + } + Ok(()) +} +fn collect( + root: &Path, + dir: &Path, + files: &mut Vec, + size: &mut usize, + depth: usize, +) -> Result<(), String> { + if depth > 16 { + return Err("Bundle is too deep".into()); + } + directory(dir)?; + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let kind = entry.file_type().map_err(|e| e.to_string())?; + if kind.is_symlink() { + return Err("Bundle symlinks are not allowed".into()); + } + if kind.is_dir() { + collect(root, &entry.path(), files, size, depth + 1)?; + continue; + } + if !kind.is_file() { + return Err("Only regular bundle files are allowed".into()); + } + if files.len() >= 512 { + return Err("Too many bundle files".into()); + } + use std::io::Read; + let mut bytes = Vec::new(); + fs::File::open(entry.path()) + .map_err(|e| e.to_string())? + .take(8 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + *size += bytes.len(); + if *size > 8 * 1024 * 1024 { + return Err("Bundle exceeds 8 MiB".into()); + } + let path = entry + .path() + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + relative(&path)?; + files.push(BundleFile { path, bytes }); + } + Ok(()) +} +/// Same manifest, policy and digest implementation as OctoSense App Hub. +/// Empty source digests are sealed into the build output, never written back. +pub fn pack(root: &Path) -> Result { + let catalog: Catalog = serde_json::from_slice( + &fs::read(root.join("system-apps.json")).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + if catalog.schema != 1 || catalog.source != "apps" { + return Err("Unsupported system-app catalog".into()); + } + if !catalog.repository.starts_with("https://") || catalog.repository.contains(['\n', '\r']) { + return Err("Expected HTTPS repository URL".into()); + } + directory(&root.join("apps"))?; + let mut ids = BTreeSet::new(); + let mut dirs = BTreeSet::new(); + let mut apps = Vec::new(); + for entry in catalog.apps { + relative(&entry.directory)?; + if entry.directory.contains('/') || !dirs.insert(entry.directory.clone()) { + return Err("Duplicate or nested app directory".into()); + } + let app_root = root.join("apps").join(&entry.directory); + directory(&app_root)?; + let bundle = app_root.join("bundle"); + let mut files = Vec::new(); + collect(&bundle, &bundle, &mut files, &mut 0, 0)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + let file = files + .iter_mut() + .find(|f| f.path == "manifest.json") + .ok_or("Missing manifest.json")?; + let mut manifest = + AppManifest::parse(std::str::from_utf8(&file.bytes).map_err(|e| e.to_string())?)?; + if !ids.insert(manifest.id.clone()) { + return Err("Duplicate system app id".into()); + } + if manifest.agent.is_some() { + return Err("Apps use host-scoped octos services, not agent profiles".into()); + } + if manifest.integrity.signature.is_some() { + return Err( + "System bundles are authenticated by the Rinx build, not a publisher signature" + .into(), + ); + } + let digest = octosense_app_policy::digest_dir(&bundle)?; + if !manifest.integrity.bundle_blake3.is_empty() + && manifest.integrity.bundle_blake3 != digest + { + return Err("Stale bundle digest".into()); + } + manifest.integrity.bundle_blake3 = digest; + octosense_app_policy::policy::resolve( + &manifest, + &HostLimits { + require_signature: false, + ..Default::default() + }, + )?; + file.bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; + match entry.native { + Some(NativeApp::ArticleEditor) if manifest.id != ARTICLE_ID => { + return Err("Native entry and stable app id disagree".into()); + } + Some(_) + if files + .iter() + .any(|f| matches!(f.path.as_str(), "main.splash" | "page.card")) => + { + return Err("Native bundle cannot supply executable script".into()); + } + None if manifest.id == ARTICLE_ID => return Err("Reserved native app id".into()), + None if !files + .iter() + .any(|f| matches!(f.path.as_str(), "main.splash" | "page.card")) => + { + return Err("Script bundle needs main.splash or page.card".into()); + } + _ => {} + } + apps.push(PackedApp { + directory: entry.directory, + native: entry.native, + manifest, + files, + }); + } + Ok(PackedCatalog { + repository: catalog.repository, + apps, + }) +} +impl PackedApp { + /// Caller chooses a fresh private directory; never overlays another app. + pub fn materialize(&self, destination: &Path) -> Result<(), String> { + fs::create_dir(destination).map_err(|e| e.to_string())?; + let result = (|| { + for file in &self.files { + relative(&file.path)?; + let path = destination.join(&file.path); + fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; + fs::write(path, &file.bytes).map_err(|e| e.to_string())?; + } + self.verify(destination) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(destination); + } + result + } + pub fn verify(&self, root: &Path) -> Result<(), String> { + let expected = self + .files + .iter() + .find(|f| f.path == "manifest.json") + .ok_or("Missing embedded manifest")?; + if fs::read(root.join("manifest.json")).map_err(|e| e.to_string())? != expected.bytes { + return Err("Built-in manifest changed".into()); + } + octosense_app_policy::admit_digest( + &self.manifest, + &octosense_app_policy::digest_dir(root)?, + &octosense_app_policy::RefuseAllSignatures, + ) + } +} diff --git a/crates/system-apps/src/main.rs b/crates/system-apps/src/main.rs new file mode 100644 index 00000000..fc0281db --- /dev/null +++ b/crates/system-apps/src/main.rs @@ -0,0 +1,11 @@ +fn main() -> Result<(), Box> { + let root = std::env::args_os().nth(1).unwrap_or_else(|| ".".into()); + let packed = rinx_system_apps::pack(std::path::Path::new(&root))?; + for app in packed.apps { + println!( + "{} {} {:?} {}", + app.manifest.id, app.manifest.version, app.native, app.manifest.integrity.bundle_blake3 + ); + } + Ok(()) +} diff --git a/crates/system-apps/tests/catalog.rs b/crates/system-apps/tests/catalog.rs new file mode 100644 index 00000000..fedc2c24 --- /dev/null +++ b/crates/system-apps/tests/catalog.rs @@ -0,0 +1,138 @@ +use rinx_system_apps::{pack, ARTICLE_ID}; +use serde_json::{json, Value}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "rinx-catalog-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(root.join("apps/test/bundle")).unwrap(); + let f = Self(root); + f.catalog(json!([{"directory":"test"}])); + f.manifest("test.script"); + fs::write( + f.0.join("apps/test/bundle/main.splash"), + "Label {text: \"Hello\"}", + ) + .unwrap(); + f + } + fn catalog(&self, apps: Value) { + fs::write(self.0.join("system-apps.json"),json!({"schema":1,"source":"apps","repository":"https://github.com/hagency-org/Rinx","apps":apps}).to_string()).unwrap(); + } + fn manifest(&self, id: &str) { + fs::write(self.0.join("apps/test/bundle/manifest.json"),json!({"schema":1,"id":id,"version":"1.0.0","name":"Test","integrity":{"bundle_blake3":""},"capabilities":["matrix.profile"]}).to_string()).unwrap(); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +#[test] +fn script_roundtrip_uses_canonical_digest_and_detects_modified_content_and_manifest() { + let f = Fixture::new(); + let source = fs::read(f.0.join("apps/test/bundle/manifest.json")).unwrap(); + let app = pack(&f.0).unwrap().apps.remove(0); + assert!(!app.manifest.integrity.bundle_blake3.is_empty()); + assert_eq!( + fs::read(f.0.join("apps/test/bundle/manifest.json")).unwrap(), + source + ); + let target = f.0.join("installed"); + app.materialize(&target).unwrap(); + assert_eq!( + app.manifest.integrity.bundle_blake3, + octosense_app_policy::digest_dir(&target).unwrap() + ); + fs::write(target.join("main.splash"), "changed").unwrap(); + assert!(app.verify(&target).is_err()); + let target = f.0.join("second"); + app.materialize(&target).unwrap(); + fs::write(target.join("manifest.json"), source).unwrap(); + assert!(app.verify(&target).is_err()); +} +#[test] +fn native_entries_require_the_registered_identity_and_no_executable_script() { + let f = Fixture::new(); + f.catalog(json!([{"directory":"test","native":"article-editor"}])); + let error = pack(&f.0).unwrap_err(); + assert!(error.contains("stable app id"), "{error}"); + f.manifest(ARTICLE_ID); + assert!(pack(&f.0).unwrap_err().contains("executable script")); + fs::remove_file(f.0.join("apps/test/bundle/main.splash")).unwrap(); + assert!(pack(&f.0).unwrap().apps[0].native.is_some()); + f.catalog(json!([{"directory":"test","native":"unregistered"}])); + assert!(pack(&f.0).is_err()); +} +#[test] +fn script_cannot_claim_native_identity_or_omit_entrypoint() { + let f = Fixture::new(); + f.manifest(ARTICLE_ID); + assert!(pack(&f.0).unwrap_err().contains("Reserved")); + f.manifest("test.script"); + fs::remove_file(f.0.join("apps/test/bundle/main.splash")).unwrap(); + assert!(pack(&f.0).unwrap_err().contains("needs main.splash")); +} +#[test] +fn rejects_duplicate_ids_and_traversal() { + let f = Fixture::new(); + fs::create_dir_all(f.0.join("apps/second/bundle")).unwrap(); + for name in ["manifest.json", "main.splash"] { + fs::copy( + f.0.join("apps/test/bundle").join(name), + f.0.join("apps/second/bundle").join(name), + ) + .unwrap(); + } + f.catalog(json!([{"directory":"test"},{"directory":"second"}])); + assert!(pack(&f.0).unwrap_err().contains("Duplicate system app id")); + f.catalog(json!([{"directory":"../test"}])); + assert!(pack(&f.0).is_err()); +} +#[cfg(unix)] +#[test] +fn rejects_symlinked_app_directory_and_bundle_files() { + let f = Fixture::new(); + std::os::unix::fs::symlink(f.0.join("apps/test"), f.0.join("apps/alias")).unwrap(); + f.catalog(json!([{"directory":"alias"}])); + assert!(pack(&f.0).is_err()); + f.catalog(json!([{"directory":"test"}])); + std::os::unix::fs::symlink("main.splash", f.0.join("apps/test/bundle/copy")).unwrap(); + assert!(pack(&f.0).is_err()); +} +#[test] +fn refuses_stale_digests_and_unknown_capabilities() { + let f = Fixture::new(); + let p = f.0.join("apps/test/bundle/manifest.json"); + let mut m: Value = serde_json::from_slice(&fs::read(&p).unwrap()).unwrap(); + m["integrity"]["bundle_blake3"] = "0".repeat(64).into(); + fs::write(&p, m.to_string()).unwrap(); + assert!(pack(&f.0).unwrap_err().contains("Stale")); + m["integrity"]["bundle_blake3"] = "".into(); + m["capabilities"] = json!(["octos.kernel.rpc"]); + fs::write(&p, m.to_string()).unwrap(); + assert!(pack(&f.0).is_err()); +} +#[test] +fn materialize_never_overwrites_an_existing_directory() { + let f = Fixture::new(); + let app = pack(&f.0).unwrap().apps.remove(0); + let target = f.0.join("occupied"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("keep"), "existing").unwrap(); + assert!(app.materialize(&target).is_err()); + assert_eq!(fs::read_to_string(target.join("keep")).unwrap(), "existing"); +} diff --git a/docs/adr/0002-octoscript-mini-app-authority.md b/docs/adr/0002-octoscript-mini-app-authority.md index e7188c6c..3741609a 100644 --- a/docs/adr/0002-octoscript-mini-app-authority.md +++ b/docs/adr/0002-octoscript-mini-app-authority.md @@ -2,7 +2,7 @@ - Date: 2026-09-20 - Status: Accepted for the built-in L0 article editor; broader runtime/catalog design remains proposed -- Implementation: `src/article_app` provides an admitted built-in Octoscript L0 +- Implementation: `apps/article-editor/native` provides an admitted built-in Octoscript L0 Markdown editor, native Makepad Html preview, recipient account consent, local per-account drafts and host-confirmed Matrix publication. See [implementation and validation](../../lab/article-editor/README.md). diff --git a/docs/adr/0003-shared-article-components.md b/docs/adr/0003-shared-article-components.md index 62507cb0..1f9d4215 100644 --- a/docs/adr/0003-shared-article-components.md +++ b/docs/adr/0003-shared-article-components.md @@ -21,7 +21,7 @@ Split reusable Rust libraries from host orchestration: OctoSense dependency. Octoscript L0 is an optional static library dependency. - `article-makepad`: native rich text input, layout, shared editor/reader styling and optional Apple PingFang setup. No application or Matrix dependency. -- `src/article_app`: Robrix-specific screens, navigation, localization, image +- `apps/article-editor/native`: Robrix-specific screens, navigation, localization, image picker, consent UI, Matrix wire format, target selection, E2EE media, publication retries, replacement events and withdrawal. Robrix supplies `RobrixArticleHost` and `RobrixPublisher` to the shared code. diff --git a/docs/adr/0004-independent-makepad-html.md b/docs/adr/0004-independent-makepad-html.md index 2364d565..ded836c4 100644 --- a/docs/adr/0004-independent-makepad-html.md +++ b/docs/adr/0004-independent-makepad-html.md @@ -18,7 +18,7 @@ and is now a default feature; `article_blitz` remains a compatibility feature alias for existing commands. No downstream Blitz Cargo patch overrides are necessary. -`src/article_app/preview.rs` remains an app-specific adapter: it converts validated +`apps/article-editor/native/preview.rs` remains an app-specific adapter: it converts validated article documents to HTML, grants already-authorized asset bytes, schedules the render, and checks the login/grant/request lifecycle before display. Drafts, themes, Markdown, consent, Matrix credentials, storage, publication and diff --git a/docs/adr/0005-octoscript-miniapps-matrix-octos.md b/docs/adr/0005-octoscript-miniapps-matrix-octos.md index 12aee5af..b214f728 100644 --- a/docs/adr/0005-octoscript-miniapps-matrix-octos.md +++ b/docs/adr/0005-octoscript-miniapps-matrix-octos.md @@ -43,7 +43,7 @@ AI generation is optional. Importing and running an existing app must not requir ## Implementation -- `crates/miniapp-core` owns the A2App Matrix request parser, response bounds, instance leases, room grants and account-generation revocation. Matrix SDK adapters live in `src/octoscript_apps/matrix`. +- `crates/miniapp-core` owns the A2App Matrix request parser, response bounds, instance leases, room grants and account-generation revocation. Matrix SDK adapters live in `src/host/matrix`. - The native **Mini apps** page imports a digest-verified, bounded snapshot of a local bundle, reviews its services and optional room, and creates one account-scoped Splash instance. Closing it revokes requests and discards late responses. - Current `main.splash` bundles use App Hub's shared script entry and asset-origin resolver. They call the same host services directly and keep their own script state. - Octoscript-Makepad prepares the same L0 source, kit and state used by OctoSense cards. Input events update state; declarative bindings populate data fields from host replies. Rinx does not translate the example into a custom native screen. diff --git a/docs/adr/0007-host-owned-octos-app-peers.md b/docs/adr/0007-host-owned-octos-app-peers.md index 49f28b17..c9f87bc7 100644 --- a/docs/adr/0007-host-owned-octos-app-peers.md +++ b/docs/adr/0007-host-owned-octos-app-peers.md @@ -370,9 +370,9 @@ record below says what changed: | Standalone | Explicit remote connection UI and standalone entry exist | Complete local-runtime provisioning and verify remote workspace behavior | Relevant Rinx sources: [module](../../src/module.rs), -[Octos adapter](../../src/octoscript_apps/octos.rs), -[package admission](../../src/octoscript_apps/package.rs), -[mini-app UI](../../src/octoscript_apps/ui.rs), and +[Octos adapter](../../src/host/octos.rs), +[package admission](../../src/miniapps/package.rs), +[mini-app UI](../../src/miniapps/ui.rs), and [build features](../../Cargo.toml). Octos source evidence is pinned to inspected revision @@ -433,7 +433,7 @@ are listed for the owners' confirmation. | Broker | OctoSense-System-Apps#14 (`3daf54e8`), #15 (`64aa865e`), #16 (`4fa0f122`), #17 (`cc1cee3e`) | `crates/app-peers`: the contract Rinx consumes (`OctosAppService`, `OctosContext`), creation-time injection (`offer` / `claim` / `withdraw`), and one broker for all three deployments. It keeps one system-owned peer per app and account and one kernel request context per mini-app instance. The lease is checked on every request and before every reply, and host tokens are kept. The connector authenticates remote servers by token only. #17 moves the shell kernel to octos `552767dd` | | Runtime | makepad#35 (`db4691d0`), OctoScript-Makepad#46 (`c3d53ba8`) | Splash `reapply_text` and stateful mini-app inputs on the current runtime (the ADR 0005 additions from makepad#32 / OSM#40, without #32's Android Back part) | | Shells | OctoSense-Desktop#50 (`c806921c`, then #41 `1e8b217`), OctoSense-ROM#34 (landed with #24 `1dc753a7`) | `app_peers_host`: a module whose declared `octos.*` services host policy grants gets a scoped service for its instance at `create`, released at teardown. Rinx is linked as a module (`octosense-module` only). CI keeps its standalone features out of every graph. No `www.github.com` patch; AppCard stays opt-in | -| Rinx | #25 (`086208d7`, stacked on #24; on main with #24 `5cf70a82`, which both shells pin) | `src/octos_service.rs` selects the deployment: hosted from module creation, standalone local or remote from Rinx's settings. `module.rs` declares the four `octos.*` services and claims the injected service. Mini apps get request contexts, and admission and dispatch use exact names. Account changes revoke, hosted close releases, standalone exit stops only an owned runtime | +| Rinx | #25 (`086208d7`, stacked on #24; on main with #24 `5cf70a82`, which both shells pin) | `src/host/service.rs` selects the deployment: hosted from module creation, standalone local or remote from Rinx's settings. `module.rs` declares the four `octos.*` services and claims the injected service. Mini apps get request contexts, and admission and dispatch use exact names. Account changes revoke, hosted close releases, standalone exit stops only an owned runtime | `KernelProvider::shared` and AppCard connection discovery are gone. No mode starts a fallback kernel or substitutes an ordinary privileged session. A hosted diff --git a/docs/adr/0008-rinx-system-app-catalog.md b/docs/adr/0008-rinx-system-app-catalog.md new file mode 100644 index 00000000..b0e650ac --- /dev/null +++ b/docs/adr/0008-rinx-system-app-catalog.md @@ -0,0 +1,91 @@ +# ADR 0008: Rinx system apps and repository boundaries + +Status: Accepted; implemented in this branch. + +## Decision + +Rinx ships a build-owned catalog of native and OctoScript apps. The root +`system-apps.json` selects directories under `apps/`; each directory has an +OctoSense App Hub `bundle/manifest.json`. Script bundles contain `main.splash` +or the existing L0 `page.card`. A native entry is selected in the trusted +catalog, outside the portable manifest, and must name a compiled registry +variant with its exact stable app ID. An imported bundle cannot register a +native entry or claim any bundled app ID. + +The first system app is the article editor. Its UI and workflows live under +`apps/article-editor/native/`. Its existing `org.octosense.article-editor` ID, +L0 source hash on Matrix share messages, library paths and consent grants stay +compatible. It remains native Makepad. `article-core` and `article-makepad` +remain reusable libraries. Native code is compiled as a Rinx module; it is not +a separately downloadable executable or an independent Rust crate yet. + +`src/miniapps/` owns the catalog screen, import/review, Splash instances and +lifecycle. Existing `octoscript_apps` Rust paths remain compatible through the +module declaration. `src/host/matrix/` contains SDK operations and their account, +room and service gates; `src/host/octos.rs` contains scoped assistant contexts. +`src/host/service.rs` retains the existing feature-gated standalone/local/remote +and injected-host lifecycle, exposed through `rinx::octos_service`. + +## Admission and packaging + +`rinx-system-apps` is a small Makepad-free packaging library and CLI. +`build.rs` uses it to validate the catalog, parse the canonical App Hub manifest, +resolve requested capabilities and compute the canonical bundle digest. Empty +source digests are filled in the embedded artifact, never in the developer's +source files. Both deployment modes embed the same catalog and bundle bytes. +Repository provenance points to this Rinx repository; the enclosing Rinx release +or pinned Git revision identifies the app source. Native executable code is +covered by that release, while a bundle digest covers its bundle files. + +Duplicate IDs, unknown native entries, a native entry with the wrong ID, +script/native entry collisions, traversal, symlinks, stale digests and unknown +capabilities fail packaging. Embedded script bundles are extracted into a fresh +private directory, pass the existing package review/run path, and are checked +against the embedded manifest and digest again before running. Local imports +remain visibly unsigned and cannot impersonate built-ins. Built-in status +never supplies an account, a room grant, or approval for publishing. + +Native article operations continue to use `article_core::host::ConsentGrant` +and the article adapter's fixed operation set. Generic manifest capabilities do +not replace those native operation checks or issue a script lease to the +editor. In particular, publishing/withdrawal still require their existing +native confirmation flow. Script apps retain exact-name capability checks. + +## Deployment and Octos + +Standalone Rinx owns its configured runtime (or explicitly configured remote +connector), credentials and provider settings. Hosted Rinx receives OctoSense's +scoped service and shares OctoSense's kernel and provider configuration. It +cannot start its standalone runtime or show a second provider-configuration +form. Mini-app instances use scoped contexts of Rinx's peer; this catalog does +not introduce a kernel or peer per mini-app. + +OctoSense pins Rinx as one native module and consumes its embedded catalog. It +does not copy Rinx's app sources into the OctoSense repository. System apps ship +with Rinx releases. Independently published apps use the existing App Hub +format and publication mechanism; this change does not create another store, +add an App Hub download UI, or claim store signatures for local imports. + +## Validation + +The portable packager tests digest/materialization, reserved native identity, +entrypoints, duplicate IDs, traversal/symlinks, stale digests/capabilities and +non-overwrite behavior. Native package tests reject imported copies of the +built-in editor. The offline Makepad catalog harness exercises both standalone +and injected-host modes: catalog/import navigation, provider-form visibility, +launching the native editor, and refusal to authorize without a Matrix account. +It never logs into Matrix or starts a kernel. + +Local validation on macOS: + +- `cargo test --locked --manifest-path crates/system-apps/Cargo.toml`: 7 passed. +- `bash tools/package-system-apps/check.sh`: embedded catalog admitted. +- `cargo test --profile fast --locked --lib`: 208 passed, 1 existing test ignored. +- `cargo check --locked --no-default-features --features octosense-module`: passed. +- `cargo build --profile fast --locked --example system_app_catalog`, then + `python3 tools/wechat-ux/live/native_system_apps.py`: both deployment modes passed. +- `python3 tools/wechat-ux/check_i18n.py`: no missing translation keys. + +Device installation of this catalog change is unverified. The previous editor +SVG integration was tested separately on the OnePlus 6; those results do not +claim device coverage of this new catalog. diff --git a/docs/adr/README.md b/docs/adr/README.md index e957ecc0..bcc41e2d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,3 +9,4 @@ implemented. An ADR is not evidence that a feature has shipped. | [0002](0002-octoscript-mini-app-authority.md) | Proposed; source review complete | Octoscript mini-app containment, account delegation and Markdown/HTML editor | | [0003](0003-shared-article-components.md) | Accepted; implemented | Standalone article libraries, host authority and optional Blitz preview | | [0007](0007-host-owned-octos-app-peers.md) | Accepted; implemented in part (native hosted flows unverified) | Host-owned Octos app peers and Rinx deployment modes | +| [0008](0008-rinx-system-app-catalog.md) | Accepted; implemented | Bundled native/OctoScript catalog, packaging and deployment boundaries | diff --git a/examples/system_app_catalog.rs b/examples/system_app_catalog.rs new file mode 100644 index 00000000..bb7ddf49 --- /dev/null +++ b/examples/system_app_catalog.rs @@ -0,0 +1,82 @@ +//! Offline native catalog harness. --hosted exercises an injected-host deployment. +//! No Matrix login, account restore, or kernel is started by this example. +pub use makepad_widgets; +use makepad_widgets::*; +use rinx::miniapps::{MiniAppsAction, MiniAppsPanelWidgetRefExt}; +use rinx::article_app::{ArticleAction, ArticlePanelWidgetRefExt}; +app_main!(App); +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + let app = startup() do #(App::script_component(vm)) { + ui: Root { + main_window := Window { + window.inner_size: vec2(430, 820) + body +: {flow: Overlay + miniapps := MiniAppsPanel {} + article := Modal {can_dismiss: false content := ArticlePanel {}} + } + } + } + } + app +} +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, +} +impl MatchEvent for App { + fn handle_startup(&mut self, cx: &mut Cx) { + if std::env::args().any(|a| a == "--hosted") { + rinx::octos_service::install_hosted(None); + } + self.ui.mini_apps_panel(cx, ids!(miniapps)).action( + cx, + ModalRef::default(), + &MiniAppsAction::Open, + ); + } + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + for action in actions { + if let Some(MiniAppsAction::Close) = action.downcast_ref::() { + self.ui.mini_apps_panel(cx, ids!(miniapps)).action( + cx, + ModalRef::default(), + &MiniAppsAction::Close, + ); + } + if let Some(action) = action.downcast_ref::() { + let modal = self.ui.modal(cx, ids!(article)); + self.ui + .article_panel(cx, ids!(article.content)) + .action(cx, modal, action); + self.ui + .view(cx, ids!(miniapps)) + .set_visible(cx, matches!(action, ArticleAction::Close)); + if matches!(action, ArticleAction::Close) { + self.ui.mini_apps_panel(cx, ids!(miniapps)).action( + cx, + ModalRef::default(), + &MiniAppsAction::Open, + ); + } + } + } + } +} +impl AppMain for App { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + makepad_widgets::theme_mod(vm); + script_eval!(vm, {mod.theme = mod.themes.light}); + #[cfg(any(target_os = "macos", target_os = "ios"))] + article_makepad::apple_fonts::install(vm); + makepad_widgets::widgets_mod(vm); + rinx::app::register_widgets(vm); + self::script_mod(vm) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + self.match_event(cx, event); + self.ui.handle_event(cx, event, &mut Scope::empty()); + } +} diff --git a/resources/i18n/en.json b/resources/i18n/en.json index ac3f2891..0c7c7037 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -1102,5 +1102,7 @@ "Remove block": "Remove block", "Full width": "Full width", "Medium width": "Medium width", - "Small width": "Small width" + "Small width": "Small width", + "Built-in apps": "Built-in apps", + "Import an app": "Import an app" } diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index 001379df..fb053943 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -1102,5 +1102,7 @@ "Remove block": "删除内容块", "Full width": "全宽", "Medium width": "中等宽度", - "Small width": "小宽度" + "Small width": "小宽度", + "Built-in apps": "内置应用", + "Import an app": "导入应用" } diff --git a/src/app.rs b/src/app.rs index 72013c03..73927905 100644 --- a/src/app.rs +++ b/src/app.rs @@ -979,7 +979,7 @@ impl AppMain for App { let mini_modal = self.ui.modal(cx, ids!(octoscript_apps_modal)); if mini_modal.is_open() && event.back_pressed() { self.ui.mini_apps_panel(cx, ids!(octoscript_apps_modal.content)) - .action(cx, mini_modal, &MiniAppsAction::Close); + .back(cx, mini_modal); return; } if let Event::LiveEdit = event { diff --git a/src/home/mobile.rs b/src/home/mobile.rs index 78ac853c..a2eb292d 100644 --- a/src/home/mobile.rs +++ b/src/home/mobile.rs @@ -492,7 +492,7 @@ impl Widget for MobileHub { self.back(cx); } if self.view.navigation_bar_button(cx,ids!(discover_mini_apps)).clicked(actions){cx.action(crate::octoscript_apps::MiniAppsAction::Open);} - if self.view.navigation_bar_button(cx, ids!(discover_article)).clicked(actions) { cx.action(crate::article_app::ArticleAction::Open); } + if self.view.navigation_bar_button(cx, ids!(discover_article)).clicked(actions) { crate::system_apps::open_article(cx); } if self.view.navigation_bar_button(cx, ids!(discover_moments)).clicked(actions) { cx.action(crate::moments::ui::MomentsAction::Open {author: None}); } diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index 147d7f1d..4f7a5025 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -673,7 +673,7 @@ impl Widget for NavigationTabBar { } if self.view.navigation_bar_button(cx,ids!(octoscript_apps_button)).clicked(actions) {cx.action(crate::octoscript_apps::MiniAppsAction::Open);} if self.view.navigation_bar_button(cx, ids!(article_editor_button)).clicked(actions) { - cx.action(crate::article_app::ArticleAction::Open); + crate::system_apps::open_article(cx); } if self.view.navigation_bar_button(cx, ids!(toggle_spaces_bar_button)).clicked(actions) { self.is_spaces_bar_shown = !self.is_spaces_bar_shown; diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 8d40f5ff..b1a453c4 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -2023,7 +2023,7 @@ impl RoomScreen { RoomInputPopupMenuAction::SendCurrentLocation => { room_input_bar.show_current_location_preview(cx); } - RoomInputPopupMenuAction::ArticleEditor => cx.action(crate::article_app::ArticleAction::Open), + RoomInputPopupMenuAction::ArticleEditor => crate::system_apps::open_article(cx), RoomInputPopupMenuAction::ShareMiniApp => { if let Some(timeline) = self.timeline_kind.clone() { cx.action(crate::mini_app::MiniAppAction::Compose(timeline)); diff --git a/src/octoscript_apps/matrix/account.rs b/src/host/matrix/account.rs similarity index 100% rename from src/octoscript_apps/matrix/account.rs rename to src/host/matrix/account.rs diff --git a/src/octoscript_apps/matrix/membership.rs b/src/host/matrix/membership.rs similarity index 100% rename from src/octoscript_apps/matrix/membership.rs rename to src/host/matrix/membership.rs diff --git a/src/octoscript_apps/matrix/mod.rs b/src/host/matrix/mod.rs similarity index 100% rename from src/octoscript_apps/matrix/mod.rs rename to src/host/matrix/mod.rs diff --git a/src/octoscript_apps/matrix/policy.rs b/src/host/matrix/policy.rs similarity index 100% rename from src/octoscript_apps/matrix/policy.rs rename to src/host/matrix/policy.rs diff --git a/src/octoscript_apps/matrix/room.rs b/src/host/matrix/room.rs similarity index 100% rename from src/octoscript_apps/matrix/room.rs rename to src/host/matrix/room.rs diff --git a/src/octoscript_apps/matrix/rooms.rs b/src/host/matrix/rooms.rs similarity index 100% rename from src/octoscript_apps/matrix/rooms.rs rename to src/host/matrix/rooms.rs diff --git a/src/octoscript_apps/matrix/send.rs b/src/host/matrix/send.rs similarity index 100% rename from src/octoscript_apps/matrix/send.rs rename to src/host/matrix/send.rs diff --git a/src/octoscript_apps/matrix/spaces.rs b/src/host/matrix/spaces.rs similarity index 100% rename from src/octoscript_apps/matrix/spaces.rs rename to src/host/matrix/spaces.rs diff --git a/src/host/mod.rs b/src/host/mod.rs new file mode 100644 index 00000000..33ebd996 --- /dev/null +++ b/src/host/mod.rs @@ -0,0 +1,3 @@ +//! Rinx-owned Matrix and scoped Octos adapters shared by all app deployments. +pub(crate) mod matrix; +pub(crate) mod octos; diff --git a/src/octoscript_apps/octos.rs b/src/host/octos.rs similarity index 100% rename from src/octoscript_apps/octos.rs rename to src/host/octos.rs diff --git a/src/octos_service.rs b/src/host/service.rs similarity index 100% rename from src/octos_service.rs rename to src/host/service.rs diff --git a/src/lib.rs b/src/lib.rs index fa4d5396..fc7ef839 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,10 +63,16 @@ pub mod block_user_modal; /// Shared UI components. pub mod shared; pub mod mini_app; +#[path = "miniapps/mod.rs"] pub mod octoscript_apps; +pub use octoscript_apps as miniapps; +pub mod system_apps; +pub mod host; /// The assistant's tools and the person's consent for them (ADR 0007). pub mod assistant; +#[path = "host/service.rs"] pub mod octos_service; +#[path = "../apps/article-editor/native/mod.rs"] pub mod article_app; pub mod forwarding; pub mod moments; diff --git a/src/octoscript_apps/mod.rs b/src/miniapps/mod.rs similarity index 95% rename from src/octoscript_apps/mod.rs rename to src/miniapps/mod.rs index 87c21182..343a868a 100644 --- a/src/octoscript_apps/mod.rs +++ b/src/miniapps/mod.rs @@ -1,5 +1,5 @@ //! Octoscript mini-app hosting with account-bound Matrix and Octos service access. -mod matrix; + pub use rinx_miniapp_core::{InstanceId, Lease, OctosProvider, ServiceEvent}; use std::sync::LazyLock; static AUTHORITY: LazyLock = LazyLock::new(Default::default); @@ -37,13 +37,13 @@ pub async fn matrix_request( args: serde_json::Value, ) -> Result { let client = crate::sliding_sync::get_client().ok_or("Not logged in")?; - matrix::execute(client, lease, service, args).await + crate::host::matrix::execute(client, lease, service, args).await } -mod octos; + mod package; pub mod ui; -pub use octos::ContextProvider; +pub use crate::host::octos::ContextProvider; pub use ui::{MiniAppsAction, MiniAppsPanelWidgetRefExt}; pub fn script_mod(vm: &mut makepad_widgets::ScriptVm) { diff --git a/src/octoscript_apps/package.rs b/src/miniapps/package.rs similarity index 76% rename from src/octoscript_apps/package.rs rename to src/miniapps/package.rs index 733e0607..0deeb2b3 100644 --- a/src/octoscript_apps/package.rs +++ b/src/miniapps/package.rs @@ -37,6 +37,7 @@ pub struct Package { pub bindings: Bindings, original: PathBuf, _snapshot: Snapshot, + builtin: Option<&'static crate::system_apps::PackedApp>, } struct Snapshot(PathBuf); impl Drop for Snapshot { @@ -50,6 +51,18 @@ impl Package { Self::load_in(root, &std::env::temp_dir()) } pub fn load_in(root: &Path, snapshots: &Path) -> Result { + Self::load_inner(root, snapshots, None) + } + pub fn load_builtin(id: &str, snapshots: &Path) -> Result { + let app = crate::system_apps::get(id).ok_or("Unknown built-in app")?; + if app.native.is_some() { return Err("Native apps use the host registry".into()); } + std::fs::create_dir_all(snapshots).map_err(|e| e.to_string())?; + let source = snapshots.join(format!("builtin-{}", uuid::Uuid::new_v4())); + app.materialize(&source)?; + let _cleanup = Snapshot(source.clone()); + Self::load_inner(&source, snapshots, Some(app)) + } + fn load_inner(root: &Path, snapshots: &Path, builtin: Option<&'static crate::system_apps::PackedApp>) -> Result { std::fs::create_dir_all(snapshots).map_err(|e| e.to_string())?; // Freeze the admitted bytes. Source edits cannot change a running app's // kit, images or bindings after the user reviews its grants. @@ -64,6 +77,11 @@ impl Package { let manifest = AppManifest::parse( &std::fs::read_to_string(root.join("manifest.json")).map_err(|e| e.to_string())?, )?; + if let Some(app) = builtin { + app.verify(root)?; + } else if crate::system_apps::is_reserved(&manifest.id) { + return Err("This app id belongs to Rinx's built-in catalog".into()); + } if manifest.agent.is_some() { return Err("Bundle agent profiles are not supported here; declare explicit octos.* services instead".into()); } @@ -128,9 +146,11 @@ impl Package { bindings, original, _snapshot: snapshot, + builtin, }) } pub fn unchanged(&self) -> Result<(), String> { + if let Some(app) = self.builtin { return app.verify(&self.root); } let current = Self::load_in( &self.original, self.root.parent().ok_or("Missing bundle cache")?, @@ -140,6 +160,9 @@ impl Package { } Ok(()) } + pub fn builtin_id(&self) -> Option<&'static str> { + self.builtin.map(|app| app.manifest.id.as_str()) + } } fn read_json(root: &Path, name: &str) -> Result, String> { match std::fs::read_to_string(root.join(name)) { @@ -244,6 +267,52 @@ pub fn arguments( mod tests { use super::*; + #[test] + fn built_in_script_owns_its_snapshot_and_rechecks_content_and_manifest() { + let root = std::env::temp_dir().join(format!("rinx-builtin-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let _cleanup = Snapshot(root.clone()); + let bundle = root.join("apps/demo/bundle"); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::write(root.join("system-apps.json"), serde_json::json!({ + "schema": 1, "source": "apps", "repository": "https://github.com/hagency-org/Rinx", + "apps": [{"directory": "demo"}] + }).to_string()).unwrap(); + std::fs::write(bundle.join("manifest.json"), serde_json::json!({ + "schema": 1, "id": "test.embedded", "name": "Embedded", "version": "1.0.0", + "integrity": {"bundle_blake3": ""}, "capabilities": ["matrix.profile"] + }).to_string()).unwrap(); + let script = "Label {text: \"Embedded\"}"; + std::fs::write(bundle.join("main.splash"), script).unwrap(); + let app = Box::leak(Box::new(rinx_system_apps::pack(&root).unwrap().apps.remove(0))); + let extracted = root.join("extracted"); + app.materialize(&extracted).unwrap(); + let package = Package::load_inner(&extracted, &root.join("snapshots"), Some(app)).unwrap(); + // The extraction is disposable; review/run owns the frozen snapshot. + std::fs::remove_dir_all(&extracted).unwrap(); + assert_eq!(package.builtin_id(), Some("test.embedded")); + assert!(package.script); + package.unchanged().unwrap(); + std::fs::write(package.root.join("main.splash"), "modified").unwrap(); + assert!(package.unchanged().is_err()); + std::fs::write(package.root.join("main.splash"), script).unwrap(); + package.unchanged().unwrap(); + let manifest = std::fs::read(package.root.join("manifest.json")).unwrap(); + let mut changed: Value = serde_json::from_slice(&manifest).unwrap(); + changed["capabilities"] = serde_json::json!(["matrix.send_message"]); + std::fs::write(package.root.join("manifest.json"), changed.to_string()).unwrap(); + assert!(package.unchanged().is_err()); + } + + #[test] + fn local_import_cannot_impersonate_the_built_in_native_editor() { + let root = std::env::temp_dir().join(format!("rinx-reserved-id-{}", uuid::Uuid::new_v4())); + crate::system_apps::get(crate::system_apps::ARTICLE_ID).unwrap().materialize(&root).unwrap(); + let result = Package::load(&root); + assert!(matches!(result, Err(e) if e.contains("built-in catalog"))); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn script_bundle_uses_the_shared_entry_and_frozen_assets() { let path = diff --git a/src/octoscript_apps/ui.rs b/src/miniapps/ui.rs similarity index 82% rename from src/octoscript_apps/ui.rs rename to src/miniapps/ui.rs index f2a271c5..19cb9293 100644 --- a/src/octoscript_apps/ui.rs +++ b/src/miniapps/ui.rs @@ -35,8 +35,19 @@ script_mod! { close := Button {text: "Back"} Label {text: "Mini apps" draw_text.color: #222 draw_text.text_style.font_size: 18} } - import_form := View {width: Fill height: Fit flow: Down spacing: 8 - path := TextInput {width: Fill empty_text: "OctoSense bundle folder"} + catalog := View {width: Fill height: Fill flow: Down spacing: 12 + Label {text: #(crate::i18n::tr("Built-in apps")) i18n_text: "Built-in apps" draw_text.color: #222} + catalog_list := PortalList {width: Fill height: Fill + App := View {width: Fill height: 68 padding: 4 + launch := Button {width: Fill height: 60 text: ""} + } + } + import_app := Button {width: Fill text: #(crate::i18n::tr("Import an app")) i18n_text: "Import an app"} + } + import_form := View {visible: false width: Fill height: Fit flow: Down spacing: 8 + bundle_path := View {width: Fill height: Fit + path := TextInput {width: Fill empty_text: "OctoSense bundle folder"} + } room := TextInput {width: Fill empty_text: "Room ID to allow (optional)"} assistant_status := Label {width: Fill draw_text.color: #555 text: ""} core := View {width: Fill height: Fit flow: Down spacing: 6 @@ -58,7 +69,7 @@ script_mod! { run := Button {text: "Run"} } } - notice := Label {width: Fill height: Fit draw_text.color: #333 text: "Import an OctoSense bundle to review its services."} + notice := Label {width: Fill height: Fit draw_text.color: #333 text: ""} approval := View {visible: false width: Fill height: Fit flow: Down spacing: 8 details := Label {width: Fill draw_text.color: #222} buttons := View {width: Fill height: Fit spacing: 8 @@ -66,7 +77,9 @@ script_mod! { deny := Button {text: "Deny"} } } - card := Splash {width: Fill height: Fill} + app_content := View {visible: false width: Fill height: Fill + card := Splash {width: Fill height: Fill} + } } } type Provider = Arc; @@ -88,6 +101,8 @@ pub struct MiniAppsPanel { #[rust] open: bool, #[rust] + showing_catalog: bool, + #[rust] reviewed_room: String, #[rust] review_notice: String, @@ -123,6 +138,44 @@ impl Drop for MiniAppsPanel { } } impl MiniAppsPanel { + fn show_catalog(&mut self, cx: &mut Cx) { + self.stop(cx); + self.package = None; + self.review_notice.clear(); + self.reviewed_room.clear(); + self.publish_to_assistant(); + self.showing_catalog = true; + self.view.view(cx, ids!(catalog)).set_visible(cx, true); + self.view.view(cx, ids!(import_form)).set_visible(cx, false); + self.view.view(cx, ids!(app_content)).set_visible(cx, false); + self.notice(cx, ""); + } + fn show_import(&mut self, cx: &mut Cx) { + self.showing_catalog = false; + self.view.view(cx, ids!(catalog)).set_visible(cx, false); + self.view.view(cx, ids!(import_form)).set_visible(cx, true); + self.view.view(cx, ids!(app_content)).set_visible(cx, true); + let imported = self.package.as_ref().and_then(Package::builtin_id).is_none(); + self.view.view(cx, ids!(bundle_path)).set_visible(cx, imported); + self.show_assistant_status(cx); + self.view.view(cx, ids!(import_form.core)).set_visible(cx, !crate::octos_service::is_hosted()); + } + fn open_builtin(&mut self, cx: &mut Cx, index: usize) -> Result<(), String> { + let app = crate::system_apps::apps().get(index).ok_or("Unknown built-in app")?; + if app.native.is_some() { + cx.action(MiniAppsAction::Close); + crate::system_apps::launch_native(cx, app); + } else { + self.stop(cx); + self.package = None; + let package = Package::load_builtin(&app.manifest.id, &crate::app_data_dir().join("miniapps/imports"))?; + self.show_import(cx); + self.view.text_input(cx, ids!(room)).set_text(cx, ""); + self.review_package(cx, package)?; + } + Ok(()) + } + fn notice(&mut self, cx: &mut Cx, message: &str) { self.view.label(cx, ids!(notice)).set_text(cx, message); } @@ -212,17 +265,28 @@ impl MiniAppsPanel { Err("This build has no local assistant runtime".into()) } fn review(&mut self, cx: &mut Cx) -> Result<(), String> { + let builtin = self.package.as_ref().and_then(Package::builtin_id); self.stop(cx); - self.package = None; - let path = self.view.text_input(cx, ids!(path)).text(); + if builtin.is_none() { self.package = None; } let snapshots = crate::app_data_dir().to_owned().join("miniapps/imports"); - let package = Package::load_in(&PathBuf::from(path.trim()), &snapshots)?; + let package = if let Some(id) = builtin { + Package::load_builtin(id, &snapshots)? + } else { + let path = self.view.text_input(cx, ids!(path)).text(); + Package::load_in(&PathBuf::from(path.trim()), &snapshots)? + }; + self.review_package(cx, package) + } + fn review_package(&mut self, cx: &mut Cx, package: Package) -> Result<(), String> { let room = self.view.text_input(cx, ids!(room)).text(); if !room.trim().is_empty() { ruma::RoomId::parse(room.trim()).map_err(|_| "Invalid Matrix room ID")?; } let services = package.manifest.capabilities.join(", "); - self.review_notice = format!("{} {} · Local unsigned bundle\nServices: {}\nAllowed room: {}\nRun grants these services for this session. Octos turns may use the connected core's tools.",package.manifest.name,package.manifest.version,services,if room.trim().is_empty(){"None"}else{room.trim()}); + let imported = package.builtin_id().is_none(); + let origin = if imported { "Local unsigned bundle" } else { crate::i18n::tr("Built-in apps") }; + self.view.view(cx, ids!(bundle_path)).set_visible(cx, imported); + self.review_notice = format!("{} {} · {}\nServices: {}\nAllowed room: {}\nRun grants these services for this session. Octos turns may use the connected core's tools.",package.manifest.name,package.manifest.version,origin,services,if room.trim().is_empty(){"None"}else{room.trim()}); let notice = self.review_notice.clone(); self.notice(cx, ¬ice); self.reviewed_room = room.trim().to_string(); @@ -598,7 +662,20 @@ impl Widget for MiniAppsPanel { self.view.handle_event(cx, event, scope); if let Event::Actions(actions) = event { if self.view.button(cx, ids!(close)).clicked(actions) { - cx.action(MiniAppsAction::Close); + if self.showing_catalog { cx.action(MiniAppsAction::Close); } + else { self.show_catalog(cx); } + return; + } + if self.view.button(cx, ids!(import_app)).clicked(actions) { + self.show_import(cx); + self.notice(cx, "Import an OctoSense bundle to review its services."); + } + let list = self.view.portal_list(cx, ids!(catalog_list)); + for (index, item) in list.items_with_actions(actions) { + if !list.was_scrolling() && item.button(cx, ids!(launch)).clicked(actions) { + if let Err(e) = self.open_builtin(cx, index) { self.notice(cx, &e); } + return; + } } let approve = self .view @@ -654,32 +731,50 @@ impl Widget for MiniAppsPanel { } if let Event::BackPressed { handled } = event { handled.set(true); - cx.action(MiniAppsAction::Close); + if self.showing_catalog { cx.action(MiniAppsAction::Close); } + else { self.show_catalog(cx); } } self.pump(cx); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.view.draw_walk(cx, scope, walk) + while let Some(item) = self.view.draw_walk(cx, scope, walk).step() { + if let Some(mut list) = item.borrow_mut::() { + let apps = crate::system_apps::apps(); + list.set_item_range(cx, 0, apps.len()); + while let Some(index) = list.next_visible_item(cx) { + if let Some(app) = apps.get(index) { + let row = list.item(cx, index, id!(App)); + row.button(cx, ids!(launch)).set_text(cx, crate::i18n::tr(&app.manifest.name)); + row.draw_all(cx, &mut Scope::empty()); + } + } + } + } + DrawStep::done() } } impl MiniAppsPanelRef { + /// Consume Back synchronously before the underlying Rinx/shell navigation. + pub fn back(&self, cx: &mut Cx, modal: ModalRef) { + let mut close = false; + if let Some(mut inner) = self.borrow_mut() { + if inner.showing_catalog { close = true; } + else { inner.show_catalog(cx); } + } + if close { self.action(cx, modal, &MiniAppsAction::Close); } + } + pub fn action(&self, cx: &mut Cx, modal: ModalRef, action: &MiniAppsAction) { if let Some(mut inner) = self.borrow_mut() { match action { MiniAppsAction::Open => { inner.open = true; - inner.notice(cx, "Import an OctoSense bundle to review its services."); - inner.show_assistant_status(cx); - // Hosted Rinx uses OctoSense's AI settings: no endpoint - // or key form of its own. - inner - .view - .view(cx, ids!(import_form.core)) - .set_visible(cx, !crate::octos_service::is_hosted()); + inner.show_catalog(cx); modal.open(cx); } MiniAppsAction::OpenReviewed => { inner.open = true; + inner.show_import(cx); let notice = inner.review_notice.clone(); inner.notice(cx, ¬ice); inner.show_assistant_status(cx); diff --git a/src/system_apps.rs b/src/system_apps.rs new file mode 100644 index 00000000..b88c56bb --- /dev/null +++ b/src/system_apps.rs @@ -0,0 +1,28 @@ +//! The build is the authority for Rinx's bundled app identities and native entries. +use std::sync::LazyLock; +pub use rinx_system_apps::{ARTICLE_ID, NativeApp, PackedApp}; +static CATALOG: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/system-apps.json"))) + .expect("build-validated system app catalog") +}); +pub fn apps() -> &'static [PackedApp] { + &CATALOG.apps +} +pub fn get(id: &str) -> Option<&'static PackedApp> { + apps().iter().find(|a| a.manifest.id == id) +} +pub fn is_reserved(id: &str) -> bool { + id == ARTICLE_ID || get(id).is_some() +} +pub fn launch_native(cx: &mut makepad_widgets::Cx, app: &PackedApp) { + match app.native { + Some(NativeApp::ArticleEditor) => cx.action(crate::article_app::ArticleAction::Open), + None => {} + } +} +/// Existing Discover/chat shortcuts use the same catalog entry as Mini apps. +pub fn open_article(cx: &mut makepad_widgets::Cx) { + if let Some(app) = get(ARTICLE_ID) { + launch_native(cx, app); + } +} diff --git a/system-apps.json b/system-apps.json new file mode 100644 index 00000000..be8042df --- /dev/null +++ b/system-apps.json @@ -0,0 +1,11 @@ +{ + "schema": 1, + "source": "apps", + "repository": "https://github.com/hagency-org/Rinx", + "apps": [ + { + "directory": "article-editor", + "native": "article-editor" + } + ] +} diff --git a/tools/package-system-apps/check.sh b/tools/package-system-apps/check.sh new file mode 100644 index 00000000..fa7185ee --- /dev/null +++ b/tools/package-system-apps/check.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/../.." && pwd) +cargo run --locked --manifest-path "$root/crates/system-apps/Cargo.toml" -- "$root" diff --git a/tools/wechat-ux/check_i18n.py b/tools/wechat-ux/check_i18n.py index 1fa0f7a4..b2d0200e 100644 --- a/tools/wechat-ux/check_i18n.py +++ b/tools/wechat-ux/check_i18n.py @@ -2,6 +2,7 @@ """Check catalog parity, placeholders, and explicit Rust UI translation keys.""" import json import re +from itertools import chain from pathlib import Path @@ -17,7 +18,7 @@ def fields(text):return sorted(set(re.findall(r'\{([^{}]+)\}',text))) assert fields(key)==fields(zh[key]), 'Placeholder mismatch: '+key missing=[] sites=0 - for path in (root/'src').rglob('*.rs'): + for path in chain((root/'src').rglob('*.rs'), (root/'apps').rglob('*.rs')): for match in re.finditer(r'i18n::(?:tr|format)\(\s*("(?:\\.|[^"\\])*")',path.read_text()): sites+=1 key=json.loads(match[1]) diff --git a/tools/wechat-ux/live/native_system_apps.py b/tools/wechat-ux/live/native_system_apps.py new file mode 100644 index 00000000..33038686 --- /dev/null +++ b/tools/wechat-ux/live/native_system_apps.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Offline native catalog/authorization check in isolated, hidden windows.""" +import json +import os +from pathlib import Path +import socket +import subprocess +import time +import uuid +from native_probe import NativeApp + + +def main(): + root = Path('target/deployment-validation/system-apps-native') / uuid.uuid4().hex + root.mkdir(parents=True, mode=0o700) + report = {'passed': False, 'checks': []} + for hosted in (False, True): + run = root / ('hosted' if hosted else 'standalone') + profile = run / 'profile' + profile.mkdir(parents=True, mode=0o700) + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + app = NativeApp(run, port=port, auto_login=False) + app.output.mkdir(parents=True, mode=0o700) + app.log = (app.output / 'native.log').open('w') + env = dict(os.environ, RINX_DATA_DIR=str(profile.resolve()), MAKEPAD_HIDE_WINDOWS='1', MAKEPAD_NO_FOCUS='1', MAKEPAD_REMOTE=str(port)) + env.pop('MAKEPAD_FOCUS', None) + args = [str(Path('target/fast/examples/system_app_catalog').resolve())] + if hosted: + args.append('--hosted') + app.process = subprocess.Popen(args, env=env, stdout=app.log, stderr=subprocess.STDOUT) + try: + for _ in range(120): + if app.process.poll() is not None: + raise RuntimeError('Catalog harness exited; see native.log') + try: + status = app.request('/s') + assert status['pid'] == app.process.pid + if status['w']: + break + except OSError: + pass + time.sleep(.25) + app.wait_text('Built-in apps') + app.wait_text('Article editor') + # A hidden Splash must not reserve half the catalog's layout. + button = next(w for w in app.snap() if w['i'] == 'import_app') + height = app.request('/s')['w'][0]['sz'][1] + assert button['r'][1] > height * .75, button + app.capture('catalog') + app.click_id('import_app') + app.wait_text('Review bundle') + fields = {w['i'] for w in app.snap()} + assert ('local_key' in fields) == (not hosted), fields + app.click_id('close') + app.wait_text('Built-in apps') + app.click_id('launch') + app.wait_text('Article studio') + app.click_id('article_continue') + app.wait_text('Sign in to Rinx to authorize this app.') + assert 'article_allow' not in {w['i'] for w in app.snap()} + app.capture('native-editor-login-required') + app.click_id('article_close') + app.wait_text('Built-in apps') + report['checks'].append({'hosted': hosted, 'catalog_to_native_editor': True, 'requires_account_and_consent': True, 'provider_form_matches_deployment': True, 'evidence': str(app.output)}) + finally: + app.stop() + report['passed'] = True + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(dict(report, evidence=str(root))), flush=True) + + +if __name__ == '__main__': + main()