From f9af6fcab34a9e1fa0ffb76047959d8f6e2622ff Mon Sep 17 00:00:00 2001 From: eric8810 Date: Mon, 19 Oct 2026 17:29:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(swift):=20rutis-host=20=E7=9A=84=20run?= =?UTF-8?q?times.swift=E3=80=81dev=E3=80=81check=E3=80=81new=20--lang=20sw?= =?UTF-8?q?ift=20=E4=B8=8E=E6=8C=87=E5=8D=97=EF=BC=88#387=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rutis-host:语言注册表加 Swift(languages/swift.rs);编译型语言(Go、Swift)共用的宿主部分抽到 languages/binary.rs(BinaryHost、DevBuild),Host.go 改为 Host.binaries;config 的 GoRuntime 改名 BinaryRuntime(GoRuntime / SwiftRuntime 为别名) - runtimes.swift:与 runtimes.go 同样的设置与语义(按需启动、空闲停止、eager、restart) - dev:据 Package.swift 认出 Swift 项目,swift build 后复制到 .rutis/swift/<产品>-,改代码只重启这个运行时 - check:列出 Swift 二进制;插件 API 低于 3 的旧 SDK 二进制失败并提示重新构建(Go 同样) - new --lang swift:Package.swift(rutis 依赖、Info.plist 经 linkerSettings 链入)、@RutisConfig / @RutisClient / @RutisService 示例、Swift Testing + RutisTest 测试、发布通用二进制的工作流 - Swift SDK:serve 时让运行时为自己负责(posix_spawn SETEXEC + disclaim),TCC 授权提示指向插件二进制 - 测试:swift_rows 加按需 / 空闲停止 / on-failure / exit;rutis-host tests/swift.rs;e2e dev_loop 加 Swift - 文档:Swift 插件指南(中英)、rutis-host 指南、plugin-api 的 Swift 列去掉预览、README --- .github/workflows/ci.yml | 9 + README.md | 12 +- README.zh-CN.md | 12 +- crates/rutis-host/Cargo.toml | 6 +- crates/rutis-host/src/config.rs | 63 ++-- crates/rutis-host/src/host.rs | 78 ++-- crates/rutis-host/src/languages/binary.rs | 260 ++++++++++++++ crates/rutis-host/src/languages/go.rs | 213 +++++------ crates/rutis-host/src/languages/mod.rs | 45 ++- crates/rutis-host/src/languages/swift.rs | 336 ++++++++++++++++++ crates/rutis-host/src/main.rs | 88 +++-- crates/rutis-host/src/new.rs | 43 ++- crates/rutis-host/src/status.rs | 18 +- .../swift/.github/workflows/release.yml | 58 +++ crates/rutis-host/templates/swift/Info.plist | 24 ++ .../rutis-host/templates/swift/Package.swift | 41 +++ crates/rutis-host/templates/swift/README.md | 12 + .../swift/Sources/__Type__Plugin/Plugin.swift | 45 +++ .../swift/Sources/__name__/main.swift | 5 + .../__Type__PluginTests/PluginTests.swift | 13 + crates/rutis-host/templates/swift/gitignore | 4 + .../rutis-host/templates/swift/rutis.dev.json | 6 + crates/rutis-host/tests/check.rs | 18 +- crates/rutis-host/tests/config.rs | 4 +- crates/rutis-host/tests/errors.rs | 2 +- crates/rutis-host/tests/persisted.rs | 9 +- crates/rutis-host/tests/project.rs | 2 +- crates/rutis-host/tests/swift.rs | 181 ++++++++++ crates/rutis-loader/tests/swift_rows.rs | 236 +++++++++++- docs/guide/README.en.md | 12 +- docs/guide/README.md | 12 +- docs/guide/plugin-api.en.md | 8 +- docs/guide/plugin-api.md | 8 +- docs/guide/rutis-host.en.md | 33 +- docs/guide/rutis-host.md | 33 +- docs/guide/swift-plugin.en.md | 231 ++++++++++++ docs/guide/swift-plugin.md | 231 ++++++++++++ swift/README.md | 6 +- swift/README.zh-CN.md | 6 +- swift/rutis/Sources/Rutis/API/Serve.swift | 1 + .../Rutis/Session/Responsibility.swift | 66 ++++ .../RutisFixturePlugins/Multilang.swift | 11 +- tests/e2e/src/residue.rs | 10 +- tests/e2e/tests/dev_loop.rs | 107 +++++- 44 files changed, 2276 insertions(+), 342 deletions(-) create mode 100644 crates/rutis-host/src/languages/binary.rs create mode 100644 crates/rutis-host/src/languages/swift.rs create mode 100644 crates/rutis-host/templates/swift/.github/workflows/release.yml create mode 100644 crates/rutis-host/templates/swift/Info.plist create mode 100644 crates/rutis-host/templates/swift/Package.swift create mode 100644 crates/rutis-host/templates/swift/README.md create mode 100644 crates/rutis-host/templates/swift/Sources/__Type__Plugin/Plugin.swift create mode 100644 crates/rutis-host/templates/swift/Sources/__name__/main.swift create mode 100644 crates/rutis-host/templates/swift/Tests/__Type__PluginTests/PluginTests.swift create mode 100644 crates/rutis-host/templates/swift/gitignore create mode 100644 crates/rutis-host/templates/swift/rutis.dev.json create mode 100644 crates/rutis-host/tests/swift.rs create mode 100644 docs/guide/swift-plugin.en.md create mode 100644 docs/guide/swift-plugin.md create mode 100644 swift/rutis/Sources/Rutis/Session/Responsibility.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f7ae658..760f6a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,7 @@ jobs: - crates/rutis-bridge/** - crates/rutis-loader/** - crates/rutis-host/** + - tests/e2e/tests/dev_loop.rs # What decides whether the SDK builds reproducibly. Kernel # changes reach the SDK too, but are checked in full runs # (Q12.6.3). @@ -858,6 +859,14 @@ jobs: run: | cargo test -p rutis-loader --features node,python,swift --test swift_rows --test multilang_swift cargo test -p rutis-loader --features node,python,swift --test caller_rows --test sync_cycles swift + # runtimes.swift in rutis-host (rows, restart, check), and the plugin + # author's loop: new --lang swift, swift test, check, dev rebuilding. + - name: cargo test (rutis-host with Swift, the Swift development loop) + env: + RUTIS_E2E_DIR: /tmp/rutis-e2e + run: | + cargo test -p rutis-host --test swift + cargo test -p rutis-e2e --test dev_loop the_swift_plugin_development_loop # The events conformance with X a Swift plugin, in the placements a # pull request runs (written; full runs sample more). - name: cargo test (events conformance, X Swift) diff --git a/README.md b/README.md index 6d4af01e..0ead863d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **A plugin runtime for software that iterates on itself** Agents and long-running programs write, swap and hot-reload their own plugins, and rutis keeps every change safe.
-A Rust core · plugins in TypeScript, Python and Go · across processes and machines +A Rust core · plugins in TypeScript, Python, Go and Swift · across processes and machines [![crates.io](https://img.shields.io/crates/v/rutis.svg?label=crates.io)](https://crates.io/crates/rutis) [![npm](https://img.shields.io/npm/v/@arcships/rutis.svg?label=npm)](https://www.npmjs.com/package/@arcships/rutis) @@ -72,7 +72,7 @@ export default definePlugin<{ city?: string }>({ }) ``` -`llm` can come from another plugin in the same process, from a Python plugin, or from another machine; this plugin stays the same. The full workflow is in the guides for [TypeScript](docs/guide/typescript-plugin.en.md), [Bun](docs/guide/bun-plugin.en.md), [Python](docs/guide/python-plugin.en.md) and [Go](docs/guide/go-plugin.en.md). +`llm` can come from another plugin in the same process, from a Python plugin, or from another machine; this plugin stays the same. The full workflow is in the guides for [TypeScript](docs/guide/typescript-plugin.en.md), [Bun](docs/guide/bun-plugin.en.md), [Python](docs/guide/python-plugin.en.md), [Go](docs/guide/go-plugin.en.md) and [Swift](docs/guide/swift-plugin.en.md). ### In Rust @@ -227,7 +227,7 @@ The [design philosophy](docs/design-philosophy.en.md) explains these choices, an | Running plugins in your app | [`rutis-loader`](https://crates.io/crates/rutis-loader), [`rutis-bridge`](https://crates.io/crates/rutis-bridge), [`rutis-dylib`](https://crates.io/crates/rutis-dylib) (dylib plugins) | [`@arcships/rutis-runtime`](https://www.npmjs.com/package/@arcships/rutis-runtime) (Node), [`@arcships/rutis-bun`](https://www.npmjs.com/package/@arcships/rutis-bun) (Bun) | [`rutis`](https://pypi.org/project/rutis/) | | A host without Rust | [`rutis-host`](https://crates.io/crates/rutis-host) | [`@arcships/rutis-host`](https://www.npmjs.com/package/@arcships/rutis-host) | [`rutis-host`](https://pypi.org/project/rutis-host/) | -Go plugins use the module [`github.com/arcships/rutis/go/rutis`](go/rutis): the SDK, and the runtime each plugin binary carries. +Go plugins use the module [`github.com/arcships/rutis/go/rutis`](go/rutis): the SDK, and the runtime each plugin binary carries. Swift plugins (macOS) use the Swift package of this repository, [`Rutis`](swift/README.md) (`.package(url: "https://github.com/arcships/rutis", from: "0.14.0")`), the same way. All of these form a release train: released together at one version, currently 0.13. Use the same version for every rutis package. @@ -236,13 +236,13 @@ All of these form a release train: released together at one version, currently 0 | Project | | | --- | --- | | [rutis-agent](crates/rutis-agent) · [rutis-cli](crates/rutis-cli) | A minimal coding agent in which the model service, tools, streaming driver and TUI are all plugins. Try it offline with `cargo run -p rutis-cli -- --scripted`. | -| [rutis-host](crates/rutis-host) | A host without Rust: runs TypeScript, JavaScript, Python and Go plugins from a `rutis.json`, reloads them during development, links machines. | +| [rutis-host](crates/rutis-host) | A host without Rust: runs TypeScript, JavaScript, Python, Go and Swift plugins from a `rutis.json`, reloads them during development, links machines. | | [rutis-dsh](crates/rutis-dsh) | Runs the full dsh web interface inside a rutis host, with model calls served by aimux in the same process. | | [aimux-llm](crates/aimux-llm) | Wraps [aimux](https://crates.io/crates/aimux-core) as an LLM service plugin. | ## Documentation -- **[Guides](docs/guide/README.en.md)**: organized by task. Plugins in TypeScript, Bun, Python and Go, running rutis-host, linking nodes, embedding in Rust, working with Cordis. +- **[Guides](docs/guide/README.en.md)**: organized by task. Plugins in TypeScript, Bun, Python, Go and Swift, running rutis-host, linking nodes, embedding in Rust, working with Cordis. - **[Design philosophy](docs/design-philosophy.en.md)**: why rutis exists, the shape that follows, its costs and principles. - **[Application design guide](docs/development-guide.en.md)**: splitting an app into plugins, drawing the dependency graph, designing reloads and multiple instances. - **[Development handbook](docs/development-handbook.en.md)**: API usage, resource cleanup, events, troubleshooting and verification. @@ -257,7 +257,7 @@ All of these form a release train: released together at one version, currently 0 rutis is at 0.x and its API is still evolving. Breaking changes are listed in the release notes and come with a migration guide. - **The core** is pure Rust with tokio, tokio-util, thiserror, serde and serde_json as its only dependencies; it needs Rust 1.88 or later. -- **Language runtimes and rutis-host** (Node, Bun, Python and Go rows, remote runtimes, shared services, peers, Cordis mounts) run on Linux, macOS and Windows x64 (MSVC); they need Node 22+, Bun 1.4+ or Python 3.10+; Go plugins are compiled binaries, built with Go 1.24+. The Bun runtime is not tested on Windows yet. +- **Language runtimes and rutis-host** (Node, Bun, Python and Go rows, remote runtimes, shared services, peers, Cordis mounts) run on Linux, macOS and Windows x64 (MSVC); they need Node 22+, Bun 1.4+ or Python 3.10+; Go plugins are compiled binaries, built with Go 1.24+. Swift plugins are compiled binaries for macOS 14+, built with a Swift 6 toolchain. The Bun runtime is not tested on Windows yet. - **dylib plugins** load on Linux, macOS and Windows x64 (MSVC). ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d473e0a..774d91d3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ **为自我迭代的软件准备的插件运行时** agent 和长期运行的程序自己写插件、换插件、热重载插件,rutis 保证每一次变化都安全。
-Rust 内核 · TypeScript、Python 与 Go 插件 · 跨进程,跨机器 +Rust 内核 · TypeScript、Python、Go 与 Swift 插件 · 跨进程,跨机器 [![crates.io](https://img.shields.io/crates/v/rutis.svg?label=crates.io)](https://crates.io/crates/rutis) [![npm](https://img.shields.io/npm/v/@arcships/rutis.svg?label=npm)](https://www.npmjs.com/package/@arcships/rutis) @@ -72,7 +72,7 @@ export default definePlugin<{ city?: string }>({ }) ``` -`llm` 可以来自同一进程里的另一个插件、一个 Python 插件,或者另一台机器,这个插件都不用改。完整流程见 [TypeScript](docs/guide/typescript-plugin.md)、[Bun](docs/guide/bun-plugin.md)、[Python](docs/guide/python-plugin.md) 和 [Go](docs/guide/go-plugin.md) 插件指南。 +`llm` 可以来自同一进程里的另一个插件、一个 Python 插件,或者另一台机器,这个插件都不用改。完整流程见 [TypeScript](docs/guide/typescript-plugin.md)、[Bun](docs/guide/bun-plugin.md)、[Python](docs/guide/python-plugin.md)、[Go](docs/guide/go-plugin.md) 和 [Swift](docs/guide/swift-plugin.md) 插件指南。 ### 在 Rust 中 @@ -227,7 +227,7 @@ stateDiagram-v2 | 在应用中运行插件 | [`rutis-loader`](https://crates.io/crates/rutis-loader)、[`rutis-bridge`](https://crates.io/crates/rutis-bridge)、[`rutis-dylib`](https://crates.io/crates/rutis-dylib)(dylib 插件) | [`@arcships/rutis-runtime`](https://www.npmjs.com/package/@arcships/rutis-runtime)(Node)、[`@arcships/rutis-bun`](https://www.npmjs.com/package/@arcships/rutis-bun)(Bun) | [`rutis`](https://pypi.org/project/rutis/) | | 不写 Rust 的宿主 | [`rutis-host`](https://crates.io/crates/rutis-host) | [`@arcships/rutis-host`](https://www.npmjs.com/package/@arcships/rutis-host) | [`rutis-host`](https://pypi.org/project/rutis-host/) | -Go 插件用模块 [`github.com/arcships/rutis/go/rutis`](go/rutis):SDK,以及每个插件二进制自带的运行时。 +Go 插件用模块 [`github.com/arcships/rutis/go/rutis`](go/rutis):SDK,以及每个插件二进制自带的运行时。Swift 插件(macOS)同样用本仓库的 Swift 包 [`Rutis`](swift/README.zh-CN.md)(`.package(url: "https://github.com/arcships/rutis", from: "0.14.0")`)。 以上所有包组成发布列车,一起发布、版本相同,当前为 0.13。各个 rutis 包请使用同一个版本。 @@ -236,13 +236,13 @@ Go 插件用模块 [`github.com/arcships/rutis/go/rutis`](go/rutis):SDK,以 | 项目 | | | --- | --- | | [rutis-agent](crates/rutis-agent) · [rutis-cli](crates/rutis-cli) | 最小的 coding agent:模型服务、工具、流式驱动和 TUI 都是插件。`cargo run -p rutis-cli -- --scripted` 可以离线体验。 | -| [rutis-host](crates/rutis-host) | 不写 Rust 的宿主:按 `rutis.json` 运行 TypeScript、JavaScript、Python 和 Go 插件,开发时自动重载,连接多台机器。 | +| [rutis-host](crates/rutis-host) | 不写 Rust 的宿主:按 `rutis.json` 运行 TypeScript、JavaScript、Python、Go 和 Swift 插件,开发时自动重载,连接多台机器。 | | [rutis-dsh](crates/rutis-dsh) | 在 rutis 宿主里运行 dsh 的完整 web 界面,模型调用由同进程的 aimux 提供。 | | [aimux-llm](crates/aimux-llm) | 把 [aimux](https://crates.io/crates/aimux-core) 包装成一个 LLM 服务插件。 | ## 文档 -- **[指南](docs/guide/README.md)**:按任务组织。写 TypeScript / Bun / Python / Go 插件、运行 rutis-host、连接节点、在 Rust 中嵌入、与 Cordis 互通。 +- **[指南](docs/guide/README.md)**:按任务组织。写 TypeScript / Bun / Python / Go / Swift 插件、运行 rutis-host、连接节点、在 Rust 中嵌入、与 Cordis 互通。 - **[设计哲学](docs/design-philosophy.md)**:rutis 为什么存在,由此而来的形态、代价和原则。 - **[应用设计指南](docs/development-guide.md)**:如何拆分插件、画依赖图、设计重载与多实例。 - **[开发手册](docs/development-handbook.md)**:API 用法、资源清理、事件、排障与验证。 @@ -257,7 +257,7 @@ Go 插件用模块 [`github.com/arcships/rutis/go/rutis`](go/rutis):SDK,以 rutis 仍处于 0.x,API 还会演进。不兼容的变化会写进发布说明,并附迁移指南。 - **内核** 是纯 Rust,依赖只有 tokio、tokio-util、thiserror、serde 和 serde_json;需要 Rust 1.88 或更高。 -- **语言运行时与 rutis-host**(Node、Bun、Python 与 Go 行、远程运行时、跨语言共享服务、节点、Cordis 挂载)支持 Linux、macOS 和 Windows x64(MSVC);需要 Node 22+、Bun 1.4+ 或 Python 3.10+;Go 插件是编译好的二进制,构建时需要 Go 1.24+。Bun 运行时还没有在 Windows 上测试。 +- **语言运行时与 rutis-host**(Node、Bun、Python 与 Go 行、远程运行时、跨语言共享服务、节点、Cordis 挂载)支持 Linux、macOS 和 Windows x64(MSVC);需要 Node 22+、Bun 1.4+ 或 Python 3.10+;Go 插件是编译好的二进制,构建时需要 Go 1.24+;Swift 插件是 macOS 14+ 的编译好的二进制,构建时需要 Swift 6 工具链。Bun 运行时还没有在 Windows 上测试。 - **dylib 插件** 支持 Linux、macOS 和 Windows x64(MSVC)。 ## 参与 diff --git a/crates/rutis-host/Cargo.toml b/crates/rutis-host/Cargo.toml index 3cc92f50..070c6b9b 100644 --- a/crates/rutis-host/Cargo.toml +++ b/crates/rutis-host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rutis-host" -description = "A rutis host that needs no Rust: run plugins written in TypeScript, JavaScript, Python and Go (in Node, Bun, Python or Go binaries) from a rutis.json, develop them with live reload, link machines" +description = "A rutis host that needs no Rust: run plugins written in TypeScript, JavaScript, Python, Go and Swift (in Node, Bun, Python, Go or Swift binaries) from a rutis.json, develop them with live reload, link machines" # The release train. version = "0.13.0" edition.workspace = true @@ -20,8 +20,8 @@ path = "src/main.rs" [dependencies] rutis = { path = "../rutis", version = "0.13.0" } -rutis-bridge = { path = "../rutis-bridge", version = "0.13.0", features = ["node", "python", "bun", "go", "websocket"] } -rutis-loader = { path = "../rutis-loader", version = "0.13.0", features = ["node", "python", "bun", "go", "peer"] } +rutis-bridge = { path = "../rutis-bridge", version = "0.13.0", features = ["node", "python", "bun", "go", "swift", "websocket"] } +rutis-loader = { path = "../rutis-loader", version = "0.13.0", features = ["node", "python", "bun", "go", "swift", "peer"] } # To say which file is not PEM (the transport only sees the contents). rustls-pki-types = { version = "1", features = ["std"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/rutis-host/src/config.rs b/crates/rutis-host/src/config.rs index 49562386..1fe78f60 100644 --- a/crates/rutis-host/src/config.rs +++ b/crates/rutis-host/src/config.rs @@ -7,7 +7,8 @@ //! "runtimes": { //! "node": { "project": "." }, //! "py": { "project": ".", "python": ".venv/bin/python", "restart": "on-failure" }, -//! "go": { "dir": "plugins/go", "start": "on-demand", "idle": 60 } +//! "go": { "dir": "plugins/go", "start": "on-demand", "idle": 60 }, +//! "swift": { "dir": "plugins/swift", "restart": "on-failure" } //! }, //! "listen": [{ "name": "public", "address": "0.0.0.0:7443", "cert": "server.pem", "key": "server.key" }], //! "rows": [ @@ -113,6 +114,9 @@ pub struct Runtimes { pub bun: Option, /// Go binaries, one runtime each, started when a row uses them. pub go: Option, + /// Swift binaries (macOS), one runtime each, started when a row uses + /// them: the same settings as Go's. + pub swift: Option, /// Runtimes on other machines: reached through a `rutis-bridge/peer` /// row with `"runtime": ""`, or dialing in to a listener, each /// connection an instance of a group (`listen` and `instances`). @@ -210,20 +214,24 @@ pub struct PythonRuntime { pub restart: Restart, } +/// `runtimes.go` and `runtimes.swift`: the binaries of a compiled language, +/// one runtime each. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] -pub struct GoRuntime { - /// Every Go binary in this directory (trusted as a whole: its files - /// that contain the Go SDK's marker are run to read their manifests). +pub struct BinaryRuntime { + /// Every binary of the language in this directory (trusted as a whole: + /// its files that contain the SDK's marker are run to read their + /// manifests). #[serde(default)] pub dir: Option, - /// Go binaries, each a runtime named after its file (`go-`). + /// Binaries, each a runtime named after its file (`go-`, + /// `swift-`). #[serde(default)] pub binaries: Vec, /// `on-demand` (the default): a runtime starts when a row uses it; /// `eager`: every one starts with the host and keeps running. #[serde(default)] - pub start: GoStart, + pub start: BinaryStart, /// Seconds an on-demand runtime stays idle before it stops (60). #[serde(default)] pub idle: Option, @@ -239,8 +247,14 @@ pub struct GoRuntime { pub dev: Option<(PathBuf, String)>, } +/// `runtimes.go`: Go binaries. +pub type GoRuntime = BinaryRuntime; + +/// `runtimes.swift`: Swift binaries. +pub type SwiftRuntime = BinaryRuntime; + /// What the host does when a local runtime's process ends on its own -/// (`runtimes..restart`; `docs/guide/rutis-host.md`, "When +/// (`runtimes..restart`; `docs/guide/rutis-host.md`, "When /// a runtime ends"). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -276,24 +290,28 @@ impl Restart { } } +/// When a compiled language's runtimes start (`runtimes..start`). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] -pub enum GoStart { +pub enum BinaryStart { #[default] OnDemand, Eager, } -impl GoRuntime { - /// Refuse settings that cannot work together. - pub fn validate(&self) -> Result<(), String> { +/// `runtimes.go.start`. +pub type GoStart = BinaryStart; + +impl BinaryRuntime { + /// Refuse settings that cannot work together, as `runtimes.`. + pub fn validate(&self, key: &str) -> Result<(), String> { if self.dir.is_none() && self.binaries.is_empty() && self.dev.is_none() { - return Err("runtimes.go needs a dir or binaries".into()); + return Err(format!("runtimes.{key} needs a dir or binaries")); } - if self.start == GoStart::Eager && self.idle.is_some() { - return Err( - "runtimes.go: eager runtimes keep running, so idle does not apply to them".into(), - ); + if self.start == BinaryStart::Eager && self.idle.is_some() { + return Err(format!( + "runtimes.{key}: eager runtimes keep running, so idle does not apply to them" + )); } Ok(()) } @@ -649,16 +667,19 @@ mod tests { fn go_runtimes_are_checked() { let parse = |value: serde_json::Value| -> GoRuntime { serde_json::from_value(value).unwrap() }; - assert!(parse(json!({ "dir": "plugins/go" })).validate().is_ok()); + assert!(parse(json!({ "dir": "plugins/go" })).validate("go").is_ok()); assert!( parse(json!({ "binaries": ["bin/netkit"], "start": "eager" })) - .validate() + .validate("go") .is_ok() ); - let none = parse(json!({})).validate().unwrap_err(); - assert!(none.contains("needs a dir or binaries"), "{none}"); + let none = parse(json!({})).validate("swift").unwrap_err(); + assert!( + none.contains("runtimes.swift needs a dir or binaries"), + "{none}" + ); let both = parse(json!({ "dir": "d", "start": "eager", "idle": 30 })) - .validate() + .validate("go") .unwrap_err(); assert!(both.contains("idle does not apply"), "{both}"); let mut config: HostConfig = serde_json::from_value(json!({ diff --git a/crates/rutis-host/src/host.rs b/crates/rutis-host/src/host.rs index 1615f835..57807b32 100644 --- a/crates/rutis-host/src/host.rs +++ b/crates/rutis-host/src/host.rs @@ -17,13 +17,13 @@ use rutis_bridge::{ PeerId, Presented, SharedToken, }; use rutis_loader::{ - register_peer_node, Builtins, Chain, GoResolver, GoRuntimesHandle, Layer, Loader, - LoaderOptions, LoaderPlugin, Patch, PeerResolver, RuntimeEnd, RuntimeInstances, - RuntimeResolver, RuntimeRows, RuntimeRowsPlugin, ServiceCatalog, + register_peer_node, Builtins, Chain, Layer, Loader, LoaderOptions, LoaderPlugin, Patch, + PeerResolver, RuntimeEnd, RuntimeInstances, RuntimeResolver, RuntimeRows, RuntimeRowsPlugin, + ServiceCatalog, }; use crate::config::{token, HostConfig, Restart}; -use crate::languages::{self, Mount, LANGUAGES}; +use crate::languages::{self, BinaryHost, Mount, LANGUAGES}; use crate::{errln, outln}; pub struct Host { @@ -33,8 +33,9 @@ pub struct Host { pub runtimes: Vec<(String, RuntimeHandle)>, /// The resolvers of the runtimes' rows, to invalidate after a change. pub resolvers: Vec>, - /// The Go binaries and their runtimes, when the configuration has them. - pub go: Option, + /// The compiled languages' binaries and runtimes (Go, Swift), those + /// the configuration has. + pub binaries: Vec, /// The local runtimes' plugins, by name, to stop one /// ([`Host::stop_runtime`]). fibers: Vec<(String, FiberView)>, @@ -55,8 +56,6 @@ pub struct Host { accepted: Option, /// What each local runtime's `restart` says, by name. restarts: Vec<(String, Restart)>, - /// What `runtimes.go.restart` says, for every Go runtime. - go_restart: Restart, /// The tasks watching the local runtimes ([`Host::supervise`]), by /// name. supervisors: Mutex)>>, @@ -92,12 +91,6 @@ pub struct Embedding { pub identity: Option, } -/// The Go side of a host. -pub struct GoHost { - pub resolver: Arc, - pub runtimes: GoRuntimesHandle, -} - pub type Error = String; /// How long the runtime processes may take to exit once every cleanup @@ -316,23 +309,18 @@ impl Host { ) }) .collect(); - // Go's runtimes are many, and come and go: their plugin supervises - // them ([`Host::supervise`]). - let go_restart = LANGUAGES - .iter() - .find(|language| language.key() == "go") - .map_or(Restart::No, |go| go.restart(&config.runtimes)); - // What waits for the loader: Go's runtimes. - let mut go = None; + // What waits for the loader: the compiled languages' runtimes, many, + // coming and going: their plugins supervise them ([`Host::supervise`]). + let mut binaries = Vec::new(); for later in later { - go = later(root.clone()).await?.or(go); + binaries.extend(later(root.clone()).await?); } Ok(Self { root, loader, runtimes, resolvers, - go, + binaries, fibers, processes, peers, @@ -340,7 +328,6 @@ impl Host { config: config.clone(), accepted, restarts, - go_restart, supervisors: Mutex::default(), }) } @@ -349,15 +336,20 @@ impl Host { /// ([`crate::restart::supervise`]): `on-failure` starts it again when /// its process ends on its own; one whose `restart` is `exit` comes out /// of the [`Exits`] returned when it ends, for the host to stop. The - /// Go runtimes, which start and stop as rows use them, are supervised - /// by their plugin ([`rutis_loader::GoRuntimesHandle::supervise`]), - /// each on its own, with the same numbers and lines. + /// runtimes of compiled languages (Go, Swift), which start and stop as + /// rows use them, are supervised by their plugin + /// ([`rutis_loader::BinaryRuntimesHandle::supervise`]), each on its + /// own, with the same numbers and lines. pub fn supervise(&self) -> Exits { let (send, exits) = tokio::sync::mpsc::unbounded_channel(); - if let (Some(go), Restart::OnFailure | Restart::Exit) = (&self.go, self.go_restart) { + for binaries in &self.binaries { + if let Restart::No = binaries.restart { + continue; + } let send = send.clone(); - go.runtimes - .supervise(self.go_restart.into(), move |end| match end { + binaries.supervise( + binaries.restart.into(), + Box::new(move |end| match end { RuntimeEnd::Exit { runtime, why } => { let _ = send.send((runtime, why)); } @@ -366,7 +358,8 @@ impl Host { outln!("{line}"); } } - }); + }), + ); } let mut supervisors = self.supervisors.lock().unwrap(); for (name, restart) in &self.restarts { @@ -398,8 +391,10 @@ impl Host { /// Stop watching the runtime `name`, or every runtime: the host is /// about to stop it. fn unsupervise(&self, name: Option<&str>) { - if let (None, Some(go)) = (name, &self.go) { - go.runtimes.unsupervise(); + if name.is_none() { + for binaries in &self.binaries { + binaries.unsupervise(); + } } self.supervisors.lock().unwrap().retain(|(runtime, task)| { @@ -491,7 +486,8 @@ impl Host { /// /// - What a running host cannot change refuses it, saying to restart /// the host: `format`, `id`, `listen`, `runtimes.remote`, and a local - /// runtime added or changed (`runtimes.go` in any way). + /// runtime added or changed (`runtimes.go` and `runtimes.swift` in any + /// way). /// - The new rows are checked first ([`Loader::check`]): a row that /// would fail, and does not fail now, refuses it. /// - Then they replace the old ones in one reconcile; a row failing @@ -712,6 +708,12 @@ impl Host { ) } + /// The binaries and runtimes of the compiled language `key` (`go`, + /// `swift`), when the configuration has it. + pub fn binaries_of(&self, key: &str) -> Option<&BinaryHost> { + self.binaries.iter().find(|binaries| binaries.key == key) + } + /// What the status lines follow ([`crate::status::follow`]): the rows, /// their links, and the runtimes. pub fn followed(&self) -> crate::status::Followed { @@ -719,7 +721,7 @@ impl Host { loader: self.loader.clone(), peers: self.peers.clone(), runtimes: self.runtimes.clone(), - go: self.go.as_ref().map(|go| go.runtimes.clone()), + binaries: self.binaries.clone(), } } @@ -1623,8 +1625,8 @@ mod tests { .unwrap() .unwrap(); assert_eq!(reply.json().unwrap(), json!("netkit pong go")); - let go = host.go.as_ref().unwrap(); - assert_eq!(go.runtimes.runtimes()[0].name, "go-netkit"); + let go = host.binaries_of("go").unwrap(); + assert_eq!(go.runtimes()[0].name, "go-netkit"); } /// A Go binary on another machine (here, listening on loopback): a peer diff --git a/crates/rutis-host/src/languages/binary.rs b/crates/rutis-host/src/languages/binary.rs new file mode 100644 index 00000000..a5b7a4fc --- /dev/null +++ b/crates/rutis-host/src/languages/binary.rs @@ -0,0 +1,260 @@ +//! What the compiled languages (Go, Swift) share: `runtimes.` names +//! binaries, one runtime each, started when a row uses them (or with the +//! host), their rows `:` and `-:`. +//! +//! Such a language overrides [`Language::start`] with [`start`]: the +//! binaries' resolver joins the chain now, their runtimes mount once the +//! loader runs ([`super::Later`]), and the host keeps a [`BinaryHost`] for +//! `check`, `dev` and the status lines. `rutis-host dev` rebuilds a project +//! of it with its own build ([`DevBuild`]). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use rutis::{BoxFuture, Ctx}; +use rutis_bridge::runtime::{RuntimeState, Stdio}; +use rutis_loader::{ + Binaries, BinaryFile, BinaryLanguage, BinaryResolver, BinaryRuntimeInfo, BinaryRuntimes, + BinaryRuntimesHandle, RestartPolicy, RuntimeEnd, ServiceCatalog, +}; + +use super::{Language, Mount, LANGUAGES}; +use crate::config::{BinaryRuntime, BinaryStart, HostConfig, Restart}; +use crate::host::Error; + +/// A compiled language's binaries and runtimes in a running host. +#[derive(Clone)] +pub struct BinaryHost { + /// Its key under `runtimes` (`go`, `swift`). + pub key: &'static str, + /// What `runtimes..restart` says, for each of its runtimes. + pub restart: Restart, + side: Arc, +} + +impl BinaryHost { + /// The language's name in messages (`Go`, `Swift`). + pub fn title(&self) -> &'static str { + self.side.title() + } + + /// The plugin API its binaries may declare at most. + pub fn plugin_api(&self) -> u64 { + self.side.plugin_api() + } + + /// Every binary, with its manifest or why it has none. + pub fn binaries(&self) -> Vec { + self.side.binaries() + } + + /// The candidate files skipped, and why. + pub fn diagnostics(&self) -> Vec { + self.side.diagnostics() + } + + /// The runtime `runtime` runs `path` from its next start on. + pub fn replace(&self, runtime: &str, path: &Path) { + self.side.replace(runtime, path) + } + + /// Stop the runtime `runtime` and start it again from its binary. + pub fn restart_runtime<'a>(&'a self, runtime: &'a str) -> BoxFuture<'a, Result<(), String>> { + self.side.restart(runtime) + } + + /// From now on, do what `policy` says when one of its runtimes ends on + /// its own, telling `report`. + pub fn supervise(&self, policy: RestartPolicy, report: Box) { + self.side.supervise(policy, report) + } + + /// Stop supervising its runtimes. + pub fn unsupervise(&self) { + self.side.unsupervise() + } + + /// The state of each runtime started and not stopped since, by name. + pub fn states(&self) -> Vec<(String, RuntimeState)> { + self.side.states() + } + + /// Every runtime: those that run, and the binaries not started. + pub fn runtimes(&self) -> Vec { + self.side.runtimes() + } +} + +/// [`BinaryHost`]'s language-independent face of a [`BinaryResolver`] and +/// its [`BinaryRuntimesHandle`]. +trait Side: Send + Sync { + fn title(&self) -> &'static str; + fn plugin_api(&self) -> u64; + fn binaries(&self) -> Vec; + fn diagnostics(&self) -> Vec; + fn replace(&self, runtime: &str, path: &Path); + fn restart<'a>(&'a self, runtime: &'a str) -> BoxFuture<'a, Result<(), String>>; + fn supervise(&self, policy: RestartPolicy, report: Box); + fn unsupervise(&self); + fn states(&self) -> Vec<(String, RuntimeState)>; + fn runtimes(&self) -> Vec; +} + +struct Of { + resolver: Arc>, + runtimes: BinaryRuntimesHandle, +} + +impl Side for Of { + fn title(&self) -> &'static str { + L::TITLE + } + + fn plugin_api(&self) -> u64 { + L::PLUGIN_API + } + + fn binaries(&self) -> Vec { + self.resolver.binaries() + } + + fn diagnostics(&self) -> Vec { + self.resolver.diagnostics() + } + + fn replace(&self, runtime: &str, path: &Path) { + self.resolver.replace(runtime, path) + } + + fn restart<'a>(&'a self, runtime: &'a str) -> BoxFuture<'a, Result<(), String>> { + Box::pin(self.runtimes.restart(runtime)) + } + + fn supervise(&self, policy: RestartPolicy, report: Box) { + self.runtimes.supervise(policy, report) + } + + fn unsupervise(&self) { + self.runtimes.unsupervise() + } + + fn states(&self) -> Vec<(String, RuntimeState)> { + self.runtimes.states() + } + + fn runtimes(&self) -> Vec { + self.runtimes.runtimes() + } +} + +/// [`Language::start`] of the compiled language `language` (`L`), whose +/// settings are `settings`: the binaries' resolver now, their runtimes once +/// the loader runs. +pub(super) fn start( + language: &dyn Language, + settings: &BinaryRuntime, + config: &HostConfig, + mount: &mut Mount<'_>, +) -> Result<(), Error> { + let key = language.key(); + let resolver = Arc::new(resolver::(key, config, settings, mount.catalog)?); + mount.resolve(resolver.clone()); + let (project, start, idle) = (settings.project.clone(), settings.start, settings.idle); + let restart = settings.restart; + mount.later.push(Box::new(move |root: Ctx| { + Box::pin(async move { + let mut plugin = BinaryRuntimes::::new(resolver.clone(), &project) + .output(Stdio::Lines, Stdio::Lines); + plugin = match start { + BinaryStart::Eager => plugin.eager(), + BinaryStart::OnDemand => plugin.idle(Some(Duration::from_secs(idle.unwrap_or(60)))), + }; + let runtimes = plugin.handle(); + (&root.plugin(plugin)) + .await + .map_err(|error| format!("the {} runtimes: {error}", L::TITLE))?; + Ok(Some(BinaryHost { + key, + restart, + side: Arc::new(Of { resolver, runtimes }), + })) + }) + })); + Ok(()) +} + +/// The resolver of the binaries `settings` names. A runtime of them may not +/// take another runtime's name: another language's, or a remote runtime's. +fn resolver( + key: &str, + config: &HostConfig, + settings: &BinaryRuntime, + catalog: &ServiceCatalog, +) -> Result, Error> { + settings.validate(key)?; + let mut binaries = Binaries::new(); + if let Some(dir) = &settings.dir { + binaries = binaries.dir(dir); + } + for binary in &settings.binaries { + binaries = binaries.file(binary); + } + if let Some((binary, name)) = &settings.dev { + binaries = binaries.file_named(binary, name); + } + let reserved = LANGUAGES + .iter() + .map(|language| language.key()) + .filter(|other| *other != key) + .map(str::to_owned) + .chain( + config + .runtimes + .remote + .iter() + .map(|remote| remote.name.clone()), + ); + Ok(BinaryResolver::::new(binaries) + .with_catalog(catalog) + .reserve(reserved)) +} + +/// The error of a remote runtime named `name` that a compiled language's +/// rows would take (`` and `-`), else `None`. +pub(super) fn claims(key: &str, title: &str, name: &str) -> Option { + let prefixed = name + .strip_prefix(key) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('-')); + prefixed.then(|| { + format!( + "remote runtime {name}: {key} and names starting with {key}- are the local {title} runtimes'" + ) + }) +} + +/// A plugin project of a compiled language as `rutis-host dev` builds it: +/// each build to a new file (`.rutis//-`), which its runtime +/// then restarts on. +pub trait DevBuild: Send + Sync { + /// The project's name, as the build's lines say it. + fn name(&self) -> &str; + + /// The runtime its binary runs as (`go-`, `swift-`). + fn runtime(&self) -> &str; + + /// Build #`n` without blocking: its binary, or the compiler's errors. + /// Dropped, it kills the build. + fn rebuild(&self, n: u32) -> BoxFuture<'_, Result>; + + /// Where build #`n` is. + fn binary(&self, n: u32) -> PathBuf; +} + +/// Run `command` without blocking, killed when dropped: its output. +pub(super) async fn run(command: std::process::Command) -> std::io::Result { + tokio::process::Command::from(command) + .kill_on_drop(true) + .output() + .await +} diff --git a/crates/rutis-host/src/languages/go.rs b/crates/rutis-host/src/languages/go.rs index 50890b3b..b9197285 100644 --- a/crates/rutis-host/src/languages/go.rs +++ b/crates/rutis-host/src/languages/go.rs @@ -2,21 +2,19 @@ //! (`go-`), started when a row uses it (or with the host); rows //! `go:` and `go-:`. //! -//! Unlike the other languages it has no single runtime: it overrides -//! [`Language::start`] to add the binaries' resolver to the chain, and -//! mounts the runtimes once the loader runs ([`super::Later`]). +//! Unlike the interpreted languages it has no single runtime: it overrides +//! [`Language::start`] with what the compiled languages share +//! ([`super::binary`]). use std::path::{Path, PathBuf}; -use std::sync::Arc; -use rutis::Ctx; -use rutis_bridge::runtime::Stdio; -use rutis_loader::{GoBinaries, GoResolver, GoRuntimes, ServiceCatalog}; +use rutis::BoxFuture; use serde_json::{json, Value}; -use super::{id_of, Language, Mount, LANGUAGES}; -use crate::config::{GoRuntime, GoStart, HostConfig, Restart, Runtimes}; -use crate::host::{Error, GoHost}; +use super::binary::{self, DevBuild}; +use super::{id_of, Language, Mount}; +use crate::config::{BinaryStart, GoRuntime, HostConfig, Restart, Runtimes}; +use crate::host::Error; pub struct Go; @@ -54,13 +52,7 @@ impl Language for Go { fn rebase(&self, runtimes: &mut Runtimes, at: &dyn Fn(&mut PathBuf)) { if let Some(go) = &mut runtimes.go { - if let Some(dir) = &mut go.dir { - at(dir); - } - for binary in &mut go.binaries { - at(binary); - } - at(&mut go.project); + rebase(go, at); } } @@ -72,11 +64,7 @@ impl Language for Go { /// `go:` and `go-:` are the Go runtimes' rows. fn claims(&self, name: &str) -> Option { - (name == "go" || name.starts_with("go-")).then(|| { - format!( - "remote runtime {name}: go and names starting with go- are the local Go runtimes'" - ) - }) + binary::claims("go", "Go", name) } fn remote_names(&self) -> &'static [&'static str] { @@ -85,30 +73,10 @@ impl Language for Go { /// The binaries' resolver now; their runtimes once the loader runs. fn start(&self, config: &HostConfig, mount: &mut Mount<'_>) -> Result<(), Error> { - let Some(go) = &config.runtimes.go else { - return Ok(()); - }; - let resolver = Arc::new(resolver(config, go, mount.catalog)?); - mount.resolve(resolver.clone()); - let (project, start, idle) = (go.project.clone(), go.start, go.idle); - mount.later.push(Box::new(move |root: Ctx| { - Box::pin(async move { - let mut plugin = - GoRuntimes::new(resolver.clone(), &project).output(Stdio::Lines, Stdio::Lines); - plugin = match start { - GoStart::Eager => plugin.eager(), - GoStart::OnDemand => { - plugin.idle(Some(std::time::Duration::from_secs(idle.unwrap_or(60)))) - } - }; - let runtimes = plugin.handle(); - (&root.plugin(plugin)) - .await - .map_err(|error| format!("the Go runtimes: {error}"))?; - Ok(Some(GoHost { resolver, runtimes })) - }) - })); - Ok(()) + match &config.runtimes.go { + Some(go) => binary::start::(self, go, config, mount), + None => Ok(()), + } } fn new_names(&self) -> &'static [&'static str] { @@ -136,66 +104,62 @@ impl Language for Go { fn dev(&self, dir: &Path) -> Result<(Vec, Runtimes), String> { let go = GoDev::of(dir).ok_or_else(|| format!("{}: no go.mod", dir.display()))?; let binary = go.build(1)?; - let manifest = rutis_loader::read_go_manifest(&binary) - .map_err(|error| format!("{}: {error}", binary.display()))?; - let rows: Vec = manifest - .plugins - .keys() - .map(|plugin| json!({ "id": id_of(plugin), "name": format!("{}:{plugin}", go.runtime), "config": {} })) - .collect(); - if rows.is_empty() { - return Err(format!("{} serves no plugins", go.package)); - } - let runtimes = Runtimes { - go: Some(GoRuntime { - dir: None, - binaries: Vec::new(), - start: GoStart::OnDemand, - idle: None, - project: go.dir.clone(), - restart: Restart::No, - - dev: Some((binary, go.runtime.clone())), - }), - ..Runtimes::default() - }; - Ok((rows, runtimes)) + let (rows, settings) = dev_rows(&binary, &go.runtime, &go.dir, &go.package)?; + Ok(( + rows, + Runtimes { + go: Some(settings), + ..Runtimes::default() + }, + )) + } + + fn dev_build(&self, dir: &Path) -> Option> { + GoDev::of(dir).map(|go| Box::new(go) as Box) } } -/// The resolver of the Go binaries `go` names. A Go runtime may not take -/// another runtime's name: another language's, or a remote runtime's. -fn resolver( - config: &HostConfig, - go: &GoRuntime, - catalog: &ServiceCatalog, -) -> Result { - go.validate()?; - let mut binaries = GoBinaries::new(); - if let Some(dir) = &go.dir { - binaries = binaries.dir(dir); +/// Make the paths of a compiled language's settings absolute with `at`. +pub(super) fn rebase(settings: &mut GoRuntime, at: &dyn Fn(&mut PathBuf)) { + if let Some(dir) = &mut settings.dir { + at(dir); } - for binary in &go.binaries { - binaries = binaries.file(binary); + for binary in &mut settings.binaries { + at(binary); } - if let Some((binary, name)) = &go.dev { - binaries = binaries.file_named(binary, name); + at(&mut settings.project); +} + +/// The rows of a compiled language's development build `binary`, one per +/// plugin of its manifest named with its runtime (`:`), +/// and the settings that run it from `project`; `what` names it when it +/// serves nothing. +pub(super) fn dev_rows( + binary: &Path, + runtime: &str, + project: &Path, + what: &str, +) -> Result<(Vec, GoRuntime), String> { + let manifest = rutis_loader::read_binary_manifest(binary) + .map_err(|error| format!("{}: {error}", binary.display()))?; + let rows: Vec = manifest + .plugins + .keys() + .map(|plugin| json!({ "id": id_of(plugin), "name": format!("{runtime}:{plugin}"), "config": {} })) + .collect(); + if rows.is_empty() { + return Err(format!("{what} serves no plugins")); } - let reserved = LANGUAGES - .iter() - .map(|language| language.key()) - .filter(|key| *key != Go.key()) - .map(str::to_owned) - .chain( - config - .runtimes - .remote - .iter() - .map(|remote| remote.name.clone()), - ); - Ok(GoResolver::new(binaries) - .with_catalog(catalog) - .reserve(reserved)) + let settings = GoRuntime { + dir: None, + binaries: Vec::new(), + start: BinaryStart::OnDemand, + idle: None, + project: project.to_owned(), + restart: Restart::No, + dev: Some((binary.to_owned(), runtime.to_owned())), + }; + Ok((rows, settings)) } /// A Go plugin project as `rutis-host dev` builds it: its main package and @@ -252,26 +216,12 @@ impl GoDev { built(command.output(), binary) } - /// [`GoDev::build`], without blocking; dropped, it kills the go - /// command. - pub async fn rebuild(&self, n: u32) -> Result { - let (command, binary) = self.build_command(n)?; - let output = tokio::process::Command::from(command) - .kill_on_drop(true) - .output() - .await; - built(output, binary) - } - /// The command that builds #`n`, and the binary it writes. fn build_command(&self, n: u32) -> Result<(std::process::Command, PathBuf), String> { - let builds = self.dir.join(".rutis").join("go"); - std::fs::create_dir_all(&builds).map_err(|error| error.to_string())?; - let file = match cfg!(windows) { - true => format!("{}-{n}.exe", self.name), - false => format!("{}-{n}", self.name), - }; - let binary = builds.join(file); + let binary = self.binary(n); + if let Some(builds) = binary.parent() { + std::fs::create_dir_all(builds).map_err(|error| error.to_string())?; + } let mut command = std::process::Command::new("go"); command .args(["build", "-o"]) @@ -282,6 +232,33 @@ impl GoDev { } } +impl DevBuild for GoDev { + fn name(&self) -> &str { + &self.name + } + + fn runtime(&self) -> &str { + &self.runtime + } + + /// [`GoDev::build`], without blocking; dropped, it kills the go + /// command. + fn rebuild(&self, n: u32) -> BoxFuture<'_, Result> { + Box::pin(async move { + let (command, binary) = self.build_command(n)?; + built(binary::run(command).await, binary) + }) + } + + fn binary(&self, n: u32) -> PathBuf { + let file = match cfg!(windows) { + true => format!("{}-{n}.exe", self.name), + false => format!("{}-{n}", self.name), + }; + self.dir.join(".rutis").join("go").join(file) + } +} + /// `binary`, if the build that wrote it succeeded; else the compiler's /// errors. fn built( diff --git a/crates/rutis-host/src/languages/mod.rs b/crates/rutis-host/src/languages/mod.rs index 7fda1f74..101fe63d 100644 --- a/crates/rutis-host/src/languages/mod.rs +++ b/crates/rutis-host/src/languages/mod.rs @@ -22,14 +22,18 @@ use rutis_loader::{Chain, Resolver, RuntimeResolver, ServiceCatalog}; use serde_json::Value; use crate::config::{HostConfig, Restart, Runtimes}; -use crate::host::{Error, GoHost}; +use crate::host::Error; +mod binary; mod bun; mod go; mod node; mod python; +mod swift; +pub use binary::{BinaryHost, DevBuild}; pub use go::GoDev; +pub use swift::SwiftDev; /// Every local language, in the order messages list them (`new --lang`) /// and `rutis-host dev` tries them on a project (Node before Bun: Node's @@ -38,7 +42,13 @@ pub use go::GoDev; /// The host starts them in the reverse order: their resolvers are tried in /// that order, and Node's, which takes bare npm names and `file:` URLs, /// comes last, after every prefixed one (`go:`, `py:`, `bun:`). -pub static LANGUAGES: &[&dyn Language] = &[&node::Node, &bun::Bun, &python::Python, &go::Go]; +pub static LANGUAGES: &[&dyn Language] = &[ + &node::Node, + &bun::Bun, + &python::Python, + &go::Go, + &swift::Swift, +]; /// One local language. pub trait Language: Sync { @@ -111,9 +121,10 @@ pub trait Language: Sync { /// messages show. fn new_names(&self) -> &'static [&'static str]; - /// The files of a new project: (path, text), with `__module__` and - /// `__name__` in paths and `{{name}}`, `{{id}}`, `{{module}}`, - /// `{{package}}`, `{{version}}` and `{{next}}` in texts filled in. + /// The files of a new project: (path, text), with `__module__`, + /// `__Type__` and `__name__` in paths and `{{name}}`, `{{id}}`, + /// `{{module}}`, `{{Type}}` (`NetProbe`), `{{package}}`, `{{version}}` + /// and `{{next}}` in texts filled in. fn template(&self) -> &'static [(&'static str, &'static str)]; /// What to run in a new project, as `rutis-host new` says. @@ -134,6 +145,13 @@ pub trait Language: Sync { /// project's own settings stay. A language with a `restart` setting /// takes the dev file's: `dev` restarts as `run` does. fn dev_file(&self, _own: &mut Runtimes, _dev: &Runtimes) {} + + /// A compiled language's project in `dir`, which `rutis-host dev` + /// rebuilds as it changes and restarts its runtime on, instead of + /// reloading its rows; `None` for the others. + fn dev_build(&self, _dir: &Path) -> Option> { + None + } } /// The error of a remote runtime named `name` when another runtime has @@ -219,13 +237,15 @@ pub struct Mount<'a> { pub fibers: Vec<(String, FiberView)>, /// The processes each local runtime started, by name. pub processes: Vec<(String, Processes)>, - /// What mounts once the loader runs (Go's runtimes, which start as - /// rows use them), and the [`GoHost`] it gives the host. + /// What mounts once the loader runs (the compiled languages' runtimes, + /// which start as rows use them), and the [`BinaryHost`] it gives the + /// host. pub later: Vec, } /// A language's part of the start that waits for the loader. -pub type Later = Box BoxFuture<'static, Result, Error>> + Send>; +pub type Later = + Box BoxFuture<'static, Result, Error>> + Send>; impl Mount<'_> { /// Try `resolver` after the resolvers added before it. @@ -335,10 +355,13 @@ mod tests { #[test] fn messages_list_the_languages() { let names: Vec = new_names().iter().map(|n| n.to_string()).collect(); - assert_eq!(either(&names), "node, bun, python or go"); + assert_eq!(either(&names), "node, bun, python, go or swift"); let remote: Vec = remote_names().iter().map(|n| n.to_string()).collect(); - assert_eq!(either(&remote), "node, bun, python or go"); + assert_eq!(either(&remote), "node, bun, python, go or swift"); let files: Vec = project_files().iter().map(|f| f.to_string()).collect(); - assert_eq!(either(&files), "package.json, pyproject.toml or go.mod"); + assert_eq!( + either(&files), + "package.json, pyproject.toml, go.mod or Package.swift" + ); } } diff --git a/crates/rutis-host/src/languages/swift.rs b/crates/rutis-host/src/languages/swift.rs new file mode 100644 index 00000000..3a9c029d --- /dev/null +++ b/crates/rutis-host/src/languages/swift.rs @@ -0,0 +1,336 @@ +//! Swift (macOS): `runtimes.swift`, compiled binaries built with the Swift +//! SDK, one runtime each (`swift-`), started when a row uses it (or +//! with the host); rows `swift:` and `swift-:`. +//! The same settings and machinery as Go's ([`super::binary`]); a project +//! is a SwiftPM package (Package.swift) that `dev` builds with `swift +//! build`. + +use std::path::{Path, PathBuf}; + +use rutis::BoxFuture; +use serde_json::Value; + +use super::binary::{self, DevBuild}; +use super::go::{dev_rows, rebase}; +use super::{Language, Mount}; +use crate::config::{HostConfig, Restart, Runtimes}; +use crate::host::Error; + +pub struct Swift; + +impl Language for Swift { + fn key(&self) -> &'static str { + "swift" + } + + fn configured(&self, runtimes: &Runtimes) -> bool { + runtimes.swift.is_some() + } + + fn settings(&self, runtimes: &Runtimes) -> Option { + runtimes.swift.as_ref().map(|swift| format!("{swift:?}")) + } + + /// Each Swift runtime on its own, as Go's. + fn restart(&self, runtimes: &Runtimes) -> Restart { + runtimes + .swift + .as_ref() + .map_or(Restart::No, |swift| swift.restart) + } + + /// The dev file's `restart`; the project's other settings stay. + fn dev_file(&self, own: &mut Runtimes, dev: &Runtimes) { + if let (Some(own), Some(dev)) = (&mut own.swift, &dev.swift) { + own.restart = dev.restart; + } + } + + /// As Go's: started as rows use them, from the loader's resolver. + fn stops_when_removed(&self) -> bool { + false + } + + fn rebase(&self, runtimes: &mut Runtimes, at: &dyn Fn(&mut PathBuf)) { + if let Some(swift) = &mut runtimes.swift { + rebase(swift, at); + } + } + + fn merge(&self, runtimes: &mut Runtimes, other: &mut Runtimes) { + if runtimes.swift.is_none() { + runtimes.swift = other.swift.take(); + } + } + + /// `swift:` and `swift-:` are the Swift + /// runtimes' rows. + fn claims(&self, name: &str) -> Option { + binary::claims("swift", "Swift", name) + } + + /// A Swift runtime on another machine, or in an app, that dials in or + /// listens (`runtimes.remote[].language`). + fn remote_names(&self) -> &'static [&'static str] { + &["swift"] + } + + fn start(&self, config: &HostConfig, mount: &mut Mount<'_>) -> Result<(), Error> { + match &config.runtimes.swift { + Some(swift) => binary::start::(self, swift, config, mount), + None => Ok(()), + } + } + + fn new_names(&self) -> &'static [&'static str] { + &["swift"] + } + + fn template(&self) -> &'static [(&'static str, &'static str)] { + TEMPLATE + } + + fn next_steps(&self) -> &'static str { + "swift test && rutis-host dev" + } + + fn project_file(&self) -> &'static str { + "Package.swift" + } + + fn is_project(&self, dir: &Path) -> bool { + dir.join("Package.swift").is_file() + } + + /// Build it, then one row per plugin of its binary, named with its + /// runtime (`swift-:`). + fn dev(&self, dir: &Path) -> Result<(Vec, Runtimes), String> { + let swift = + SwiftDev::of(dir).ok_or_else(|| format!("{}: no Package.swift", dir.display()))?; + let binary = swift.build(1)?; + let (rows, settings) = dev_rows(&binary, &swift.runtime, &swift.dir, &swift.product)?; + Ok(( + rows, + Runtimes { + swift: Some(settings), + ..Runtimes::default() + }, + )) + } + + fn dev_build(&self, dir: &Path) -> Option> { + SwiftDev::of(dir).map(|swift| Box::new(swift) as Box) + } +} + +/// A Swift plugin project as `rutis-host dev` builds it: a SwiftPM package +/// and its executable product, the binary a host runs. Each build is +/// copied to `.rutis/swift/-`, a new file: a binary rewritten +/// in place while it runs would no longer match its code signature. +#[derive(Debug, Clone)] +pub struct SwiftDev { + pub dir: PathBuf, + /// The executable product: the target `Sources//main.swift` + /// is, `` the package's name or else the first such target. + pub product: String, + pub runtime: String, +} + +impl SwiftDev { + /// The Swift project in `dir`, if it is one (a Package.swift). + pub fn of(dir: &Path) -> Option { + let manifest = std::fs::read_to_string(dir.join("Package.swift")).ok()?; + let name = package_name(&manifest); + let sources = dir.join("Sources"); + let product = name + .filter(|name| sources.join(name).join("main.swift").is_file()) + .or_else(|| { + let mut targets: Vec = std::fs::read_dir(&sources) + .ok()? + .flatten() + .filter(|entry| entry.path().join("main.swift").is_file()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + targets.sort(); + targets.into_iter().next() + }) + .unwrap_or_else(|| { + dir.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "plugin".into()) + }); + let runtime = rutis_bridge::runtime::swift_runtime_name(Path::new(&product)); + Some(Self { + dir: dir.to_owned(), + product, + runtime, + }) + } + + /// Build #`n`: its binary, or the compiler's errors. + pub fn build(&self, n: u32) -> Result { + let built = self.build_command().output(); + self.copied(built, n) + } + + /// `swift build` of the product, in the debug configuration: what the + /// development loop rebuilds should build fast. + fn build_command(&self) -> std::process::Command { + let mut command = std::process::Command::new("swift"); + command + .args(["build", "--product", &self.product]) + .current_dir(&self.dir); + command + } + + /// Where `swift build` puts the products. + fn bin_path(&self) -> Result { + let output = std::process::Command::new("swift") + .args(["build", "--show-bin-path"]) + .current_dir(&self.dir) + .output() + .map_err(|error| format!("cannot run swift: {error}"))?; + match output.status.success() { + true => Ok(PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim(), + )), + false => Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()), + } + } + + /// Build #`n` at its own path, once `built` (the build's output) says + /// it succeeded; else the compiler's errors. + fn copied( + &self, + built: std::io::Result, + n: u32, + ) -> Result { + let output = built.map_err(|error| { + format!("cannot run swift (is a Swift toolchain on PATH?): {error}") + })?; + if !output.status.success() { + // The compiler's errors are on stdout, the build system's on + // stderr. + let mut errors = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + errors = format!("{errors}\n{}", stderr.trim()).trim().to_owned(); + } + return Err(errors); + } + let product = self.bin_path()?.join(&self.product); + let binary = self.binary(n); + if let Some(builds) = binary.parent() { + std::fs::create_dir_all(builds).map_err(|error| error.to_string())?; + } + let _ = std::fs::remove_file(&binary); + std::fs::copy(&product, &binary) + .map_err(|error| format!("{}: {error}", product.display()))?; + Ok(binary) + } +} + +impl DevBuild for SwiftDev { + fn name(&self) -> &str { + &self.product + } + + fn runtime(&self) -> &str { + &self.runtime + } + + /// [`SwiftDev::build`], without blocking; dropped, it kills `swift + /// build`. + fn rebuild(&self, n: u32) -> BoxFuture<'_, Result> { + Box::pin(async move { + let built = binary::run(self.build_command()).await; + self.copied(built, n) + }) + } + + fn binary(&self, n: u32) -> PathBuf { + self.dir + .join(".rutis") + .join("swift") + .join(format!("{}-{n}", self.product)) + } +} + +/// The `name:` of the `Package(...)` in a Package.swift. +fn package_name(manifest: &str) -> Option { + let rest = &manifest[manifest.find("Package(")? + "Package(".len()..]; + let rest = rest.trim_start().strip_prefix("name:")?.trim_start(); + let rest = rest.strip_prefix('"')?; + Some(rest[..rest.find('"')?].to_owned()) +} + +const TEMPLATE: &[(&str, &str)] = &[ + ( + "Package.swift", + include_str!("../../templates/swift/Package.swift"), + ), + ( + "Info.plist", + include_str!("../../templates/swift/Info.plist"), + ), + ( + "Sources/__Type__Plugin/Plugin.swift", + include_str!("../../templates/swift/Sources/__Type__Plugin/Plugin.swift"), + ), + ( + "Sources/__name__/main.swift", + include_str!("../../templates/swift/Sources/__name__/main.swift"), + ), + ( + "Tests/__Type__PluginTests/PluginTests.swift", + include_str!("../../templates/swift/Tests/__Type__PluginTests/PluginTests.swift"), + ), + ( + "rutis.dev.json", + include_str!("../../templates/swift/rutis.dev.json"), + ), + ("README.md", include_str!("../../templates/swift/README.md")), + ( + ".gitignore", + include_str!("../../templates/swift/gitignore"), + ), + ( + ".github/workflows/release.yml", + include_str!("../../templates/swift/.github/workflows/release.yml"), + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_package_is_named_and_its_product_found() { + assert_eq!( + package_name("let package = Package(\n name: \"net-probe\",\n").as_deref(), + Some("net-probe") + ); + assert_eq!(package_name("let package = Package(platforms: [])"), None); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Package.swift"), + "let package = Package(name: \"kits\")", + ) + .unwrap(); + // No target of the package's name: the first with a main.swift. + for target in ["Zeta", "Alpha", "Lib"] { + std::fs::create_dir_all(dir.path().join("Sources").join(target)).unwrap(); + } + std::fs::write(dir.path().join("Sources/Zeta/main.swift"), "").unwrap(); + std::fs::write(dir.path().join("Sources/Alpha/main.swift"), "").unwrap(); + let swift = SwiftDev::of(dir.path()).unwrap(); + assert_eq!( + (swift.product.as_str(), swift.runtime.as_str()), + ("Alpha", "swift-alpha") + ); + std::fs::create_dir_all(dir.path().join("Sources/kits")).unwrap(); + std::fs::write(dir.path().join("Sources/kits/main.swift"), "").unwrap(); + assert_eq!(SwiftDev::of(dir.path()).unwrap().product, "kits"); + assert!(swift.binary(3).ends_with(".rutis/swift/Alpha-3")); + } +} diff --git a/crates/rutis-host/src/main.rs b/crates/rutis-host/src/main.rs index 11691e88..21df17b7 100644 --- a/crates/rutis-host/src/main.rs +++ b/crates/rutis-host/src/main.rs @@ -4,7 +4,7 @@ //! rutis-host run [rutis.json] run what the file names //! rutis-host dev run the plugin project here, reloading it as it changes //! rutis-host check [rutis.json] describe every row, and fail on what cannot run -//! rutis-host new --lang node|bun|python|go +//! rutis-host new --lang node|bun|python|go|swift //! rutis-host go add @ [rutis.json] //! ``` @@ -18,28 +18,35 @@ mod stop; use stop::{Ended, Stopped}; -/// The plugin API of Go binaries this host runs (their manifests); Node, Bun -/// and Python runtimes check their own plugins against their own API. One -/// definition, shared with the loader that reads the manifests. +/// The plugin API of Go and Swift binaries this host runs (their +/// manifests); Node, Bun and Python runtimes check their own plugins +/// against their own API. One definition, shared with the loader that +/// reads the manifests. const PLUGIN_API: u64 = rutis_loader::PLUGIN_API; +const _: () = assert!(PLUGIN_API == rutis_loader::SWIFT_PLUGIN_API); + +/// The oldest plugin API of a binary this host runs: an SDK older than rutis +/// 0.13 (plugin API 2 or lower) does not declare the event protocol of 0.13, +/// so its session is refused at the handshake; `check` says so first. +const OLDEST_PLUGIN_API: u64 = 3; const USAGE: &str = "\ -rutis-host: run rutis plugins written in TypeScript, JavaScript, Python and Go +rutis-host: run rutis plugins written in TypeScript, JavaScript, Python, Go and Swift usage: rutis-host run [rutis.json] run what the configuration names rutis-host dev [dir] run the plugin project in dir (default .), - reloading (Go: rebuilding) it as its files change + reloading (Go, Swift: rebuilding) it as its files change run and dev: --shutdown-timeout how long cleanups may take after Ctrl-C or SIGTERM (default 10, or RUTIS_SHUTDOWN_TIMEOUT) run: --watch-config read rutis.json again when it changes (SIGUSR1 does too, on Unix) - rutis-host check [rutis.json] [--strict] describe every row and Go binary; fail on what + rutis-host check [rutis.json] [--strict] describe every row and Go or Swift binary; fail on what cannot run (in a plugin project without rutis.json: the project); warn of a service no row declares, which --strict fails - rutis-host new --lang node|bun|python|go + rutis-host new --lang node|bun|python|go|swift create a plugin project rutis-host go add @ [rutis.json] install a Go plugin binary into runtimes.go.dir @@ -278,13 +285,12 @@ async fn dev(args: &[String]) -> Result { std::env::set_var("RUTIS_DEV", "1"); let (config, id) = project::dev_config(&dir)?; warn(&config); - // A Go project is rebuilt, not reloaded: the loop below does both. - let go_project = match languages::of_project(&dir) { - Some(language) if language.key() == "go" => { - project::GoDev::of(&std::path::absolute(&dir).unwrap_or(dir.clone())) - } - _ => None, - }; + // A compiled language's project (Go, Swift) is rebuilt, not reloaded: + // the loop below does both. + let compiled = languages::of_project(&dir).and_then(|language| { + let build = language.dev_build(&std::path::absolute(&dir).unwrap_or(dir.clone()))?; + Some((language.key(), build)) + }); let mut build = 1; let host = host::Host::start(&config).await?; for line in status::listening(&host.listening) { @@ -322,12 +328,15 @@ async fn dev(args: &[String]) -> Result { continue; } seen = now; - if let (Some(go), Some(running)) = (&go_project, &host.go) { - // A Go plugin is a binary: build it again, then restart its - // runtime on the new build. A failed build keeps the old one. - // Nor does a build: a signal ends it (and the go command). + let running = compiled + .as_ref() + .and_then(|(key, project)| Some((project, host.binaries_of(key)?))); + if let Some((project, running)) = running { + // A Go or Swift plugin is a binary: build it again, then restart + // its runtime on the new build. A failed build keeps the old one. + // Nor does a build: a signal ends it (and the build command). let built = tokio::select! { - built = go.rebuild(build + 1) => built, + built = project.rebuild(build + 1) => built, signal = signals.next() => { return Ok(stop::stop(&host, signal, deadline, &mut signals).await); } @@ -335,27 +344,23 @@ async fn dev(args: &[String]) -> Result { match built { Ok(binary) => { build += 1; - running.resolver.replace(&go.runtime, &binary); + running.replace(project.runtime(), &binary); // A runtime that hangs starting does not keep a signal out. let restarted = tokio::select! { - restarted = running.runtimes.restart(&go.runtime) => restarted, + restarted = running.restart_runtime(project.runtime()) => restarted, signal = signals.next() => { return Ok(stop::stop(&host, signal, deadline, &mut signals).await); } }; match restarted { - Ok(()) => outln!("{}: rebuilt and restarted", go.runtime), - Err(error) => outln!("{}: cannot restart: {error}", go.runtime), + Ok(()) => outln!("{}: rebuilt and restarted", project.runtime()), + Err(error) => outln!("{}: cannot restart: {error}", project.runtime()), } - let previous = go.dir.join(".rutis/go").join(match cfg!(windows) { - true => format!("{}-{}.exe", go.name, build - 1), - false => format!("{}-{}", go.name, build - 1), - }); - let _ = std::fs::remove_file(previous); + let _ = std::fs::remove_file(project.binary(build - 1)); } Err(errors) => outln!( "{}: the build failed; the last build keeps running\n{errors}", - go.name + project.name() ), } continue; @@ -403,7 +408,7 @@ async fn check(args: &[String]) -> Result<(), String> { let host = host::Host::start(&config).await?; host.runtimes_ready().await?; outln!( - "plugin API for Go binaries: {PLUGIN_API} (checked by this host; the Node, Bun and Python runtimes check their own plugins)" + "plugin API for Go and Swift binaries: {PLUGIN_API} (checked by this host; the Node, Bun and Python runtimes check their own plugins)" ); // Each local runtime: what implements it and what it runs on. for (name, runtime) in &host.runtimes { @@ -510,9 +515,9 @@ async fn check(args: &[String]) -> Result<(), String> { _ => outln!("{id} ({name}): not checked"), } } - if let Some(go) = &host.go { - outln!("go binaries:"); - for binary in go.resolver.binaries() { + for binaries in &host.binaries { + outln!("{} binaries:", binaries.key); + for binary in binaries.binaries() { match &binary.manifest { Ok(manifest) => { let plugins: Vec = manifest @@ -523,9 +528,12 @@ async fn check(args: &[String]) -> Result<(), String> { None => name.clone(), }) .collect(); - let api = match manifest.plugin_api <= PLUGIN_API { - true => "compatible", - false => "incompatible: upgrade the host", + let api = if manifest.plugin_api > binaries.plugin_api() { + "incompatible: upgrade the host" + } else if manifest.plugin_api < OLDEST_PLUGIN_API { + "incompatible: built with an SDK older than rutis 0.13; rebuild it with the 0.13 SDK or later" + } else { + "compatible" }; outln!( " {} ({}): {}sdk {}, plugin API {} ({api}), plugins: {}", @@ -540,7 +548,9 @@ async fn check(args: &[String]) -> Result<(), String> { manifest.plugin_api, plugins.join(", ") ); - if manifest.plugin_api > PLUGIN_API { + if manifest.plugin_api > binaries.plugin_api() + || manifest.plugin_api < OLDEST_PLUGIN_API + { failed += 1; } } @@ -553,7 +563,7 @@ async fn check(args: &[String]) -> Result<(), String> { } } } - for skipped in go.resolver.diagnostics() { + for skipped in binaries.diagnostics() { failed += 1; outln!(" skipped {skipped}"); } diff --git a/crates/rutis-host/src/new.rs b/crates/rutis-host/src/new.rs index 81af531d..58d39af9 100644 --- a/crates/rutis-host/src/new.rs +++ b/crates/rutis-host/src/new.rs @@ -1,4 +1,4 @@ -//! `rutis-host new --lang node|bun|python|go`: a plugin project from a +//! `rutis-host new --lang node|bun|python|go|swift`: a plugin project from a //! template, with a working plugin, its test, a dev configuration and a //! publish workflow. @@ -46,16 +46,29 @@ pub fn create(parent: &Path, name: &str, lang: &str) -> Result<(), String> { let module = name.replace('-', "_"); // A Go package name has no `-` or `_`. let package = name.replace('-', ""); + // A Swift type name: `net-probe` is `NetProbe`. + let type_name: String = name + .split('-') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect(); let next = next_minor(VERSION); for (path, text) in files { let path = path .replace("__module__", &module) + .replace("__Type__", &type_name) .replace("__name__", name); let text = text .replace("{{package}}", &package) .replace("{{name}}", name) .replace("{{id}}", name) .replace("{{module}}", &module) + .replace("{{Type}}", &type_name) .replace("{{version}}", VERSION) .replace("{{next}}", &next); let target = dir.join(&path); @@ -125,8 +138,34 @@ mod tests { assert!( plugin.starts_with("// Package netprobe") && plugin.contains("Name: \"net-probe\"") ); + create(dir.path(), "mac-kit", "swift").unwrap(); + let manifest = std::fs::read_to_string(dir.path().join("mac-kit/Package.swift")).unwrap(); + assert!( + manifest.contains(&format!( + ".package(url: \"https://github.com/arcships/rutis\", from: \"{VERSION}\")" + )) && manifest.contains("name: \"MacKitPlugin\"") + && manifest.contains("__info_plist"), + "{manifest}" + ); + let plugin = + std::fs::read_to_string(dir.path().join("mac-kit/Sources/MacKitPlugin/Plugin.swift")) + .unwrap(); + assert!(plugin.contains("name: \"mac-kit\""), "{plugin}"); + for file in [ + "Sources/mac-kit/main.swift", + "Tests/MacKitPluginTests/PluginTests.swift", + "Info.plist", + ] { + assert!(dir.path().join("mac-kit").join(file).exists(), "{file}"); + } // Each project's dev configuration names its format (risk J3). - for project in ["weather-plugin", "greeter", "bun-greeter", "net-probe"] { + for project in [ + "weather-plugin", + "greeter", + "bun-greeter", + "net-probe", + "mac-kit", + ] { let path = dir.path().join(project).join("rutis.dev.json"); let text = std::fs::read_to_string(&path).unwrap(); let value: serde_json::Value = serde_json::from_str(&text).unwrap(); diff --git a/crates/rutis-host/src/status.rs b/crates/rutis-host/src/status.rs index 1ad9e548..726492c5 100644 --- a/crates/rutis-host/src/status.rs +++ b/crates/rutis-host/src/status.rs @@ -11,7 +11,9 @@ use rutis::FiberState; use crate::outln; use rutis_bridge::runtime::{RuntimeHandle, RuntimeState}; use rutis_bridge::{Failure, LinkState}; -use rutis_loader::{EntryStatus, GoRuntimesHandle, Loader, PeerResolver}; +use rutis_loader::{EntryStatus, Loader, PeerResolver}; + +use crate::languages::BinaryHost; /// One line for a row's state. pub fn describe(status: &EntryStatus) -> String { @@ -147,20 +149,22 @@ pub struct Followed { pub peers: Arc, /// The local runtimes (`node`, `py`, `bun`) by name. pub runtimes: Vec<(String, RuntimeHandle)>, - /// The Go runtimes, when the configuration has them. - pub go: Option, + /// The compiled languages' runtimes (Go, Swift), those the + /// configuration has. + pub binaries: Vec, } impl Followed { - /// Each local runtime's state by name, Go's among them. + /// Each local runtime's state by name, the compiled languages' among + /// them. fn runtime_states(&self) -> Vec<(String, RuntimeState)> { let mut states: Vec<(String, RuntimeState)> = self .runtimes .iter() .map(|(name, handle)| (name.clone(), handle.state())) .collect(); - if let Some(go) = &self.go { - states.extend(go.states()); + for binaries in &self.binaries { + states.extend(binaries.states()); } states } @@ -187,7 +191,7 @@ impl Shown { } /// The lines for the runtimes `states` whose state changed. A runtime - /// gone from `states` keeps its last line: one Go stops when idle, and + /// gone from `states` keeps its last line: one of Go or Swift stops when idle, and /// one the host stops goes on to `Idle`, neither printed. pub fn runtime_changes(&mut self, states: &[(String, RuntimeState)]) -> Vec { let mut out = Vec::new(); diff --git a/crates/rutis-host/templates/swift/.github/workflows/release.yml b/crates/rutis-host/templates/swift/.github/workflows/release.yml new file mode 100644 index 00000000..5d4feca7 --- /dev/null +++ b/crates/rutis-host/templates/swift/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write # the release and its binary + +jobs: + release: + # Xcode: building for two architectures at once needs it. + runs-on: macos-15 + env: + SIGNING: ${{ secrets.MACOS_CERTIFICATE != '' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: swift test + - run: npx --yes @arcships/rutis-host@^{{version}} check + - name: build a universal binary (arm64 + x86_64) + run: | + swift build -c release --arch arm64 --arch x86_64 --product {{name}} + mkdir -p dist + cp .build/apple/Products/Release/{{name}} dist/{{name}}-macos-universal + lipo -info dist/{{name}}-macos-universal + # Signed with a Developer ID and notarized when the repository has the + # secrets; otherwise the binary is ad-hoc signed, and a host that + # downloads it must trust it (`xattr -d com.apple.quarantine`). + - name: sign and notarize + if: env.SIGNING == 'true' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} # a .p12, base64 + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} # "Developer ID Application: …" + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + keychain="$RUNNER_TEMP/signing.keychain-db" + security create-keychain -p "" "$keychain" + security set-keychain-settings "$keychain" + security unlock-keychain -p "" "$keychain" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "" "$keychain" + security list-keychains -d user -s "$keychain" + codesign --force --options runtime --timestamp --identifier com.example.{{name}} \ + --sign "$MACOS_SIGNING_IDENTITY" dist/{{name}}-macos-universal + ditto -c -k dist/{{name}}-macos-universal "$RUNNER_TEMP/{{name}}.zip" + xcrun notarytool submit "$RUNNER_TEMP/{{name}}.zip" --wait \ + --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" + - run: (cd dist && shasum -a 256 * > SHA256SUMS) + - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes + env: + GH_TOKEN: ${{ github.token }} diff --git a/crates/rutis-host/templates/swift/Info.plist b/crates/rutis-host/templates/swift/Info.plist new file mode 100644 index 00000000..c82c5f12 --- /dev/null +++ b/crates/rutis-host/templates/swift/Info.plist @@ -0,0 +1,24 @@ + + + + + + CFBundleIdentifier + com.example.{{name}} + CFBundleName + {{name}} + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + + diff --git a/crates/rutis-host/templates/swift/Package.swift b/crates/rutis-host/templates/swift/Package.swift new file mode 100644 index 00000000..11c1c4e7 --- /dev/null +++ b/crates/rutis-host/templates/swift/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version:6.0 +// The {{name}} rutis plugin: the library {{Type}}Plugin (the plugin), the +// binary {{name}} a host runs (Sources/{{name}}/main.swift), and its tests. +import Foundation +import PackageDescription + +// Info.plist is linked into the binary: macOS shows its usage descriptions +// when the plugin asks for access (calendars, contacts, ...), and the access +// it grants is the binary's own. +let infoPlist = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Info.plist").path + +let package = Package( + name: "{{name}}", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "{{name}}", targets: ["{{name}}"]), + .library(name: "{{Type}}Plugin", targets: ["{{Type}}Plugin"]), + ], + dependencies: [ + // The Swift SDK released with the rutis-host that made this project. + .package(url: "https://github.com/arcships/rutis", from: "{{version}}"), + ], + targets: [ + .target( + name: "{{Type}}Plugin", + dependencies: [.product(name: "Rutis", package: "rutis")] + ), + .executableTarget( + name: "{{name}}", + dependencies: ["{{Type}}Plugin", .product(name: "Rutis", package: "rutis")], + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", infoPlist]), + ] + ), + .testTarget( + name: "{{Type}}PluginTests", + dependencies: ["{{Type}}Plugin", .product(name: "RutisTest", package: "rutis")] + ), + ], + swiftLanguageModes: [.v5] +) diff --git a/crates/rutis-host/templates/swift/README.md b/crates/rutis-host/templates/swift/README.md new file mode 100644 index 00000000..f6eebeaa --- /dev/null +++ b/crates/rutis-host/templates/swift/README.md @@ -0,0 +1,12 @@ +# {{name}} + +A [rutis](https://github.com/arcships/rutis) plugin in Swift (macOS 14 or later). + +```bash +swift test # unit tests, no host needed +rutis-host dev # run it in a local host; changes rebuild and restart it +``` + +A host runs the binary the product `{{name}}` builds (`Sources/{{name}}/main.swift`). `Info.plist` is linked into it: when the plugin asks for access to calendars, contacts and the like, macOS names this binary and shows the usage description you add there. + +Push a `v*` tag and the workflow in `.github/workflows/release.yml` tests the plugin and attaches a universal binary (arm64 and x86_64, with SHA-256 sums) to a GitHub release, signed with a Developer ID and notarized when the repository has the secrets it names. A host puts the binary in its Swift plugin directory (`runtimes.swift.dir`) and adds a row `{ "id": "{{id}}", "name": "swift:{{id}}" }`. diff --git a/crates/rutis-host/templates/swift/Sources/__Type__Plugin/Plugin.swift b/crates/rutis-host/templates/swift/Sources/__Type__Plugin/Plugin.swift new file mode 100644 index 00000000..6bd064c6 --- /dev/null +++ b/crates/rutis-host/templates/swift/Sources/__Type__Plugin/Plugin.swift @@ -0,0 +1,45 @@ +// The {{name}} rutis plugin. +import Rutis + +/// The plugin's configuration: a plain Decodable type. `@RutisConfig` +/// derives its JSON Schema, which `rutis-host check` validates rows with. +@RutisConfig +public struct Config: Decodable { + /// how to greet + public var greeting: String? +} + +/// A service other plugins provide, in any language: `@RutisClient` makes +/// `ClockProxy`. To use one, add "clock" to `inject` and, in `apply`, +/// `let clock = try ctx.use("clock", ClockProxy.self)`. +@RutisClient +public protocol Clock { + func now() async throws -> String +} + +/// The service the plugin provides: `@RutisService` makes its methods the +/// service's (`hello` to other languages). +@RutisService +public final class Greeter: @unchecked Sendable { + let greeting: String + + init(greeting: String) { self.greeting = greeting } + + public func hello(_ name: String) -> String { "\(greeting), \(name)!" } +} + +public let plugin = Rutis.define(Plugin( + name: "{{id}}", + // Services this plugin uses: it runs while they are all there. + inject: [], + // What ctx.provide provides reaches rutis by itself, every method async. + // Declare provides: ["greeter": .of(Greeter.self, sync: ["hello"])] only + // to make methods sync, or for `rutis-host check` to know it before the + // plugin runs. + config: Config.self, + apply: { ctx, config in + ctx.provide("greeter", Greeter(greeting: config.greeting ?? "Hello")) + // Cleanups run when the plugin unloads, latest first. + ctx.effect {} + } +)) diff --git a/crates/rutis-host/templates/swift/Sources/__name__/main.swift b/crates/rutis-host/templates/swift/Sources/__name__/main.swift new file mode 100644 index 00000000..d352fe3d --- /dev/null +++ b/crates/rutis-host/templates/swift/Sources/__name__/main.swift @@ -0,0 +1,5 @@ +// The {{name}} binary: what a host runs. +import {{Type}}Plugin +import Rutis + +Rutis.serve(plugin) diff --git a/crates/rutis-host/templates/swift/Tests/__Type__PluginTests/PluginTests.swift b/crates/rutis-host/templates/swift/Tests/__Type__PluginTests/PluginTests.swift new file mode 100644 index 00000000..168ee360 --- /dev/null +++ b/crates/rutis-host/templates/swift/Tests/__Type__PluginTests/PluginTests.swift @@ -0,0 +1,13 @@ +import RutisTest +import Testing + +@testable import {{Type}}Plugin + +/// The plugin runs as it would in a host, across a real session: values +/// cross as they would between processes. +@Test func greeterGreets() async throws { + let loaded = try await RutisTest.load(plugin, config: ["greeting": "Hi"]) + let greeting = try await loaded.service("greeter").call("hello", "Ada", as: String.self) + await loaded.close() + #expect(greeting == "Hi, Ada!") +} diff --git a/crates/rutis-host/templates/swift/gitignore b/crates/rutis-host/templates/swift/gitignore new file mode 100644 index 00000000..a4c742b0 --- /dev/null +++ b/crates/rutis-host/templates/swift/gitignore @@ -0,0 +1,4 @@ +/.build +/.swiftpm +/.rutis +/dist diff --git a/crates/rutis-host/templates/swift/rutis.dev.json b/crates/rutis-host/templates/swift/rutis.dev.json new file mode 100644 index 00000000..ad4dbb3b --- /dev/null +++ b/crates/rutis-host/templates/swift/rutis.dev.json @@ -0,0 +1,6 @@ +{ + "format": 1, + "rows": [ + { "id": "{{id}}", "config": { "greeting": "Hello" } } + ] +} diff --git a/crates/rutis-host/tests/check.rs b/crates/rutis-host/tests/check.rs index d33567bb..2f880b5a 100644 --- a/crates/rutis-host/tests/check.rs +++ b/crates/rutis-host/tests/check.rs @@ -120,7 +120,7 @@ async fn check_passes_what_can_run() { assert_eq!(checked.stderr, ""); assert_eq!( checked.stdout, - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ greeter (file:///plugins/greeter.mjs): ok\n \ inject: []\n \ provides: {\"greeter\":{\"hello\":\"sync\"}}\n\ @@ -170,7 +170,7 @@ async fn check_fails_what_cannot_run() { .await; assert_eq!( checked.stdout, - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ greeter (file:///greeter.mjs): ok\n \ inject: []\n \ provides: {}\n\ @@ -214,7 +214,7 @@ async fn check_refuses_a_configuration_that_is_not_valid() { let checked = check(dir.path(), &["rutis.json"], &[]).await; assert_eq!( checked.stdout, - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ w (weather): no plugin named \"weather\"\n \ install it where its runtime finds it (a runtime under runtimes), or correct the row's name\n" ); @@ -257,7 +257,7 @@ async fn check_without_a_configuration_checks_the_project() { assert_eq!(checked.stderr, ""); assert_eq!( checked.stdout, - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)\n\ weather (file:///src/index.mjs): ok\n \ inject: []\n \ provides: {\"weather\":{\"now\":\"async\"}}\n" @@ -323,7 +323,7 @@ async fn check_fails_on_a_service_nothing_provides() { assert_eq!( stdout, [ - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", "chat (file:///chat.mjs): cannot run: nothing provides index, llm", " inject: [\"llm\",\"index\",\"calendar\"]", " provides: {}", @@ -361,7 +361,7 @@ async fn check_fails_on_a_service_nothing_provides() { assert_eq!( stdout, [ - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", "chat (file:///chat.mjs): ok", " inject: [\"llm\",\"index\",\"calendar\"]", " provides: {}", @@ -447,7 +447,7 @@ async fn check_passes_what_cordis_rows_may_provide_at_runtime() { assert_eq!( stdout, [ - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", "storage (file:///storage.mjs): ok", " inject: []", " provides: {}", @@ -493,7 +493,7 @@ async fn check_passes_what_cordis_rows_may_provide_at_runtime() { assert_eq!( stdout, [ - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", "storage (file:///storage.mjs): ok", "domain (file:///domain.mjs): cannot run with --strict: nothing declares storage", "workspace (file:///workspace.mjs): cannot run with --strict: nothing declares storageDomain", @@ -547,7 +547,7 @@ async fn check_passes_a_service_that_may_come_from_a_remote_runtime() { assert_eq!( stdout, [ - "plugin API for Go binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)", "chat (file:///chat.mjs): ok", " inject: [\"embeddings\"]", " provides: {}", diff --git a/crates/rutis-host/tests/config.rs b/crates/rutis-host/tests/config.rs index 3c8d38df..00876579 100644 --- a/crates/rutis-host/tests/config.rs +++ b/crates/rutis-host/tests/config.rs @@ -62,7 +62,7 @@ fn every_field_is_read() { assert_eq!(go.start, GoStart::OnDemand); assert_eq!(go.idle, Some(30)); assert_eq!(go.project, base.join("work")); - assert!(go.validate().is_ok()); + assert!(go.validate("go").is_ok()); assert_eq!(config.runtimes.remote[0].name, "gpu"); assert_eq!(config.runtimes.remote[0].language, "python"); let listener = &config.listen[0]; @@ -341,7 +341,7 @@ fn unknown_fields_are_refused() { ), ( r#"{ "runtimes": { "deno": {} } }"#, - "/rutis.json: unknown field `deno`, expected one of `node`, `py`, `bun`, `go`, `remote` at line 1 column 22", + "/rutis.json: unknown field `deno`, expected one of `node`, `py`, `bun`, `go`, `swift`, `remote` at line 1 column 22", ), ( r#"{ "runtimes": { "node": { "projects": "." } } }"#, diff --git a/crates/rutis-host/tests/errors.rs b/crates/rutis-host/tests/errors.rs index 1ea8bc8a..bb6e9b17 100644 --- a/crates/rutis-host/tests/errors.rs +++ b/crates/rutis-host/tests/errors.rs @@ -80,7 +80,7 @@ async fn a_configuration_that_is_not_there_or_not_valid() { ), ( r#"{ "runtimes": { "remote": [{ "name": "gpu", "language": "ruby" }] } }"#, - "remote runtime gpu: the language is node, bun, python or go, not ruby", + "remote runtime gpu: the language is node, bun, python, go or swift, not ruby", ), ]; for (text, expected) in cases { diff --git a/crates/rutis-host/tests/persisted.rs b/crates/rutis-host/tests/persisted.rs index ed83c9b2..f0c1f633 100644 --- a/crates/rutis-host/tests/persisted.rs +++ b/crates/rutis-host/tests/persisted.rs @@ -117,7 +117,12 @@ fn normalized(config: &HostConfig, dir: &Path) -> Value { restart(bun.restart, &mut fields); runtimes.insert("bun".into(), fields.into()); } - if let Some(go) = &config.runtimes.go { + // `swift` (0.14): the same settings as `go`. + for (key, go) in [ + ("go", &config.runtimes.go), + ("swift", &config.runtimes.swift), + ] { + let Some(go) = go else { continue }; let mut fields = Map::new(); paths(&[&go.dir], &["dir"], &mut fields); let binaries: Vec = go.binaries.iter().map(|b| path(b, dir)).collect(); @@ -133,7 +138,7 @@ fn normalized(config: &HostConfig, dir: &Path) -> Value { fields.insert("project".into(), path(&go.project, dir)); // `restart` (0.14) likewise. restart(go.restart, &mut fields); - runtimes.insert("go".into(), fields.into()); + runtimes.insert(key.into(), fields.into()); } let remote: Vec = config .runtimes diff --git a/crates/rutis-host/tests/project.rs b/crates/rutis-host/tests/project.rs index 6f0698c4..59f582a1 100644 --- a/crates/rutis-host/tests/project.rs +++ b/crates/rutis-host/tests/project.rs @@ -183,7 +183,7 @@ fn which_kind_of_project() { let error = dev_config(project).unwrap_err(); assert_eq!( normalize(&error, project), - " has no package.json, pyproject.toml or go.mod: run rutis-host dev in a plugin project" + " has no package.json, pyproject.toml, go.mod or Package.swift: run rutis-host dev in a plugin project" ); let error = dev_config(&project.join("absent")).unwrap_err(); assert_eq!(normalize(&error, project), "/absent: not a directory"); diff --git a/crates/rutis-host/tests/swift.rs b/crates/rutis-host/tests/swift.rs new file mode 100644 index 00000000..8b7b5121 --- /dev/null +++ b/crates/rutis-host/tests/swift.rs @@ -0,0 +1,181 @@ +//! Swift binaries in rutis-host (macOS; needs `swift` on PATH): rows of +//! `runtimes.swift` run in their binary's runtime, which `restart` brings +//! back; `check` describes each binary and fails on one built with an SDK +//! older than rutis 0.13. +#![cfg(target_os = "macos")] + +mod common; + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use common::{normalize, repo}; +use rutis_bridge::session::HostDispatch; +use rutis_host::config::HostConfig; +use rutis_host::host::Host; +use rutis_test_support::{Guard, HANG_GUARD}; +use serde_json::{json, Value}; + +/// Runtimes start cold: twice the hang guard. +const COLD: Guard = Guard(std::time::Duration::from_secs(2 * HANG_GUARD.as_secs())); + +/// The Swift package's product `product` (the repository's Package.swift), +/// built once, copied to `dir` as `name`. +fn install(product: &str, dir: &Path, name: &str) -> PathBuf { + static BUILT: OnceLock = OnceLock::new(); + let bin = BUILT.get_or_init(|| { + let status = std::process::Command::new("swift") + .arg("build") + .current_dir(repo()) + .status() + .expect("swift is on PATH"); + assert!(status.success(), "swift build"); + let output = std::process::Command::new("swift") + .args(["build", "--show-bin-path"]) + .current_dir(repo()) + .output() + .expect("swift is on PATH"); + PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()) + }); + std::fs::create_dir_all(dir).unwrap(); + let target = dir.join(name); + std::fs::copy(bin.join(product), &target).unwrap(); + target +} + +/// A program that says it is a Swift binary of plugin API 2 (an SDK before +/// rutis 0.13) when asked for its manifest. +fn old_sdk(path: &Path) { + std::fs::write( + path, + "#!/bin/sh\n# rutis-swift-runtime:1\n\ + echo '{\"manifest\":1,\"sdk\":\"0.12.0\",\"pluginApi\":2,\"runtime\":\"rutis-swift-runtime:1\",\"plugins\":{\"old\":{\"inject\":[],\"provides\":{}}}}'\n", + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +async fn call( + service: std::sync::Arc, + method: &str, + args: Value, +) -> Result { + let method = method.to_owned(); + tokio::task::spawn_blocking(move || service.invoke(&method, args.into())) + .await + .unwrap() + .map_err(|error| error.to_string()) + .and_then(|value| value.json().map_err(|error| error.to_string())) +} + +/// A row `swift:ping` runs in the runtime of the binary that has it, +/// started when the row loads; `restart: on-failure` starts it again when +/// it crashes, and the row comes back. +/// guarantee: host/runtime-restart, loader/swift-runtimes +#[tokio::test(flavor = "multi_thread")] +async fn a_swift_row_runs_in_its_binarys_runtime_and_comes_back_after_a_crash() { + let dir = tempfile::tempdir().unwrap(); + install("RutisPing", &dir.path().join("plugins"), "netkit"); + let mut config: HostConfig = serde_json::from_value(json!({ + "id": "swift-test", + "runtimes": { "swift": { "dir": "plugins", "restart": "on-failure" } }, + "rows": [{ "id": "ping", "name": "swift:ping" }] + })) + .unwrap(); + config.rebase(dir.path()); + let host = Host::start(&config).await.unwrap(); + let _exits = host.supervise(); + host.load(config.rows()).await.unwrap(); + let service = COLD + .eventually(|| host.service("ping"), "the ping service") + .await; + assert_eq!( + call(service, "echo", json!(["swift"])).await.unwrap(), + json!("netkit pong swift") + ); + // The runtime answers for its own access to protected data (TCC): + // macOS holds it responsible, not the host that started it. + let identity = call(host.service("ping").unwrap(), "responsible", json!([])) + .await + .unwrap(); + assert_eq!(identity[0], identity[1], "pid, responsible pid: {identity}"); + let swift = host.binaries_of("swift").unwrap(); + assert_eq!(swift.runtimes()[0].name, "swift-netkit"); + assert_eq!(swift.title(), "Swift"); + + let _ = call(host.service("ping").unwrap(), "crash", json!([])).await; + // Gone, then back after the backoff's first wait (1 s). + COLD.until(|| host.service("ping").is_none(), "the crash") + .await; + let service = COLD + .eventually(|| host.service("ping"), "the ping service again") + .await; + assert_eq!( + call(service, "echo", json!(["back"])).await.unwrap(), + json!("netkit pong back") + ); +} + +/// `check` lists each Swift binary with its platform, SDK and plugin API; +/// one built with an SDK older than rutis 0.13 fails it, saying to rebuild. +/// guarantee: host/check, loader/swift-runtimes +#[tokio::test(flavor = "multi_thread")] +async fn check_describes_swift_binaries_and_fails_an_old_one() { + let dir = tempfile::tempdir().unwrap(); + let plugins = dir.path().join("plugins"); + install("RutisPing", &plugins, "netkit"); + old_sdk(&plugins.join("legacy")); + std::fs::write( + dir.path().join("rutis.json"), + json!({ + "runtimes": { "swift": { "dir": "plugins" } }, + "rows": [{ "id": "ping", "name": "swift:ping" }] + }) + .to_string(), + ) + .unwrap(); + let output = Guard(HANG_GUARD * 6) + .within( + "check ends", + tokio::process::Command::new(env!("CARGO_BIN_EXE_rutis-host")) + .arg("check") + .current_dir(dir.path()) + .kill_on_drop(true) + .output(), + ) + .await + .unwrap(); + let stdout = normalize(&String::from_utf8_lossy(&output.stdout), dir.path()); + let stderr = String::from_utf8_lossy(&output.stderr); + let context = format!("stdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!(output.status.code(), Some(1), "{context}"); + let arch = match std::env::consts::ARCH { + "aarch64" => "arm64", + arch => arch, + }; + for line in [ + "plugin API for Go and Swift binaries: 3 (checked by this host; the Node, Bun and Python runtimes check their own plugins)".to_owned(), + "ping (swift:ping): ok".to_owned(), + " runtime: \"swift-netkit\"".to_owned(), + "swift binaries:".to_owned(), + " swift-legacy (/plugins/legacy): sdk 0.12.0, plugin API 2 (incompatible: built with an SDK older than rutis 0.13; rebuild it with the 0.13 SDK or later), plugins: old".to_owned(), + format!(" swift-netkit (/plugins/netkit): macos/{arch}, sdk {}, plugin API 3 (compatible), plugins: ping", sdk_version()), + ] { + assert!(stdout.lines().any(|out| out == line), "{line}\n{context}"); + } + assert!( + stderr.contains("1 row(s) or binaries cannot run"), + "{context}" + ); +} + +/// The Swift SDK's version (swift/rutis/Sources/Rutis/Version.swift). +fn sdk_version() -> String { + let source = + std::fs::read_to_string(repo().join("swift/rutis/Sources/Rutis/Version.swift")).unwrap(); + let start = source.find("sdkVersion").unwrap(); + let rest = &source[start..]; + let rest = &rest[rest.find('"').unwrap() + 1..]; + rest[..rest.find('"').unwrap()].to_owned() +} diff --git a/crates/rutis-loader/tests/swift_rows.rs b/crates/rutis-loader/tests/swift_rows.rs index dc2444d4..7808bd41 100644 --- a/crates/rutis-loader/tests/swift_rows.rs +++ b/crates/rutis-loader/tests/swift_rows.rs @@ -15,8 +15,9 @@ use common::runtimes::{layer, row_state, swift_binary, COLD}; use rutis::{Ctx, FiberState}; use rutis_bridge::session::{host_key, settle, HostDispatch}; use rutis_loader::{ - read_swift_manifest, Chain, EntryStatus, Loader, LoaderOptions, LoaderPlugin, ServiceCatalog, - SwiftBinaries, SwiftResolver, SwiftRuntimeState, SwiftRuntimes, SwiftRuntimesHandle, + read_swift_manifest, Chain, EntryStatus, Loader, LoaderOptions, LoaderPlugin, RestartPolicy, + RuntimeEnd, ServiceCatalog, SwiftBinaries, SwiftResolver, SwiftRuntimeState, SwiftRuntimes, + SwiftRuntimesHandle, }; use serde_json::{json, Value}; @@ -35,7 +36,16 @@ struct Fixture { dir: tempfile::TempDir, } -async fn fixture(eager: bool, binaries: &[(&str, &str)]) -> Fixture { +/// When the fixture's runtimes start. +#[derive(Clone, Copy)] +enum Start { + /// All at once, and they keep running. + Eager, + /// As rows use them, stopping once idle this long (never: `None`). + OnDemand(Option), +} + +async fn fixture(start: Start, binaries: &[(&str, &str)]) -> Fixture { let dir = tempfile::tempdir().unwrap(); let plugins = dir.path().join("plugins"); std::fs::create_dir_all(&plugins).unwrap(); @@ -59,9 +69,9 @@ async fn fixture(eager: bool, binaries: &[(&str, &str)]) -> Fixture { let loader = plugin.handle(); root.plugin(plugin).await.unwrap(); let swift = SwiftRuntimes::new(resolver.clone(), dir.path()); - let swift = match eager { - true => swift.eager(), - false => swift.idle(None), + let swift = match start { + Start::Eager => swift.eager(), + Start::OnDemand(idle) => swift.idle(idle), }; let runtimes = swift.handle(); root.plugin(swift).await.unwrap(); @@ -139,7 +149,7 @@ fn a_binary_describes_itself() { #[tokio::test(flavor = "multi_thread")] async fn rows_find_their_binary_and_call_across_binaries() { let fixture = fixture( - false, + Start::OnDemand(None), &[("RutisPing", "netkit"), ("RutisMultilang", "multilang")], ) .await; @@ -212,7 +222,11 @@ async fn rows_find_their_binary_and_call_across_binaries() { /// guarantee: loader/swift-runtimes #[tokio::test(flavor = "multi_thread")] async fn a_plugin_in_two_binaries_is_named_with_its_runtime() { - let fixture = fixture(false, &[("RutisPing", "netkit"), ("RutisPing", "dup")]).await; + let fixture = fixture( + Start::OnDemand(None), + &[("RutisPing", "netkit"), ("RutisPing", "dup")], + ) + .await; fixture .loader .reconcile(layer(vec![row("p", "swift:ping")]), None) @@ -253,7 +267,7 @@ async fn a_plugin_in_two_binaries_is_named_with_its_runtime() { #[tokio::test(flavor = "multi_thread")] async fn eager_runtimes_run_side_by_side_and_a_crash_stops_only_its_rows() { let fixture = fixture( - true, + Start::Eager, &[("RutisPing", "netkit"), ("RutisMultilang", "multilang")], ) .await; @@ -316,3 +330,207 @@ async fn eager_runtimes_run_side_by_side_and_a_crash_stops_only_its_rows() { ); fixture.root.shutdown().await.unwrap(); } + +/// On demand: a runtime starts when a row uses it, keeps running while one +/// does, stops once idle, and starts again for the next row (design S5). +/// guarantee: loader/swift-runtimes +#[tokio::test(flavor = "multi_thread")] +async fn an_idle_runtime_stops_and_starts_again_when_used() { + let fixture = fixture( + Start::OnDemand(Some(Duration::from_millis(300))), + &[("RutisPing", "netkit"), ("RutisMultilang", "multilang")], + ) + .await; + assert_eq!( + runtime_state(&fixture.runtimes, "swift-netkit"), + Some(SwiftRuntimeState::NotStarted), + "nothing starts before a row uses it" + ); + fixture + .loader + .reconcile( + layer(vec![row("ping", "swift:ping"), row("probe", "swift:probe")]), + None, + ) + .await + .unwrap(); + let loader = fixture.loader.clone(); + COLD.until( + || { + row_state(&loader, "ping") == Some(FiberState::Active) + && row_state(&loader, "probe") == Some(FiberState::Active) + && service(&fixture.root, "ping").is_some() + }, + "the rows", + ) + .await; + let ping = service(&fixture.root, "ping").unwrap(); + // The probe no longer used: its runtime stops once idle, while the one + // a row still uses runs on, its row never stopped. + fixture + .loader + .reconcile(layer(vec![row("ping", "swift:ping")]), None) + .await + .unwrap(); + let runtimes = fixture.runtimes.clone(); + COLD.until( + || runtime_state(&runtimes, "swift-multilang") == Some(SwiftRuntimeState::NotStarted), + "the idle stop of swift-multilang", + ) + .await; + assert_eq!( + runtime_state(&fixture.runtimes, "swift-netkit"), + Some(SwiftRuntimeState::Running) + ); + let now = service(&fixture.root, "ping").expect("ping's service"); + assert!( + std::ptr::addr_eq(Arc::as_ptr(&now), Arc::as_ptr(&ping)), + "the same row, never stopped" + ); + drop((now, ping)); + + fixture.loader.reconcile(layer(vec![]), None).await.unwrap(); + COLD.until( + || runtime_state(&runtimes, "swift-netkit") == Some(SwiftRuntimeState::NotStarted), + "the idle stop of swift-netkit", + ) + .await; + + fixture + .loader + .reconcile(layer(vec![row("ping", "swift:ping")]), None) + .await + .unwrap(); + COLD.until( + || { + row_state(&loader, "ping") == Some(FiberState::Active) + && service(&fixture.root, "ping").is_some() + }, + "the row again", + ) + .await; + assert_eq!( + call(&fixture.root, "ping", "echo", json!(["again"])) + .await + .unwrap(), + json!("netkit pong again") + ); + fixture.root.shutdown().await.unwrap(); +} + +/// What supervising reported. +type Ends = Arc>>; + +fn supervise(runtimes: &SwiftRuntimesHandle, policy: RestartPolicy) -> Ends { + let ends: Ends = Arc::default(); + let report = ends.clone(); + runtimes.supervise(policy, move |end| report.lock().unwrap().push(end)); + ends +} + +/// Wait until `ping` runs with its service published, then crash its +/// runtime (`exit(17)` in the plugin). +async fn crash_ping(fixture: &Fixture) { + let loader = fixture.loader.clone(); + COLD.until( + || { + row_state(&loader, "ping") == Some(FiberState::Active) + && service(&fixture.root, "ping").is_some() + }, + "ping and its service", + ) + .await; + let _ = call(&fixture.root, "ping", "crash", json!([])).await; +} + +/// `restart: on-failure` (design S5, #274): a Swift runtime whose process +/// ends is started again after the backoff (1 s first), its row coming +/// back with it; an idle stop is not an end, and starts nothing. +/// guarantee: loader/swift-runtimes, host/runtime-restart +#[tokio::test(flavor = "multi_thread")] +async fn a_crashed_runtime_restarts_on_failure_and_an_idle_stop_does_not() { + let fixture = fixture( + Start::OnDemand(Some(Duration::from_millis(300))), + &[("RutisPing", "netkit")], + ) + .await; + let ends = supervise(&fixture.runtimes, RestartPolicy::OnFailure); + fixture + .loader + .reconcile(layer(vec![row("ping", "swift:ping")]), None) + .await + .unwrap(); + crash_ping(&fixture).await; + let reported = ends.clone(); + COLD.until(|| !reported.lock().unwrap().is_empty(), "the restart") + .await; + assert_eq!( + ends.lock().unwrap()[0], + RuntimeEnd::Restarting { + runtime: "swift-netkit".into() + } + ); + let loader = fixture.loader.clone(); + COLD.until( + || { + row_state(&loader, "ping") == Some(FiberState::Active) + && service(&fixture.root, "ping").is_some() + }, + "ping back", + ) + .await; + assert_eq!( + call(&fixture.root, "ping", "echo", json!(["back"])) + .await + .unwrap(), + json!("netkit pong back") + ); + + // Unused, it stops once idle: not an end, not restarted. + fixture.loader.reconcile(layer(vec![]), None).await.unwrap(); + let runtimes = fixture.runtimes.clone(); + COLD.until( + || runtime_state(&runtimes, "swift-netkit") == Some(SwiftRuntimeState::NotStarted), + "the idle stop", + ) + .await; + tokio::time::sleep(Duration::from_millis(1500)).await; + assert_eq!(ends.lock().unwrap().len(), 1, "{:?}", ends.lock().unwrap()); + assert_eq!( + runtime_state(&fixture.runtimes, "swift-netkit"), + Some(SwiftRuntimeState::NotStarted) + ); + fixture.root.shutdown().await.unwrap(); +} + +/// `restart: exit`: a Swift runtime whose process ends is reported with +/// why, for the application to stop, and stays down. +/// guarantee: loader/swift-runtimes, host/runtime-restart +#[tokio::test(flavor = "multi_thread")] +async fn exit_reports_a_crashed_runtime() { + let fixture = fixture(Start::OnDemand(None), &[("RutisPing", "netkit")]).await; + let ends = supervise(&fixture.runtimes, RestartPolicy::Exit); + fixture + .loader + .reconcile(layer(vec![row("ping", "swift:ping")]), None) + .await + .unwrap(); + crash_ping(&fixture).await; + let reported = ends.clone(); + COLD.until(|| !reported.lock().unwrap().is_empty(), "the end") + .await; + match &ends.lock().unwrap()[0] { + RuntimeEnd::Exit { runtime, why } => { + assert_eq!(runtime, "swift-netkit"); + assert!(!why.is_empty()); + } + other => panic!("{other:?}"), + } + tokio::time::sleep(Duration::from_millis(1500)).await; + assert!(matches!( + runtime_state(&fixture.runtimes, "swift-netkit"), + Some(SwiftRuntimeState::Stopped(_)) + )); + assert_eq!(ends.lock().unwrap().len(), 1); + fixture.root.shutdown().await.unwrap(); +} diff --git a/docs/guide/README.en.md b/docs/guide/README.en.md index 0b2af7d9..9e29bfb6 100644 --- a/docs/guide/README.en.md +++ b/docs/guide/README.en.md @@ -8,6 +8,7 @@ Choose a guide based on what you want to do: | Write a plugin in TypeScript / JavaScript, run by Bun | [Write a Bun plugin](bun-plugin.en.md) | | Write a plugin in Python | [Write a Python plugin](python-plugin.en.md) | | Write a plugin in Go | [Write a Go plugin](go-plugin.en.md) | +| Write a macOS plugin in Swift | [Write a Swift plugin](swift-plugin.en.md) | | Look up the plugin API and value passing rules | [Plugin API](plugin-api.en.md) | | Send and receive events between plugins (any language, process or node) | [Plugin API: events](plugin-api.en.md#events) | | Run plugins without writing Rust | [rutis-host and rutis.json](rutis-host.en.md) | @@ -17,11 +18,11 @@ Choose a guide based on what you want to do: ## Packages -| User | Rust (crates.io) | Node (npm) | Python (PyPI) | Go | -| --- | --- | --- | --- | --- | -| Plugin author | `rutis-sdk` (dylib plugins) | `@arcships/rutis` | `rutis` | `github.com/arcships/rutis/go/rutis` | -| Host (runs plugins) | `rutis`, `rutis-loader`, `rutis-bridge`, `rutis-dylib` | `@arcships/rutis-runtime` (Node), `@arcships/rutis-bun` (Bun) | `rutis` | (a plugin binary has its runtime) | -| Host without Rust | `rutis-host` | `@arcships/rutis-host` | `rutis-host` | | +| User | Rust (crates.io) | Node (npm) | Python (PyPI) | Go | Swift (SwiftPM) | +| --- | --- | --- | --- | --- | --- | +| Plugin author | `rutis-sdk` (dylib plugins) | `@arcships/rutis` | `rutis` | `github.com/arcships/rutis/go/rutis` | `https://github.com/arcships/rutis` (products `Rutis`, `RutisTest`) | +| Host (runs plugins) | `rutis`, `rutis-loader`, `rutis-bridge`, `rutis-dylib` | `@arcships/rutis-runtime` (Node), `@arcships/rutis-bun` (Bun) | `rutis` | (a plugin binary has its runtime) | (a plugin binary has its runtime) | +| Host without Rust | `rutis-host` | `@arcships/rutis-host` | `rutis-host` | | | All of these packages, the `rutis` core and the dylib toolchain included, are released together with matching version numbers (a release train, currently 0.13). Use the same version for every rutis package. @@ -33,4 +34,5 @@ Upgrading from 0.13: see [migrating to 0.14](../migration-0.14.en.md) (no code c - Node 22 or later, or Bun 1.4 or later, for TypeScript / JavaScript plugins. - Python 3.10 or later for Python plugins. - Go 1.24 or later only to write Go plugins and build their binaries; running a Go plugin binary needs no Go. +- macOS 14 or later and a Swift 6 toolchain only to write Swift plugins and build their binaries; running a Swift plugin binary needs no toolchain, and runs on macOS only. - Rust 1.88 or later, only when embedding rutis in Rust. This version is verified with the repository's `Cargo.lock`; when an older Rust resolves dependencies itself, setting `resolver.incompatible-rust-versions = "fallback"` in the Cargo configuration makes Cargo pick dependency versions that work. diff --git a/docs/guide/README.md b/docs/guide/README.md index d3da9535..83947395 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -8,6 +8,7 @@ | 用 TypeScript / JavaScript 写插件,在 Bun 里运行 | [写一个 Bun 插件](bun-plugin.md) | | 用 Python 写插件 | [写一个 Python 插件](python-plugin.md) | | 用 Go 写插件 | [写一个 Go 插件](go-plugin.md) | +| 用 Swift 写 macOS 插件 | [写一个 Swift 插件](swift-plugin.md) | | 查插件能做什么、值怎样传递 | [插件 API](plugin-api.md) | | 在插件之间(任何语言、任何进程、任何节点)收发事件 | [插件 API:事件](plugin-api.md#事件) | | 不写 Rust,直接运行插件 | [rutis-host 与 rutis.json](rutis-host.md) | @@ -17,11 +18,11 @@ ## 包 -| 谁装 | Rust(crates.io) | Node(npm) | Python(PyPI) | Go | -| --- | --- | --- | --- | --- | -| 插件作者 | `rutis-sdk`(dylib 插件) | `@arcships/rutis` | `rutis` | `github.com/arcships/rutis/go/rutis` | -| 宿主(运行插件) | `rutis`、`rutis-loader`、`rutis-bridge`、`rutis-dylib` | `@arcships/rutis-runtime`(Node)、`@arcships/rutis-bun`(Bun) | `rutis` | (插件二进制自带运行时) | -| 不写 Rust 的宿主 | `rutis-host` | `@arcships/rutis-host` | `rutis-host` | | +| 谁装 | Rust(crates.io) | Node(npm) | Python(PyPI) | Go | Swift(SwiftPM) | +| --- | --- | --- | --- | --- | --- | +| 插件作者 | `rutis-sdk`(dylib 插件) | `@arcships/rutis` | `rutis` | `github.com/arcships/rutis/go/rutis` | `https://github.com/arcships/rutis`(产品 `Rutis`、`RutisTest`) | +| 宿主(运行插件) | `rutis`、`rutis-loader`、`rutis-bridge`、`rutis-dylib` | `@arcships/rutis-runtime`(Node)、`@arcships/rutis-bun`(Bun) | `rutis` | (插件二进制自带运行时) | (插件二进制自带运行时) | +| 不写 Rust 的宿主 | `rutis-host` | `@arcships/rutis-host` | `rutis-host` | | | 这些包(包括内核 `rutis` 和 dylib 工具链)一起发布、版本号相同(发布列车,当前为 0.13)。各个 rutis 包请使用同一个版本。 @@ -33,4 +34,5 @@ - Node 22 或更高,或 Bun 1.4 或更高(TypeScript / JavaScript 插件)。 - Python 3.10 或更高(Python 插件)。 - Go 1.24 或更高(只在写 Go 插件、构建二进制时需要;运行 Go 插件二进制不需要)。 +- macOS 14 或更高与 Swift 6 工具链(只在写 Swift 插件、构建二进制时需要;运行 Swift 插件二进制不需要工具链,但只在 macOS 上运行)。 - Rust 1.88 或更高(只在 Rust 里嵌入时需要)。这个版本是按仓库的 `Cargo.lock` 验证的;用更旧的 Rust 自己解析依赖时,可以在 Cargo 配置里设 `resolver.incompatible-rust-versions = "fallback"`,让 Cargo 选能用的依赖版本。 diff --git a/docs/guide/plugin-api.en.md b/docs/guide/plugin-api.en.md index 6f61ff73..ac9d9f5a 100644 --- a/docs/guide/plugin-api.en.md +++ b/docs/guide/plugin-api.en.md @@ -1,10 +1,10 @@ # Plugin API -TypeScript / JavaScript plugins (`@arcships/rutis`), Python plugins (`rutis`) and Go plugins (`github.com/arcships/rutis/go/rutis`, see [Go plugins](go-plugin.en.md)) use the same conventions; so do Swift plugins (the Swift package `Rutis` of this repository, a preview until its guide: [swift/README.md](../../swift/README.md)). The current plugin API version is **3**. +TypeScript / JavaScript plugins (`@arcships/rutis`), Python plugins (`rutis`) and Go plugins (`github.com/arcships/rutis/go/rutis`, see [Go plugins](go-plugin.en.md)) use the same conventions; so do Swift plugins (the Swift package `Rutis` of this repository, see [Swift plugins](swift-plugin.en.md)). The current plugin API version is **3**. ## Declarations -| Declaration | TypeScript (`definePlugin({...})`) | Python (`define_plugin(apply, ...)` or module variable) | Go (`rutis.Define(rutis.Plugin[C]{...})`) | Swift (`Rutis.define(Plugin(...))`, preview) | Meaning | +| Declaration | TypeScript (`definePlugin({...})`) | Python (`define_plugin(apply, ...)` or module variable) | Go (`rutis.Define(rutis.Plugin[C]{...})`) | Swift (`Rutis.define(Plugin(...))`) | Meaning | | --- | --- | --- | --- | --- | --- | | Services used | `inject: ['llm']` | `inject=["llm"]` | `Inject: []string{"llm"}` | `inject: ["llm"]` | The plugin starts when all are ready. If any is revoked, the plugin stops and waits for it to return. | | Services provided (optional) | `provides: { weather: { today: 'sync' } }` | `provides={"weather": Weather}` or `{"weather": {"today": "sync"}}` | `Provides: rutis.Provides{"weather": rutis.MethodsOf[*Weather](rutis.Sync("Unit"))}` (exported methods, `async` unless `Sync` names them) | `provides: ["weather": .of(Weather.self, sync: ["unit"])]` (the methods of a `@RutisService` type, `async` unless `sync` names them) | Not needed: what `ctx.provide` provides reaches rutis by itself (see below). When present it applies: it marks methods `sync` and lets `rutis-host check` know before running who provides what; a declared service offers only its declared methods. | @@ -61,7 +61,7 @@ Rust also has `waterfall_sync`: a synchronous `waterfall`, under the same rules Each language has the five modes, written the language's own way: -| | Rust (`rutis`) | Node / Bun leaf plugin (`@arcships/rutis`) | Cordis plugin (in the Node runtime) | Python (`rutis`) | Go (`rutis`) | Swift (`Rutis`, preview) | +| | Rust (`rutis`) | Node / Bun leaf plugin (`@arcships/rutis`) | Cordis plugin (in the Node runtime) | Python (`rutis`) | Go (`rutis`) | Swift (`Rutis`) | | --- | --- | --- | --- | --- | --- | --- | | Listen | `ctx.events().on(ctx, &key, listener)`; a synchronous listener `on_sync`; with options `on_opt` / `on_sync_opt`; a prefix `on_pattern(ctx, &EventPattern::prefix(p), ..)`; `once` | `ctx.on(name, fn, { prepend, global }?)` → `() => void` | Cordis's own `ctx.on(name, fn, options?)` → `() => boolean`; `ctx.once` | `ctx.on(name, fn, *, prepend=False, global_=False)` → `Callable[[], None]` | `ctx.On(name, func(ctx context.Context, args ...any) (any, error), rutis.Prepend(), rutis.Global())` → `func()` | `ctx.on(name, prepend:, global:) { (args...) in ... }` → `() -> Void` | | Listener | `impl Listener`: `call(ctx, &event, call: Call<'_, E::Value>)`, with `call.mode()`, `call.next()`; an `on_sync` closure gets a `SyncCall` | `(...args[, next]) => value \| Promise` | The same | `fn(*args[, next])`, may be `async def` | `fn(ctx, args...)`; in a waterfall the last argument is a `rutis.Next` (`func(context.Context) (any, error)`) | a closure of any parameters, decoded by their types; in a waterfall the last one is a `Rutis.Next` (`async throws -> R?`) | @@ -275,7 +275,7 @@ Values are not copied within one process. Code that mutates an object after pass ### The ten values -| Value | JS (Node, Bun) | Python | Go | Rust (`rutis-bridge`) | Swift (preview) | +| Value | JS (Node, Bun) | Python | Go | Rust (`rutis-bridge`) | Swift | | --- | --- | --- | --- | --- | --- | | Point in time | `Date` | `datetime` with a time zone (received in UTC) | `time.Time` (received in UTC) | `Instant` | `Date` (to the microsecond) | | Local date and time | String `"2026-10-15T14:30:05"` | `datetime` without a time zone | `rutis.DateTime` | `DateTime` | `Rutis.DateTime` | diff --git a/docs/guide/plugin-api.md b/docs/guide/plugin-api.md index dd3beeca..e72ad1a0 100644 --- a/docs/guide/plugin-api.md +++ b/docs/guide/plugin-api.md @@ -1,10 +1,10 @@ # 插件 API -TypeScript / JavaScript 插件(`@arcships/rutis`)、Python 插件(`rutis`)和 Go 插件(`github.com/arcships/rutis/go/rutis`,见 [写一个 Go 插件](go-plugin.md))的写法与规则相同;Swift 插件(本仓库的 Swift 包 `Rutis`,指南发布前为预览,见 [swift/README.zh-CN.md](../../swift/README.zh-CN.md))也一样。当前插件 API 版本:**3**。 +TypeScript / JavaScript 插件(`@arcships/rutis`)、Python 插件(`rutis`)和 Go 插件(`github.com/arcships/rutis/go/rutis`,见 [写一个 Go 插件](go-plugin.md))的写法与规则相同;Swift 插件(本仓库的 Swift 包 `Rutis`,见 [写一个 Swift 插件](swift-plugin.md))也一样。当前插件 API 版本:**3**。 ## 声明 -| 声明 | TypeScript(`definePlugin({...})`) | Python(`define_plugin(apply, ...)` 或模块变量) | Go(`rutis.Define(rutis.Plugin[C]{...})`) | Swift(`Rutis.define(Plugin(...))`,预览) | 含义 | +| 声明 | TypeScript(`definePlugin({...})`) | Python(`define_plugin(apply, ...)` 或模块变量) | Go(`rutis.Define(rutis.Plugin[C]{...})`) | Swift(`Rutis.define(Plugin(...))`) | 含义 | | --- | --- | --- | --- | --- | --- | | 用到的服务 | `inject: ['llm']` | `inject=["llm"]` | `Inject: []string{"llm"}` | `inject: ["llm"]` | 都就绪才启动;任何一个撤销,插件停下,等它回来再启动 | | 提供的服务(可选) | `provides: { weather: { today: 'sync' } }` | `provides={"weather": Weather}` 或 `{"weather": {"today": "sync"}}` | `Provides: rutis.Provides{"weather": rutis.MethodsOf[*Weather](rutis.Sync("Unit"))}`(导出方法,除 `Sync` 列出的外都是 `async`) | `provides: ["weather": .of(Weather.self, sync: ["unit"])]`(`@RutisService` 类型的方法,除 `sync` 列出的外都是 `async`) | 不写也行:`ctx.provide` 提供的服务自动对外(见下)。写了就按声明:把方法标成 `sync`,并让 `rutis-host check` 在运行前就知道谁提供什么;声明过的服务只能调用声明过的方法 | @@ -61,7 +61,7 @@ Rust 另有 `waterfall_sync`:同步的 `waterfall`,规则与 `bail` 相同 每种语言都有这五种方式,写法是这门语言自己的: -| | Rust(`rutis`) | Node / Bun 叶子插件(`@arcships/rutis`) | Cordis 插件(Node 运行时里) | Python(`rutis`) | Go(`rutis`) | Swift(`Rutis`,预览) | +| | Rust(`rutis`) | Node / Bun 叶子插件(`@arcships/rutis`) | Cordis 插件(Node 运行时里) | Python(`rutis`) | Go(`rutis`) | Swift(`Rutis`) | | --- | --- | --- | --- | --- | --- | --- | | 监听 | `ctx.events().on(ctx, &key, listener)`;同步监听器 `on_sync`;带选项 `on_opt` / `on_sync_opt`;前缀 `on_pattern(ctx, &EventPattern::prefix(p), ..)`;`once` | `ctx.on(name, fn, { prepend, global }?)` → `() => void` | Cordis 自己的 `ctx.on(name, fn, options?)` → `() => boolean`;`ctx.once` | `ctx.on(name, fn, *, prepend=False, global_=False)` → `Callable[[], None]` | `ctx.On(name, func(ctx context.Context, args ...any) (any, error), rutis.Prepend(), rutis.Global())` → `func()` | `ctx.on(name, prepend:, global:) { (参数...) in ... }` → `() -> Void` | | 监听器 | `impl Listener`:`call(ctx, &event, call: Call<'_, E::Value>)`,`call.mode()`、`call.next()`;`on_sync` 的闭包收到 `SyncCall` | `(...args[, next]) => 值 \| Promise` | 同左 | `fn(*args[, next])`,可以是 `async def` | `fn(ctx, args...)`;waterfall 中最后一个参数是 `rutis.Next`(`func(context.Context) (any, error)`) | 任意参数的闭包,按声明类型解码;waterfall 中最后一个参数是 `Rutis.Next`(`async throws -> R?`) | @@ -275,7 +275,7 @@ incompatible session: the far end ( ) does not declare events-v2 ### 十种值 -| 值 | JS(Node、Bun) | Python | Go | Rust(`rutis-bridge`) | Swift(预览) | +| 值 | JS(Node、Bun) | Python | Go | Rust(`rutis-bridge`) | Swift | | --- | --- | --- | --- | --- | --- | | 时间点 | `Date` | 带时区的 `datetime`(收到为 UTC) | `time.Time`(收到为 UTC) | `Instant` | `Date`(到微秒) | | 本地日期时间 | 字符串 `"2026-10-15T14:30:05"` | 无时区的 `datetime` | `rutis.DateTime` | `DateTime` | `Rutis.DateTime` | diff --git a/docs/guide/rutis-host.en.md b/docs/guide/rutis-host.en.md index a4502601..da192097 100644 --- a/docs/guide/rutis-host.en.md +++ b/docs/guide/rutis-host.en.md @@ -1,6 +1,6 @@ # rutis-host and rutis.json -`rutis-host` is a host that requires no Rust code. It assembles the core, plugin loader, local Node / Python / Go runtimes, and network connections, all driven by a `rutis.json` file. Plugin authors can use it for local development or deployment. For applications that need custom Rust services, see [Embed in a Rust application](rust-host.en.md). +`rutis-host` is a host that requires no Rust code. It assembles the core, plugin loader, local Node / Python / Go / Swift runtimes, and network connections, all driven by a `rutis.json` file. Plugin authors can use it for local development or deployment. For applications that need custom Rust services, see [Embed in a Rust application](rust-host.en.md). ## Installation @@ -11,16 +11,16 @@ | Binary | `rutis-host--.tar.gz` from GitHub Releases (Linux and macOS, x64 / arm64), or `.zip` for Windows x64 | | crates.io | `cargo install rutis-host` | -The npm distribution includes the Node runtime (`@arcships/rutis-runtime`), and the PyPI distribution includes the Python runtime (`rutis`). They are used when the project does not provide its own runtime. Go plugins are compiled binaries: running them needs no other package and no Go toolchain. +The npm distribution includes the Node runtime (`@arcships/rutis-runtime`), and the PyPI distribution includes the Python runtime (`rutis`). They are used when the project does not provide its own runtime. Go and Swift plugins are compiled binaries: running them needs no other package and no Go or Swift toolchain (Swift plugins run on macOS). ## Commands | Command | Purpose | | --- | --- | | `rutis-host run [rutis.json]` | Runs the configuration (reading it again while it runs on SIGUSR1 on Unix, or with `--watch-config`; see [Changing the configuration while it runs](#changing-the-configuration-while-it-runs)) and prints each row's status changes, the state of node rows' links and local runtimes that end (see [What `run` prints](#what-run-prints)). Press Ctrl-C to stop (see [Stopping and exit status](#stopping-and-exit-status)). | -| `rutis-host dev [directory]` | Runs a plugin project with its `rutis.dev.json` file and reloads it when files change; a Go project is rebuilt, restarting only its runtime. | -| `rutis-host check [rutis.json] [--strict]` | Without running any plugin, checks that each row can run (see [Checking a configuration](#checking-a-configuration)) and prints its version, dependencies, provided services, and configuration schema, and lists every Go binary (runtime name, SDK, plugin API, plugins). Exits with a nonzero status if a row or binary cannot run. A dependency no row declares, which another row may provide at runtime, is a warning; with `--strict`, an error. Without `rutis.json`, checks the plugin project in the current directory. | -| `rutis-host new --lang node\|bun\|python\|go` | Creates a plugin project. | +| `rutis-host dev [directory]` | Runs a plugin project with its `rutis.dev.json` file and reloads it when files change; a Go or Swift project is rebuilt (`go build`, `swift build`), restarting only its runtime. | +| `rutis-host check [rutis.json] [--strict]` | Without running any plugin, checks that each row can run (see [Checking a configuration](#checking-a-configuration)) and prints its version, dependencies, provided services, and configuration schema, and lists every Go and Swift binary (runtime name, platform, SDK, plugin API, plugins; a binary of an SDK older than rutis 0.13 cannot run). Exits with a nonzero status if a row or binary cannot run. A dependency no row declares, which another row may provide at runtime, is a warning; with `--strict`, an error. Without `rutis.json`, checks the plugin project in the current directory. | +| `rutis-host new --lang node\|bun\|python\|go\|swift` | Creates a plugin project. | | `rutis-host go add @ [rutis.json]` | `go install`s a plugin binary into `runtimes.go.dir` with the machine's Go toolchain. | ## What `run` prints @@ -35,12 +35,12 @@ rutis-host: listening on Then each change of a row's state prints `: ` (`running`, `waiting for its services`, `starting`, `failed: …`, `disabled`, `removed`, and so on; a copy inside an instance is ` in PluginId(n)`). -A local runtime (`node`, `py`, `bun`, Go's `go-`) whose process ends on its own, or that cannot start, prints a line, and another when it runs again; one that `rutis-host` stops (the host stopping, a Go runtime stopping when idle) prints none: +A local runtime (`node`, `py`, `bun`, Go's `go-`, Swift's `swift-`) whose process ends on its own, or that cannot start, prints a line, and another when it runs again; one that `rutis-host` stops (the host stopping, a Go or Swift runtime stopping when idle) prints none: | Output | Meaning | | --- | --- | | `runtime : down ()` | The runtime process ended; the reason says how, such as `exited with signal: 9 (SIGKILL)` or `exited with exit status: 1`; or it could not start, and the reason is the error. Its rows go back to `waiting for its services`; other runtimes and their rows go on. | -| `runtime : running` | A runtime that printed `down` runs again (a Go runtime rebuilt by `dev`). | +| `runtime : running` | A runtime that printed `down` runs again (a Go or Swift runtime rebuilt by `dev`). | A runtime that stays down prints once; the reason in parentheses is for people, and its wording is not guaranteed. @@ -124,7 +124,7 @@ While `run` runs, `rutis.json` can change without restarting the host: Both run the same reload, all or nothing: 1. A file that cannot be read, or is not a valid configuration: refused. -2. A change to what a running host cannot change: refused, saying to restart the host. That is the top-level `format`, `id`, `listen`, `runtimes.go` (any change), `runtimes.remote` (an entry added or removed, or any field of one changed, `listen` and `instances` included), and a local runtime (`node`, `py`, `bun`) added or with changed settings. +2. A change to what a running host cannot change: refused, saying to restart the host. That is the top-level `format`, `id`, `listen`, `runtimes.go` and `runtimes.swift` (any change), `runtimes.remote` (an entry added or removed, or any field of one changed, `listen` and `instances` included), and a local runtime (`node`, `py`, `bun`) added or with changed settings. 3. The new rows are checked first (as by `check`): a row that cannot start (its plugin not found, its settings failing the checks before loading), and that is not failing now, refuses it. A row that waits for a service is not one that cannot start, as at start. 4. Then the new rows replace the old: added rows start, removed rows unload, changed rows restart; rows that did not change are left alone (rows using a service of a restarted row wait for it and come back with it, as always). A row failing after the replacement (its plugin's `apply` throwing, say) that was not failing before brings the old rows back (the rows replaced restart once more), and refuses it. 5. A local runtime removed: its rows unload first, then the runtime stops, and the reload is done once its process has exited. @@ -145,7 +145,7 @@ Credentials are read from the environment when a link dials, or when a far end d ## When a runtime ends -When a local runtime's (`node`, `py`, `bun`, each Go runtime) process ends on its own (a crash, `kill -9`, a plugin making the process exit), `rutis-host` does what that runtime's `restart` says: +When a local runtime's (`node`, `py`, `bun`, each Go or Swift runtime) process ends on its own (a crash, `kill -9`, a plugin making the process exit), `rutis-host` does what that runtime's `restart` says: | `restart` | What happens | | --- | --- | @@ -164,14 +164,14 @@ Also: - What the process held in memory is lost with the crash; a restart loses nothing more. Calls in progress have already failed explicitly and are not retried. What runs again is what plugins do as they start (`apply`), as when the host is restarted by hand. - Once the host stops (a termination signal, the `exit` policy), no runtime is restarted; nor is one removed by [changing the configuration while it runs](#changing-the-configuration-while-it-runs). - `restart` is a runtime setting: changing it while the host runs needs a restart. -- `dev` follows the same rules and defaults; write `"runtimes": { "py": { "restart": "on-failure" } }` (or `"go"`, …) in `rutis.dev.json` (for the project's own runtime, only `restart` is taken). +- `dev` follows the same rules and defaults; write `"runtimes": { "py": { "restart": "on-failure" } }` (or `"go"`, `"swift"`, …) in `rutis.dev.json` (for the project's own runtime, only `restart` is taken). -- Go: `runtimes.go.restart` applies to every Go runtime, each on its own (its own waits and limit; `runtime go-: restarting`). A runtime stopped for being idle did not end on its own; nor did one whose binary's handshake was refused (an old SDK), which is not restarted until its file changes. Whatever `restart` says, a crashed Go runtime also starts again when a row uses it once its binary changed. Changing `runtimes.go` while the host runs needs a restart, as before. +- Go: `runtimes.go.restart` applies to every Go runtime, each on its own (its own waits and limit; `runtime go-: restarting`). A runtime stopped for being idle did not end on its own; nor did one whose binary's handshake was refused (an old SDK), which is not restarted until its file changes. Whatever `restart` says, a crashed Go runtime also starts again when a row uses it once its binary changed. Changing `runtimes.go` while the host runs needs a restart, as before. `runtimes.swift.restart` is the same for the Swift runtimes (`runtime swift-: restarting`). - Remote runtimes and peer rows' links reconnect by themselves and are not affected. ## Output -The standard output and standard error of the runtime processes (Node, Bun, Python, Go) are not written straight onto `rutis-host`'s: `rutis-host` reads them through pipes and writes them a whole line at a time — a line a plugin writes to stdout comes out on `rutis-host`'s stdout, one written to stderr on its stderr, unchanged, with no prefix. `rutis-host` writes its own status lines a whole line at a time too. So a plugin's line and a line of `rutis-host`'s, or lines of two runtimes, never run into each other, also when the plugin writes a line in pieces (Python's `print` writes the text, then the newline) and when stdout and stderr are the same file (`2>&1`). +The standard output and standard error of the runtime processes (Node, Bun, Python, Go, Swift) are not written straight onto `rutis-host`'s: `rutis-host` reads them through pipes and writes them a whole line at a time — a line a plugin writes to stdout comes out on `rutis-host`'s stdout, one written to stderr on its stderr, unchanged, with no prefix. `rutis-host` writes its own status lines a whole line at a time too. So a plugin's line and a line of `rutis-host`'s, or lines of two runtimes, never run into each other, also when the plugin writes a line in pieces (Python's `print` writes the text, then the newline) and when stdout and stderr are the same file (`2>&1`). - A line comes out when its newline comes; nothing is flushed after a time: a prompt without a newline, or a progress bar redrawn with `\r`, shows when the line ends. When a runtime ends, a last line without a newline comes out as a line of its own. - A line longer than 64 KiB without a newline comes out in pieces. @@ -190,6 +190,7 @@ The standard output and standard error of the runtime processes (Node, Bun, Pyth "node": { "project": "." }, "py": { "project": "plugins", "python": ".venv/bin/python" }, "go": { "dir": "plugins/go" }, + "swift": { "dir": "plugins/swift", "restart": "on-failure" }, "remote": [{ "name": "gpu", "language": "python" }] }, "listen": [ @@ -199,6 +200,7 @@ The standard output and standard error of the runtime processes (Node, Bun, Pyth { "id": "weather", "name": "weather-plugin", "config": { "city": "Oslo" } }, { "id": "llm", "name": "py:llm_gateway" }, { "id": "dns", "name": "go:dns" }, + { "id": "calendar", "name": "swift:calendar" }, { "id": "local-tool", "name": "./tools/tool.ts" }, { "id": "office", "name": "rutis-bridge/peer", "config": { "peer": "office", "dial": "wss://office.example.com/rutis", "export": ["weather"] } }, { "id": "gpu", "name": "rutis-bridge/peer", "config": { "peer": "gpu", "dial": "wss://gpu.example.com/rutis", "runtime": "gpu" } }, @@ -233,11 +235,12 @@ The endpoint ID of this node. Connected nodes see this name. Defaults to `host`. | `bun` | `project` (default `.`), `runtime`, `program`, `restart` | Starts the Bun runtime, with rows named `bun:`. Modules resolve from `project` (packages in `node_modules`, `./relative-paths`). Install `@arcships/rutis-bun` there or specify it with `runtime`; `program` is the Bun executable, by default `bun` on `PATH`. See [Write a Bun plugin](bun-plugin.en.md). | | `py` | `project` (default `.`), `python`, `restart` | Starts the Python runtime. The interpreter defaults to `$VIRTUAL_ENV/bin/python`, then `/.venv/bin/python`, then `python3` (on Windows, `Scripts\python.exe` and `python`). Its environment must contain `rutis` and the plugins. | | `go` | `dir`, `binaries`, `start` (`on-demand` by default / `eager`), `idle` (seconds, default 60), `project` (default `.`), `restart` | Go plugins: one runtime process per binary. Every executable in `dir` that contains the Go SDK's marker counts (the directory is trusted as a whole and should be dedicated); `binaries` lists single files. A runtime is named after its file (`netkit` → `go-netkit`). An on-demand runtime starts when a row first uses it and stops after `idle` seconds without one; `eager` starts all and keeps them running (not together with `idle`). A replaced binary takes effect when the host restarts. | +| `swift` | as `go` | Swift plugins (macOS): as `go`, for binaries built with the Swift SDK (`plugin-binary` → `swift-plugin-binary`); see [Swift plugins](swift-plugin.en.md). | | `remote` | `name`, `language`, `listen`, `instances` | A runtime on another machine: connected through a node row with `"runtime": ""`, see [Connect nodes](nodes.en.md); or, with `listen` and `instances`, one that connects in, see below. | If a runtime package is missing, startup fails and prints an installation command. -`restart` of `node`, `py`, `bun` and `go` (`"no"`, the default; `"on-failure"`; `"exit"`) says what happens when the runtime's process ends on its own (for `go`, each Go runtime's); see [When a runtime ends](#when-a-runtime-ends). +`restart` of `node`, `py`, `bun`, `go` and `swift` (`"no"`, the default; `"on-failure"`; `"exit"`) says what happens when the runtime's process ends on its own (for `go` and `swift`, each of their runtimes'); see [When a runtime ends](#when-a-runtime-ends). #### Runtimes that connect in @@ -279,7 +282,7 @@ Each row runs a plugin: | Field | Meaning | | --- | --- | | `id` | Row name, unique within this file. | -| `name` | Plugin identifier: an npm package (or package subpath), `py:` (Python entry point or module), `bun:` (a module in the Bun runtime), `go:` (the Go binary that has it; `go-:` when two do), `./relative-path` (plugin file), `:`, `rutis-bridge/peer` (a node; see [Connect nodes](nodes.en.md)), or `peer:/` (a plugin running on another node). | +| `name` | Plugin identifier: an npm package (or package subpath), `py:` (Python entry point or module), `bun:` (a module in the Bun runtime), `go:` (the Go binary that has it; `go-:` when two do), `swift:` (likewise, Swift binaries), `./relative-path` (plugin file), `:`, `rutis-bridge/peer` (a node; see [Connect nodes](nodes.en.md)), or `peer:/` (a plugin running on another node). | | `config` | Plugin configuration. | | `inject` | Additional service names to wait for. | | `isolate` | `{ "service": true }` gives this row its own instance of the service; `{ "service": "label" }` shares one instance among rows with that label. | @@ -302,7 +305,7 @@ The environment cannot be changed from outside the process, so these tokens cann ## Deployment -Place `rutis.json`, the Node project (with plugins and `@arcships/rutis-runtime` installed), the Python environment (with `rutis` and plugins installed), and the Go plugin directory (binaries for the platform) together. Run `rutis-host run /srv/app/rutis.json` under a process manager such as systemd. When `rutis-host` exits, the runtime processes it started exit as well. +Place `rutis.json`, the Node project (with plugins and `@arcships/rutis-runtime` installed), the Python environment (with `rutis` and plugins installed), and the Go and Swift plugin directories (binaries for the platform) together. Run `rutis-host run /srv/app/rutis.json` under a process manager such as systemd. When `rutis-host` exits, the runtime processes it started exit as well. To apply a changed `rutis.json`, use `systemctl reload` (`ExecReload=/bin/kill -USR1 $MAINPID`), or run with `--watch-config`; see [Changing the configuration while it runs](#changing-the-configuration-while-it-runs). diff --git a/docs/guide/rutis-host.md b/docs/guide/rutis-host.md index d8cd69ba..53e2dc73 100644 --- a/docs/guide/rutis-host.md +++ b/docs/guide/rutis-host.md @@ -1,6 +1,6 @@ # rutis-host 与 rutis.json -`rutis-host` 是不需要写 Rust 的宿主:它把内核、插件加载、本机的 Node / Python / Go 运行时和网络连接组装好,由一份 `rutis.json` 驱动。插件作者用它在本地开发,也可以直接用它部署。需要自定义 Rust 服务的应用见 [在 Rust 应用里嵌入](rust-host.md)。 +`rutis-host` 是不需要写 Rust 的宿主:它把内核、插件加载、本机的 Node / Python / Go / Swift 运行时和网络连接组装好,由一份 `rutis.json` 驱动。插件作者用它在本地开发,也可以直接用它部署。需要自定义 Rust 服务的应用见 [在 Rust 应用里嵌入](rust-host.md)。 ## 安装 @@ -11,16 +11,16 @@ | 二进制 | GitHub Release 的 `rutis-host-<版本>-<平台>.tar.gz`(Linux、macOS 的 x64 / arm64),Windows x64 为 `.zip` | | crates.io | `cargo install rutis-host` | -npm 分发自带 Node 运行时(`@arcships/rutis-runtime`),PyPI 分发自带 Python 运行时(`rutis`):项目里没有自己的运行时时用它们。Go 插件是编译好的二进制,运行它们不需要别的包,也不需要 Go 工具链。 +npm 分发自带 Node 运行时(`@arcships/rutis-runtime`),PyPI 分发自带 Python 运行时(`rutis`):项目里没有自己的运行时时用它们。Go 与 Swift 插件是编译好的二进制,运行它们不需要别的包,也不需要 Go 或 Swift 工具链(Swift 插件在 macOS 上运行)。 ## 命令 | 命令 | 作用 | | --- | --- | | `rutis-host run [rutis.json]` | 按配置运行(Unix 上 SIGUSR1 或加 `--watch-config` 时运行中重新读配置,见 [运行中改配置](#运行中改配置)),打印每一行状态的变化、节点行 link 的状态和本机运行时的结束(见 [run 打印什么](#run-打印什么));Ctrl-C 结束(见 [停止与退出码](#停止与退出码)) | -| `rutis-host dev [目录]` | 在插件项目里运行这个插件(加上 `rutis.dev.json`),文件变化时重新加载;Go 项目重新构建,只重启它的运行时 | -| `rutis-host check [rutis.json] [--strict]` | 不运行任何插件,检查每一行能不能运行(见 [检查配置](#检查配置)),打印版本、依赖、提供的服务、配置 Schema,并列出每个 Go 二进制(运行时名、SDK、插件 API、插件);有不能运行的行或二进制时以非零状态退出。没有行声明、只可能在运行时由别的行提供的依赖是告警,加 `--strict` 时算错误。在没有 rutis.json 的插件项目里检查这个项目 | -| `rutis-host new <名字> --lang node\|bun\|python\|go` | 创建插件项目 | +| `rutis-host dev [目录]` | 在插件项目里运行这个插件(加上 `rutis.dev.json`),文件变化时重新加载;Go、Swift 项目重新构建(`go build`、`swift build`),只重启它的运行时 | +| `rutis-host check [rutis.json] [--strict]` | 不运行任何插件,检查每一行能不能运行(见 [检查配置](#检查配置)),打印版本、依赖、提供的服务、配置 Schema,并列出每个 Go 与 Swift 二进制(运行时名、平台、SDK、插件 API、插件;rutis 0.13 以前的 SDK 构建的二进制不能运行);有不能运行的行或二进制时以非零状态退出。没有行声明、只可能在运行时由别的行提供的依赖是告警,加 `--strict` 时算错误。在没有 rutis.json 的插件项目里检查这个项目 | +| `rutis-host new <名字> --lang node\|bun\|python\|go\|swift` | 创建插件项目 | | `rutis-host go add <模块>@<版本> [rutis.json]` | 用本机的 Go 工具链把插件二进制 `go install` 到 `runtimes.go.dir` | ## run 打印什么 @@ -35,12 +35,12 @@ rutis-host: listening on <监听器名> <地址:端口> 之后每一行状态变化时打印一行 `<行 id>: <状态>`(`running`、`waiting for its services`、`starting`、`failed: …`、`disabled`、`removed` 等;实例里的副本写成 `<行 id> in PluginId(n)`)。 -本机的运行时(`node`、`py`、`bun`、Go 的 `go-<二进制>`)进程自己结束或起不来时打印一行,恢复运行时再打印一行;`rutis-host` 停止它们(停止宿主、Go 运行时空闲停下)时不打印: +本机的运行时(`node`、`py`、`bun`、Go 的 `go-<二进制>`、Swift 的 `swift-<二进制>`)进程自己结束或起不来时打印一行,恢复运行时再打印一行;`rutis-host` 停止它们(停止宿主、Go 或 Swift 运行时空闲停下)时不打印: | 输出 | 含义 | | --- | --- | | `runtime <名字>: down (<原因>)` | 运行时进程结束了,原因写它怎样结束,例如 `exited with signal: 9 (SIGKILL)`、`exited with exit status: 1`;或者它没能启动、原因是启动失败的错误。它的行回到 `waiting for its services`,其他运行时和它们的行不受影响 | -| `runtime <名字>: running` | 打印过 `down` 的运行时重新运行了(`dev` 里重新构建的 Go 运行时) | +| `runtime <名字>: running` | 打印过 `down` 的运行时重新运行了(`dev` 里重新构建的 Go 或 Swift 运行时) | 一个运行时停在 `down` 时只打印一次;括号里的原因是给人看的,不保证措辞。 @@ -124,7 +124,7 @@ Unix 上,`rutis-host` 启动时就被忽略的信号(比如用 `nohup` 启 两种方式走同一个重新读取,全有或全无: 1. 文件读不出来、不是有效的配置:拒绝。 -2. 运行中改不了的部分有变化:拒绝,提示重启宿主。它们是顶层的 `format`、`id`、`listen`、`runtimes.go`(任何变化)、`runtimes.remote`(增删条目,或改了条目的任何字段,包括 `listen`、`instances`),以及新增的或改了设置的本地运行时(`node`、`py`、`bun`)。 +2. 运行中改不了的部分有变化:拒绝,提示重启宿主。它们是顶层的 `format`、`id`、`listen`、`runtimes.go` 与 `runtimes.swift`(任何变化)、`runtimes.remote`(增删条目,或改了条目的任何字段,包括 `listen`、`instances`),以及新增的或改了设置的本地运行时(`node`、`py`、`bun`)。 3. 先检查新的行(同 `check`):有行起不来(插件找不到、行的设置通不过加载前的检查),而它现在并没有在失败,就拒绝。等待服务的行不算起不来,和启动时一样。 4. 再用新的行替换旧的:新增的行启动,删掉的行卸载,改了的行重启;没改的行不动(用到被重启的行所提供服务的行随它等待、恢复,和平时一样)。替换后有行失败(例如插件的 `apply` 抛错),而它之前没有失败,就换回旧的行(这时被换掉的行会重启一次),然后拒绝。 5. 删掉的本地运行时:它的行先卸载,再停止运行时,它的进程退出后才算完成。 @@ -145,7 +145,7 @@ rutis-host: reload rejected: <原因>; nothing changed ## 运行时结束时 -本机运行时(`node`、`py`、`bun`、每个 Go 运行时)的进程自己结束(崩溃、被 `kill -9`、插件让进程退出)时,`rutis-host` 按这个运行时的 `restart` 处理: +本机运行时(`node`、`py`、`bun`、每个 Go 或 Swift 运行时)的进程自己结束(崩溃、被 `kill -9`、插件让进程退出)时,`rutis-host` 按这个运行时的 `restart` 处理: | `restart` | 做法 | | --- | --- | @@ -164,14 +164,14 @@ rutis-host: reload rejected: <原因>; nothing changed - 进程内存里的状态随崩溃丢失,重启不会多丢;正在进行的调用已经显式失败,不会自动重试。会再执行一次的是插件启动时做的事(`apply`),和手动重启宿主一样。 - 宿主停止时(收到终止信号、`exit` 策略)不再重启任何运行时;[运行中改配置](#运行中改配置)删掉的运行时也不会重启。 - `restart` 是运行时的设置,运行中改它要重启宿主。 -- `dev` 用同样的规则和默认值;在 `rutis.dev.json` 里写 `"runtimes": { "py": { "restart": "on-failure" } }`(或 `"go"` 等)(项目自己的那个运行时只取 `restart`)。 +- `dev` 用同样的规则和默认值;在 `rutis.dev.json` 里写 `"runtimes": { "py": { "restart": "on-failure" } }`(或 `"go"`、`"swift"` 等)(项目自己的那个运行时只取 `restart`)。 -- Go:`runtimes.go.restart` 对每个 Go 运行时生效,各算各的(各自的等待和上限;打印 `runtime go-<名字>: restarting`)。空闲停下的运行时不算自己结束;握手被拒(旧 SDK)的二进制也不算,文件改变前不再启动。不论 `restart` 怎么写,崩溃的 Go 运行时换了二进制后有行用到时也会再启动。运行中改 `runtimes.go` 仍要重启宿主。 +- Go:`runtimes.go.restart` 对每个 Go 运行时生效,各算各的(各自的等待和上限;打印 `runtime go-<名字>: restarting`)。空闲停下的运行时不算自己结束;握手被拒(旧 SDK)的二进制也不算,文件改变前不再启动。不论 `restart` 怎么写,崩溃的 Go 运行时换了二进制后有行用到时也会再启动。运行中改 `runtimes.go` 仍要重启宿主。`runtimes.swift.restart` 对 Swift 运行时相同(打印 `runtime swift-<名字>: restarting`)。 - 远程运行时和节点行的 link 自己重连,不受它影响。 ## 输出 -运行时进程(Node、Bun、Python、Go)的标准输出和标准错误不直接写到 `rutis-host` 的输出上:`rutis-host` 经管道读出它们,一次一整行地写出——插件写到 stdout 的行出现在 `rutis-host` 的 stdout,写到 stderr 的出现在 stderr,内容原样,不加前缀。`rutis-host` 自己的状态行同样一次写一整行。所以插件的行和 `rutis-host` 的行、两个运行时的行不会拼到一起,即使插件把一行分几次写(Python 的 `print` 先写内容、再写换行),也即使 stdout 和 stderr 是同一个文件(`2>&1`)。 +运行时进程(Node、Bun、Python、Go、Swift)的标准输出和标准错误不直接写到 `rutis-host` 的输出上:`rutis-host` 经管道读出它们,一次一整行地写出——插件写到 stdout 的行出现在 `rutis-host` 的 stdout,写到 stderr 的出现在 stderr,内容原样,不加前缀。`rutis-host` 自己的状态行同样一次写一整行。所以插件的行和 `rutis-host` 的行、两个运行时的行不会拼到一起,即使插件把一行分几次写(Python 的 `print` 先写内容、再写换行),也即使 stdout 和 stderr 是同一个文件(`2>&1`)。 - 一行在它的换行到来时写出,没有按时间的刷新:不带换行的提示、用 `\r` 重画的进度条,要到换行时才出现。运行时结束时,没有换行的最后一行作为单独的一行写出。 - 超过 64 KiB 还没有换行的行分段写出。 @@ -190,6 +190,7 @@ rutis-host: reload rejected: <原因>; nothing changed "node": { "project": "." }, "py": { "project": "plugins", "python": ".venv/bin/python" }, "go": { "dir": "plugins/go" }, + "swift": { "dir": "plugins/swift", "restart": "on-failure" }, "remote": [{ "name": "gpu", "language": "python" }] }, "listen": [ @@ -199,6 +200,7 @@ rutis-host: reload rejected: <原因>; nothing changed { "id": "weather", "name": "weather-plugin", "config": { "city": "Oslo" } }, { "id": "llm", "name": "py:llm_gateway" }, { "id": "dns", "name": "go:dns" }, + { "id": "calendar", "name": "swift:calendar" }, { "id": "local-tool", "name": "./tools/tool.ts" }, { "id": "office", "name": "rutis-bridge/peer", "config": { "peer": "office", "dial": "wss://office.example.com/rutis", "export": ["weather"] } }, { "id": "gpu", "name": "rutis-bridge/peer", "config": { "peer": "gpu", "dial": "wss://gpu.example.com/rutis", "runtime": "gpu" } }, @@ -233,11 +235,12 @@ rutis.json: this file is format 2, this rutis-host reads up to 1: upgrade rutis- | `bun` | `project`(默认 `.`)、`runtime`、`program`、`restart` | 启动 Bun 运行时,行名 `bun:<模块>`。模块从 `project` 解析(`node_modules` 里的包、`./相对路径`);`@arcships/rutis-bun` 装在这里(或由 `runtime` 指定);`program` 是 Bun 可执行文件,默认 `PATH` 上的 `bun`。见 [写一个 Bun 插件](bun-plugin.md) | | `py` | `project`(默认 `.`)、`python`、`restart` | 启动 Python 运行时。解释器默认依次为 `$VIRTUAL_ENV/bin/python`、`/.venv/bin/python`、`python3`(Windows 上为 `Scripts\python.exe` 和 `python`),它的环境里要装 `rutis` 和插件 | | `go` | `dir`、`binaries`、`start`(`on-demand` 默认 / `eager`)、`idle`(秒,默认 60)、`project`(默认 `.`)、`restart` | Go 插件:一个二进制一个运行时进程。`dir` 里含 Go SDK 标记的可执行文件都算(整个目录被视为受信任,应是专用目录),`binaries` 列出单个文件。运行时名由文件名得出(`netkit` → `go-netkit`)。按需启动的运行时在第一次有行用到时启动、空闲 `idle` 秒后停下;`eager` 全部启动并一直运行(不能同时写 `idle`)。换了二进制,重启宿主后生效 | +| `swift` | 同 `go` | Swift 插件(macOS):与 `go` 相同,针对用 Swift SDK 构建的二进制(`plugin-binary` → `swift-plugin-binary`);见 [Swift 插件](swift-plugin.md) | | `remote` | `name`、`language`、`listen`、`instances` | 别的机器上的运行时:经 `rows` 里 `"runtime": ""` 的节点行连接,见 [连接节点](nodes.md);或写 `listen` 与 `instances`,由运行时连进来,见下 | 缺少运行时包时,启动失败并给出安装命令。 -`node`、`py`、`bun`、`go` 的 `restart`(`"no"` 默认、`"on-failure"`、`"exit"`)决定运行时进程自己结束时怎么办(`go` 是每个 Go 运行时各自的),见 [运行时结束时](#运行时结束时)。 +`node`、`py`、`bun`、`go`、`swift` 的 `restart`(`"no"` 默认、`"on-failure"`、`"exit"`)决定运行时进程自己结束时怎么办(`go`、`swift` 是其每个运行时各自的),见 [运行时结束时](#运行时结束时)。 #### 连进来的运行时 @@ -279,7 +282,7 @@ Python 运行时用 `python -m rutis dial:wss://… --id --peer <本宿主 | 字段 | 含义 | | --- | --- | | `id` | 行的名字,在本文件内唯一 | -| `name` | 插件:npm 包名(或包的子路径)、`py:<名字>`(Python 入口点或模块)、`bun:<模块>`(Bun 运行时里的模块)、`go:<插件>`(含这个插件的 Go 二进制;两个二进制都有时写 `go-<二进制名>:<插件>`)、`./相对路径`(插件文件)、`<远程运行时>:<模块或插件>`、`rutis-bridge/peer`(节点,见 [连接节点](nodes.md))、`peer:<节点>/<插件>`(在别的节点上运行的插件) | +| `name` | 插件:npm 包名(或包的子路径)、`py:<名字>`(Python 入口点或模块)、`bun:<模块>`(Bun 运行时里的模块)、`go:<插件>`(含这个插件的 Go 二进制;两个二进制都有时写 `go-<二进制名>:<插件>`)、`swift:<插件>`(同上,Swift 二进制)、`./相对路径`(插件文件)、`<远程运行时>:<模块或插件>`、`rutis-bridge/peer`(节点,见 [连接节点](nodes.md))、`peer:<节点>/<插件>`(在别的节点上运行的插件) | | `config` | 插件的配置 | | `inject` | 额外要等的服务名 | | `isolate` | `{ "服务名": true }` 让这一行用自己的那份服务;`{ "服务名": "标签" }` 让同标签的行共用一份 | @@ -302,7 +305,7 @@ Python 运行时用 `python -m rutis dial:wss://… --id --peer <本宿主 ## 部署 -把 `rutis.json`、Node 项目(装好插件和 `@arcships/rutis-runtime`)、Python 环境(装好 `rutis` 和插件)、Go 插件目录(对应平台的二进制)放在一起,用进程管理器(systemd 等)运行 `rutis-host run /srv/app/rutis.json`。`rutis-host` 结束时,它启动的运行时进程随之结束。 +把 `rutis.json`、Node 项目(装好插件和 `@arcships/rutis-runtime`)、Python 环境(装好 `rutis` 和插件)、Go 与 Swift 插件目录(对应平台的二进制)放在一起,用进程管理器(systemd 等)运行 `rutis-host run /srv/app/rutis.json`。`rutis-host` 结束时,它启动的运行时进程随之结束。 改了 `rutis.json` 要生效时用 `systemctl reload`(`ExecReload=/bin/kill -USR1 $MAINPID`),或者用 `--watch-config` 运行,见 [运行中改配置](#运行中改配置)。 diff --git a/docs/guide/swift-plugin.en.md b/docs/guide/swift-plugin.en.md new file mode 100644 index 00000000..eb87bc1b --- /dev/null +++ b/docs/guide/swift-plugin.en.md @@ -0,0 +1,231 @@ +# Write a Swift Plugin + +[中文](swift-plugin.md) + +From creating a project to publishing it, and to a host using it. Needs macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later, or its Command Line Tools); the SDK itself is written in the Swift 5 language mode, and so is the project `new` makes. + +Swift plugins follow the same conventions as TypeScript, Python and Go plugins (see the [plugin API](plugin-api.en.md)); as with Go, **a Swift plugin is compiled into a binary, and a binary is one runtime process in the host**. A binary holds one or several plugins; a host can run many binaries at once, starting each only when a row uses it. Swift is the language for what macOS offers its own programs: calendars and reminders (EventKit), contacts, accessibility and Apple events automation, screen capture, Core ML. + +## 1. Create the project + +```bash +rutis-host new weather --lang swift +cd weather +swift test +``` + +The project (a SwiftPM package): + +| File | What it is | +| --- | --- | +| `Sources/WeatherPlugin/Plugin.swift` | The plugin (library `WeatherPlugin`, exporting `plugin`) | +| `Sources/weather/main.swift` | The binary: `Rutis.serve(plugin)` | +| `Tests/WeatherPluginTests/PluginTests.swift` | Unit tests with `RutisTest`, no host needed | +| `Info.plist` | Linked into the binary: its name and the usage descriptions macOS shows when the plugin asks for access (see [System access](#5-system-access)) | +| `Package.swift` | Depends on `https://github.com/arcships/rutis` (the products `Rutis` and `RutisTest`); `linkerSettings` link `Info.plist` | +| `rutis.dev.json` | Configuration of the local runtime and other plugins for testing | +| `.github/workflows/release.yml` | On a `v*` tag: tests, then a universal binary (arm64 + x86_64) on a GitHub release | + +The first build compiles the SDK's macros (swift-syntax, at build time only); Xcode asks once to trust them (`-skipMacroValidation` in CI that runs `xcodebuild`). + +## 2. Write the plugin + +```swift +import Rutis + +/// The configuration: a plain Decodable type; @RutisConfig derives its JSON Schema. +@RutisConfig +public struct Config: Decodable { + /// the city to report on + public var city: String +} + +/// A service it uses, in any language: @RutisClient makes `LLMProxy`. +@RutisClient +public protocol LLM { + func ask(_ question: String) async throws -> String +} + +/// The service it provides: @RutisService makes its methods the service's. +@RutisService +public final class Weather: @unchecked Sendable { + let llm: any LLM + let city: String + + init(llm: any LLM, city: String) { self.llm = llm; self.city = city } + + public func today() async throws -> String { + try await llm.ask("weather in \(city)") + " in \(city)" + } + + public func unit() -> String { "celsius" } +} + +public let plugin = Rutis.define(Plugin( + name: "weather", + inject: ["llm"], // runs only while all are there + provides: ["weather": .of(Weather.self, sync: ["unit"])], // optional: marks unit sync; methods are async by default + config: Config.self, + apply: { ctx, config in + ctx.provide("weather", Weather(llm: try ctx.use("llm", LLMProxy.self), city: config.city)) + ctx.effect { /* a cleanup, optional */ } + } +)) +``` + +In short: + +| Topic | Rule | +| --- | --- | +| Methods | `@RutisService` makes every instance method that is not `private` or `fileprivate` a method of the service, its wire name the base name (`today`); arguments by position. Overloads, generic methods and function parameters that are not `async throws` are compile errors. Methods of a superclass or of a protocol extension are not seen: declare them in the type | +| Sync or async | `async` by default, whatever the Swift signature; `provides: [... .of(T.self, sync: ["unit"])]` marks synchronous ones. A `sync` method may itself be `async` and call other services: the Task waits without holding a thread. A sync method blocks a Node caller's event loop: keep methods that do I/O `async` | +| Using services | A protocol with `@RutisClient` (its methods `async throws`); `ctx.use("llm", LLMProxy.self)` returns an `any LLM`, the provider itself when it is in the same process and implements the protocol. Or call by name: `try await ctx.use("llm").call("ask", values: ["q"], as: String.self)` | +| Call chain | It travels with the Task (`@TaskLocal`): plugin code passes no context. Tasks a plugin starts with `ctx.spawn { ... }` carry it and are cancelled at unload | +| Concurrency | Every incoming call runs in a Task of its own: provided objects must be safe for concurrent use | +| Cancellation | A caller that gives up cancels the method's Task (`Task.checkCancellation()` throws `CancellationError`); a call the plugin makes from a cancelled Task throws `CancellationError` | +| Values | Data (`Codable` structs, arrays, dictionaries, numbers, strings) is copied, by the SDK's own encoder and decoder; `Date`, `Data`, `Decimal`, `UUID`, `Set`, dictionaries whose keys are not `String`, and `Rutis.DateTime`, `Rutis.CalendarDate`, `Rutis.TimeOfDay`, `Rutis.BigInt` cross as their own values (table below); integers beyond ±(2^53−1) (`Int64`, `UInt64`, ...) cross as big integers by themselves; instances of `@RutisService` types and closures cross by reference; a parameter of type `Any` receives native values | +| Errors | A thrown error crosses with its type's name (`QuotaError`), its message (`LocalizedError`'s `errorDescription`, else its description), and the stored properties `code` and `data` when it has them, which the caller reads as its error's `code` and `data`. An error received is a `RutisError` (`name`, `message`, `code`, `data`, `errors`, `cause`), matched by `name` | +| `fatalError`, `try!`, a force unwrap | Not catchable in Swift: the runtime process ends, its rows stop, and the host does what `restart` says ([When a runtime ends](rutis-host.en.md#when-a-runtime-ends)) | + +How Swift values cross ([Passing values](plugin-api.en.md#passing-values)): + +| Value | Swift | +| --- | --- | +| Point in time | `Date`, truncated to the microsecond | +| Local date and time, date, time of day | `Rutis.DateTime`, `Rutis.CalendarDate`, `Rutis.TimeOfDay` (Swift has no such types) | +| Binary data | `Data` | +| Map | A `Dictionary` whose keys are not `String`; sent with its keys sorted, so one value is always sent the same way | +| Set | `Set`, sent sorted | +| Big integer | `Rutis.BigInt`, or an integer type that holds it | +| Decimal | `Decimal`: at most 38 digits, and trailing zeros are not kept (`1.10` arrives as `1.1`); declare the parameter `String` to keep the text as it was sent | +| UUID | `UUID` | + +### Events + +Every listener on a host, in any language and any process, is on one event bus ([design](../design-events-2026-10-18.en.md)): where an event is emitted and where its listeners are does not change the result. A listener's parameters are the event's arguments, decoded by their types (missing ones `nil`, extra ones ignored); in a waterfall its last parameter is a `Rutis.Next`. + +```swift +let off = ctx.on("session/created") { (id: String) in print("new session \(id)") } // options: prepend:, global: + +try ctx.emit("session/created", id) // returns at once; throws only for a refused emission +try await ctx.parallel("session/created", id) // waits for all; a RutisError named AggregateError +let value = try await ctx.serial("config/resolve", key, as: String.self) // the first result (anything but nil and false) +let first = try await ctx.bail("config/resolve", key, as: String.self) // serial as a synchronous call +let text = try await ctx.waterfall("chat/reply", message, as: String.self) { (message: String) in message } // the terminal: the end of the chain +let some = try await ctx.filter { $0.row == "chat" }.serial("config/resolve", key, as: String.self) +``` + +A complete example: the plugin answers `greeting/pick` and adds a link to the `greeting/format` waterfall; the service it provides takes the first result with `serial`, lets other plugins (in any language) rewrite the greeting with `waterfall`, and notifies with `emit`: + +```swift +import Rutis + +@RutisService +public final class Greeter: @unchecked Sendable { + let ctx: Rutis.Ctx + init(ctx: Rutis.Ctx) { self.ctx = ctx } + + public func hello(_ name: String, _ lang: String) async throws -> String { + let word = try await ctx.serial("greeting/pick", lang, as: String.self) ?? "Hello" + let text = try await ctx.waterfall("greeting/format", "\(word), \(name)", as: String.self) { (text: String) in text } + try ctx.emit("greeting/said", name) + return text ?? "" + } +} + +public let greeter = Rutis.define(Plugin(name: "greeter") { ctx in + ctx.on("greeting/pick") { (lang: String) -> String? in lang == "sv" ? "Hej" : nil } // nil: the next listener goes on + ctx.on("greeting/format") { (text: String, next: Rutis.Next) in + (try await next() ?? text) + "!" // next runs the rest of the chain + } + ctx.provide("greeter", Greeter(ctx: ctx)) +}) +``` + +The rules (waterfall, failures, scope, filter, cancellation, reserved names) are the same in every language: see [plugin API: events](plugin-api.en.md#events) and the [Go guide's table](go-plugin.en.md#events). In Swift, a listener's Task is cancelled when its row unloads, and when the emitter of a `parallel`, `serial`, `waterfall` or `bail` gives up; every listener is called as a synchronous one, so `bail` reaches Swift listeners anywhere. + +## 3. Test + +`RutisTest` needs no host, but the plugin runs in its real runtime and values cross a real session (an in-memory socket pair); the test side is the host: + +```swift +import RutisTest +import Testing +@testable import WeatherPlugin + +@RutisService +final class FakeLLM: @unchecked Sendable { + func ask(_ question: String) -> String { "sunny" } +} + +@Test func today() async throws { + let loaded = try await RutisTest.load(plugin, config: ["city": "Oslo"], services: ["llm": FakeLLM()]) + let today = try await loaded.service("weather").call("today", as: String.self) + await loaded.close() + #expect(today == "sunny in Oslo") +} +``` + +```bash +swift test +``` + +Events in tests: `loaded.emit`, `parallel`, `serial`, `waterfall`, `bail` emit from the row's scope; `loaded.on(name) { ... }` listens as the host does; `loaded.load(config:row:scope:)` loads another row (`RutisTest.scope("t1/s1")`); `loaded.update(config:)` changes a row's config as rutis does; `loaded.reported` is what the runtime reported of failed `emit` listeners. What works only within one process (sharing a class instance that is not a `@RutisService`, relying on shared memory) fails here, as it would in a host. + +## 4. Run it in a local host + +```bash +rutis-host dev +``` + +`dev` builds the package's executable product with `swift build` and runs each of its plugins as the runtime `swift-weather`; a change to the sources, `Package.swift` or `Package.resolved` rebuilds it and restarts only that runtime, and a failed build keeps the old one running (the compiler's errors are printed). Each build is copied to `.rutis/swift/-`, a new file each time. Other services the plugin needs go in `rutis.dev.json`; they may be Python or TypeScript plugins (with their `runtimes`). `rutis-host check` lists every row and every Swift binary with its platform, SDK, plugin API and plugins; a binary built with an SDK older than rutis 0.13 cannot run, and `check` says to rebuild it. + +## 5. System access + +macOS asks the user before a program reads calendars, contacts and the like, and remembers the answer for that program. For a Swift plugin, that program is **the plugin binary**: the runtime makes itself responsible for its own requests when it starts (whoever started it: `rutis-host`, a terminal, a process manager), so the prompt names the binary, and what the user grants applies to it alone, not to the host or the terminal. Plugins in one binary share its access; plugins that need different access go in different binaries. + +- The prompt shows the binary's name (`CFBundleName` in `Info.plist`) and the usage description of what is asked for: add the key (`NSCalendarsFullAccessUsageDescription`, `NSRemindersFullAccessUsageDescription`, `NSContactsUsageDescription`, `NSAppleEventsUsageDescription`, ...) to `Info.plist`. The template's `Package.swift` links it into the binary. +- A denial is an ordinary failure: check the answer and throw, and the caller, in any language, gets the error: + +```swift +import EventKit + +struct CalendarAccessDenied: Error, LocalizedError { + let code = "denied" + var errorDescription: String? { "calendar access is not granted to this plugin" } +} + +@RutisService +public final class Calendar: @unchecked Sendable { + let store = EKEventStore() + + public func titles(_ days: Int) async throws -> [String] { + guard try await store.requestFullAccessToEvents() else { throw CalendarAccessDenied() } + let start = Date() + let end = start.addingTimeInterval(Double(days) * 86_400) + let events = store.events(matching: store.predicateForEvents(withStart: start, end: end, calendars: nil)) + return events.map { $0.title ?? "" } + } +} +``` + +- Local builds are ad-hoc signed (`swift build` does it): macOS knows the binary by its signature, so every rebuild is a new program to it and may ask again. `tccutil reset Calendar ` forgets an answer. A binary signed with a Developer ID keeps its access across versions. + +## 6. Publish + +Push a tag such as `v0.1.0`: the template's workflow (on a GitHub macOS runner with Xcode) runs the tests and `rutis-host check`, builds a universal binary for arm64 and x86_64 (`swift build -c release --arch arm64 --arch x86_64`), and publishes it with `SHA256SUMS` on a GitHub release. With the secrets it names (a Developer ID certificate and notarization credentials) it signs the binary with the hardened runtime and notarizes it; without them the binary is ad-hoc signed, and a host that downloads it has to trust it. + +Whoever wants your plugin in a binary of their own depends on your package and serves it with theirs: `Rutis.serve(WeatherPlugin.plugin, other.plugin)`. + +## 7. Used by a host + +A host names a Swift plugin directory in `rutis.json` and puts binaries there: + +```json +{ + "runtimes": { "swift": { "dir": "plugins/swift", "restart": "on-failure" } }, + "rows": [{ "id": "weather", "name": "swift:weather", "config": { "city": "Oslo" } }] +} +``` + +Download the binary from a release into the directory (a downloaded binary is quarantined: `rutis-host check` says so, and `xattr -d com.apple.quarantine ` clears it once you trust it). The host finds `swift:` in all binaries; when two binaries have a plugin of the same name, write `swift-:`. The settings are those of `runtimes.go`: a binary starts when a row first uses it and stops after `idle` seconds (60) without one, `"start": "eager"` starts all and keeps them running, `restart` (`no` by default, `on-failure`, `exit`) applies to each runtime on its own, and a replaced binary takes effect when its runtime restarts. Running Swift binaries needs no Swift toolchain. See [rutis-host and rutis.json](rutis-host.en.md); for Rust hosts, [embedding in a Rust application](rust-host.en.md) (`SwiftResolver`, `SwiftRuntimes` in rutis-loader). diff --git a/docs/guide/swift-plugin.md b/docs/guide/swift-plugin.md new file mode 100644 index 00000000..329e5898 --- /dev/null +++ b/docs/guide/swift-plugin.md @@ -0,0 +1,231 @@ +# 写一个 Swift 插件 + +[English](swift-plugin.en.md) + +从创建项目到发布,再到被宿主使用。需要 macOS 14 或更高,以及 Swift 6 工具链(Xcode 16 或更高,或它的 Command Line Tools);SDK 本身按 Swift 5 语言模式编写,`new` 生成的项目也是。 + +Swift 插件和 TypeScript、Python、Go 插件的写法与规则相同(见 [插件 API](plugin-api.md));和 Go 一样,**Swift 插件编译成二进制,一个二进制就是宿主里的一个运行时进程**。一个二进制可以带一个或几个插件;宿主可以同时运行很多个二进制,只在有行用到时才启动它。Swift 用来接入 macOS 给本机程序的能力:日历与提醒(EventKit)、通讯录、辅助功能与 Apple 事件自动化、屏幕录制、Core ML。 + +## 1. 创建项目 + +```bash +rutis-host new weather --lang swift +cd weather +swift test +``` + +得到的项目(一个 SwiftPM 包): + +| 文件 | 作用 | +| --- | --- | +| `Sources/WeatherPlugin/Plugin.swift` | 插件本身(库 `WeatherPlugin`,导出 `plugin`) | +| `Sources/weather/main.swift` | 二进制:`Rutis.serve(plugin)` | +| `Tests/WeatherPluginTests/PluginTests.swift` | 用 `RutisTest` 的单元测试,不需要宿主 | +| `Info.plist` | 链进二进制:它的名字,以及插件请求权限时 macOS 显示的用途说明(见 [系统权限](#5-系统权限)) | +| `Package.swift` | 依赖 `https://github.com/arcships/rutis`(产品 `Rutis` 与 `RutisTest`);`linkerSettings` 链入 `Info.plist` | +| `rutis.dev.json` | 本地运行时的配置和测试用的其他插件 | +| `.github/workflows/release.yml` | 打 `v*` tag 时测试,并把通用二进制(arm64 + x86_64)发布到 GitHub Release | + +第一次构建会编译 SDK 的宏(swift-syntax,只在编译期);Xcode 会询问一次是否信任它们(CI 里用 `xcodebuild` 时加 `-skipMacroValidation`)。 + +## 2. 写插件 + +```swift +import Rutis + +/// 配置:普通的 Decodable 类型;@RutisConfig 由它生成 JSON Schema。 +@RutisConfig +public struct Config: Decodable { + /// 要查天气的城市 + public var city: String +} + +/// 它用到的服务,可以是任何语言的:@RutisClient 生成 `LLMProxy`。 +@RutisClient +public protocol LLM { + func ask(_ question: String) async throws -> String +} + +/// 它提供的服务:@RutisService 让它的方法成为服务的方法。 +@RutisService +public final class Weather: @unchecked Sendable { + let llm: any LLM + let city: String + + init(llm: any LLM, city: String) { self.llm = llm; self.city = city } + + public func today() async throws -> String { + try await llm.ask("weather in \(city)") + " in \(city)" + } + + public func unit() -> String { "celsius" } +} + +public let plugin = Rutis.define(Plugin( + name: "weather", + inject: ["llm"], // 全部就绪时才运行 + provides: ["weather": .of(Weather.self, sync: ["unit"])], // 可选:把 unit 标为同步;方法默认异步 + config: Config.self, + apply: { ctx, config in + ctx.provide("weather", Weather(llm: try ctx.use("llm", LLMProxy.self), city: config.city)) + ctx.effect { /* 清理,可选 */ } + } +)) +``` + +要点: + +| 主题 | 规则 | +| --- | --- | +| 方法 | `@RutisService` 让每个不是 `private` / `fileprivate` 的实例方法成为服务的方法,线上名是基本名(`today`),参数按位置。重载、泛型方法、不是 `async throws` 的函数参数在编译期报错。看不到父类和协议扩展里的方法:在类型里声明 | +| 同步还是异步 | 默认 `async`,与 Swift 签名无关;`provides: [... .of(T.self, sync: ["unit"])]` 标出同步方法。`sync` 方法自己也可以是 `async`、调用别的服务:Task 等待时不占线程。同步方法会阻塞 Node 调用方的事件循环:做 I/O 的方法保持 `async` | +| 使用服务 | 带 `@RutisClient` 的协议(方法须 `async throws`);`ctx.use("llm", LLMProxy.self)` 返回 `any LLM`,提供者在同一进程且实现了这个协议时就是它本身。也可以按名字调用:`try await ctx.use("llm").call("ask", values: ["q"], as: String.self)` | +| 调用链 | 随 Task 传递(`@TaskLocal`):插件代码不传任何上下文。插件用 `ctx.spawn { ... }` 起的 Task 带着它,卸载时被取消 | +| 并发 | 每个进来的调用在自己的 Task 里运行:提供的对象要能并发使用 | +| 取消 | 调用方放弃时取消方法的 Task(`Task.checkCancellation()` 抛 `CancellationError`);在已取消的 Task 里发出的调用抛 `CancellationError` | +| 值 | 数据(`Codable` 结构体、数组、字典、数字、字符串)按值复制,用 SDK 自己的编码器与解码器;`Date`、`Data`、`Decimal`、`UUID`、`Set`、键不是 `String` 的字典,以及 `Rutis.DateTime`、`Rutis.CalendarDate`、`Rutis.TimeOfDay`、`Rutis.BigInt` 按各自的值传递(见下表);超出 ±(2^53−1) 的整数(`Int64`、`UInt64` 等)自动按大整数传递;`@RutisService` 类型的实例和闭包按引用传递;类型为 `Any` 的参数收到原生值 | +| 错误 | 抛出的错误带着类型名(`QuotaError`)、消息(`LocalizedError` 的 `errorDescription`,否则它的描述),以及存储属性 `code`、`data`(有的话)跨过去,调用方读到的错误带同样的 `code`、`data`。收到的错误是 `RutisError`(`name`、`message`、`code`、`data`、`errors`、`cause`),按 `name` 匹配 | +| `fatalError`、`try!`、强制解包 | Swift 里不可捕获:运行时进程结束、它的行停止,宿主按 `restart` 处理([运行时结束时](rutis-host.md#运行时结束时)) | + +Swift 的值怎样跨过去([传递值](plugin-api.md#传递值)): + +| 值 | Swift | +| --- | --- | +| 时间点 | `Date`,截断到微秒 | +| 本地日期时间、日期、一天中的时间 | `Rutis.DateTime`、`Rutis.CalendarDate`、`Rutis.TimeOfDay`(Swift 没有这些类型) | +| 二进制数据 | `Data` | +| 映射 | 键不是 `String` 的 `Dictionary`;发出时按键排序,同一个值总是同样发出 | +| 集合 | `Set`,发出时排序 | +| 大整数 | `Rutis.BigInt`,或装得下它的整数类型 | +| 十进制数 | `Decimal`:至多 38 位,不保留末尾的零(`1.10` 收到是 `1.1`);要原样保留文本时把参数声明为 `String` | +| UUID | `UUID` | + +### 事件 + +一个宿主上所有语言、所有进程的监听器都在同一条事件总线上([设计](../design-events-2026-10-18.md)):事件在哪里发出、监听器在哪里,结果都一样。监听器的参数就是事件的参数,按声明的类型解码(缺的为 `nil`,多的忽略);waterfall 里最后一个参数是 `Rutis.Next`。 + +```swift +let off = ctx.on("session/created") { (id: String) in print("new session \(id)") } // 选项:prepend:、global: + +try ctx.emit("session/created", id) // 立即返回;只在发出被拒时抛出 +try await ctx.parallel("session/created", id) // 等全部;失败是名为 AggregateError 的 RutisError +let value = try await ctx.serial("config/resolve", key, as: String.self) // 第一个结果(nil 和 false 以外的) +let first = try await ctx.bail("config/resolve", key, as: String.self) // 以同步调用进行的 serial +let text = try await ctx.waterfall("chat/reply", message, as: String.self) { (message: String) in message } // terminal:链的末端 +let some = try await ctx.filter { $0.row == "chat" }.serial("config/resolve", key, as: String.self) +``` + +一个完整的例子:插件回答 `greeting/pick`,并在 `greeting/format` 这条 waterfall 上加一环;它提供的服务用 `serial` 取第一个结果,用 `waterfall` 让别的插件(任何语言)改写问候语,用 `emit` 发通知: + +```swift +import Rutis + +@RutisService +public final class Greeter: @unchecked Sendable { + let ctx: Rutis.Ctx + init(ctx: Rutis.Ctx) { self.ctx = ctx } + + public func hello(_ name: String, _ lang: String) async throws -> String { + let word = try await ctx.serial("greeting/pick", lang, as: String.self) ?? "Hello" + let text = try await ctx.waterfall("greeting/format", "\(word), \(name)", as: String.self) { (text: String) in text } + try ctx.emit("greeting/said", name) + return text ?? "" + } +} + +public let greeter = Rutis.define(Plugin(name: "greeter") { ctx in + ctx.on("greeting/pick") { (lang: String) -> String? in lang == "sv" ? "Hej" : nil } // nil:交给下一个监听器 + ctx.on("greeting/format") { (text: String, next: Rutis.Next) in + (try await next() ?? text) + "!" // next 运行链的其余部分 + } + ctx.provide("greeter", Greeter(ctx: ctx)) +}) +``` + +规则(waterfall、失败、作用域、过滤、取消、保留名)在各语言相同:见 [插件 API:事件](plugin-api.md#事件) 和 [Go 指南的表](go-plugin.md#事件)。在 Swift 里,监听器的 Task 在它的行卸载时被取消,`parallel`、`serial`、`waterfall`、`bail` 的发出方放弃时也被取消;监听器一律按同步监听器调用,所以 `bail` 在任何地方都能到达 Swift 监听器。 + +## 3. 测试 + +`RutisTest` 不需要宿主,但插件在它真实的运行时里运行,值经真实的会话(内存里的一对 socket)传递;测试一方就是宿主: + +```swift +import RutisTest +import Testing +@testable import WeatherPlugin + +@RutisService +final class FakeLLM: @unchecked Sendable { + func ask(_ question: String) -> String { "sunny" } +} + +@Test func today() async throws { + let loaded = try await RutisTest.load(plugin, config: ["city": "Oslo"], services: ["llm": FakeLLM()]) + let today = try await loaded.service("weather").call("today", as: String.self) + await loaded.close() + #expect(today == "sunny in Oslo") +} +``` + +```bash +swift test +``` + +测试里的事件:`loaded.emit`、`parallel`、`serial`、`waterfall`、`bail` 从这一行的作用域发出;`loaded.on(name) { ... }` 像宿主一样监听;`loaded.load(config:row:scope:)` 再装载一行(`RutisTest.scope("t1/s1")`);`loaded.update(config:)` 像 rutis 一样改一行的配置;`loaded.reported` 是运行时报告的 `emit` 监听器失败。只在同一进程里才成立的写法(共享一个不是 `@RutisService` 的 class 实例、依赖共享内存)在这里失败,在宿主里也会失败。 + +## 4. 在本地宿主里运行 + +```bash +rutis-host dev +``` + +`dev` 用 `swift build` 构建包的可执行产品,把它的每个插件作为运行时 `swift-weather` 运行;源码、`Package.swift` 或 `Package.resolved` 变化时重新构建,只重启这个运行时,构建失败时旧的继续运行(打印编译器的错误)。每次构建复制到 `.rutis/swift/<产品>-<序号>`,每次一个新文件。插件需要的其他服务写在 `rutis.dev.json` 里;它们可以是 Python 或 TypeScript 插件(带上它们的 `runtimes`)。`rutis-host check` 列出每一行和每个 Swift 二进制,带平台、SDK、插件 API 和插件;用 rutis 0.13 以前的 SDK 构建的二进制不能运行,`check` 提示重新构建。 + +## 5. 系统权限 + +程序读日历、通讯录等之前,macOS 会询问用户,并为这个程序记住答复。对 Swift 插件,这个程序是**插件二进制本身**:运行时启动时让自己为自己的请求负责(不论是谁启动它:`rutis-host`、终端、进程管理器),所以提示里是这个二进制的名字,用户的授权只给它,不给宿主或终端。同一个二进制里的插件共享它的授权;需要不同授权的插件放进不同的二进制。 + +- 提示显示二进制的名字(`Info.plist` 的 `CFBundleName`)和所请求权限的用途说明:把对应的键(`NSCalendarsFullAccessUsageDescription`、`NSRemindersFullAccessUsageDescription`、`NSContactsUsageDescription`、`NSAppleEventsUsageDescription` 等)加进 `Info.plist`。模板的 `Package.swift` 把它链进二进制。 +- 被拒绝是普通的失败:检查答复并抛出,任何语言的调用方都会收到这个错误: + +```swift +import EventKit + +struct CalendarAccessDenied: Error, LocalizedError { + let code = "denied" + var errorDescription: String? { "calendar access is not granted to this plugin" } +} + +@RutisService +public final class Calendar: @unchecked Sendable { + let store = EKEventStore() + + public func titles(_ days: Int) async throws -> [String] { + guard try await store.requestFullAccessToEvents() else { throw CalendarAccessDenied() } + let start = Date() + let end = start.addingTimeInterval(Double(days) * 86_400) + let events = store.events(matching: store.predicateForEvents(withStart: start, end: end, calendars: nil)) + return events.map { $0.title ?? "" } + } +} +``` + +- 本地构建是 ad-hoc 签名的(`swift build` 自动签):macOS 按签名认程序,所以每次重新构建对它都是一个新程序,可能再问一次。`tccutil reset Calendar ` 清除一次答复。用 Developer ID 签名的二进制在版本之间保留授权。 + +## 6. 发布 + +推一个 tag,比如 `v0.1.0`:模板的工作流(在带 Xcode 的 GitHub macOS runner 上)运行测试和 `rutis-host check`,构建 arm64 与 x86_64 的通用二进制(`swift build -c release --arch arm64 --arch x86_64`),连同 `SHA256SUMS` 发布到 GitHub Release。配好它列出的 secrets(Developer ID 证书和公证凭据)时,用 hardened runtime 签名并公证;没有时二进制是 ad-hoc 签名,下载它的宿主要先信任它。 + +想把你的插件放进自己二进制的人依赖你的包,和自己的插件一起 serve:`Rutis.serve(WeatherPlugin.plugin, other.plugin)`。 + +## 7. 被宿主使用 + +宿主在 `rutis.json` 里指定 Swift 插件目录,把二进制放进去: + +```json +{ + "runtimes": { "swift": { "dir": "plugins/swift", "restart": "on-failure" } }, + "rows": [{ "id": "weather", "name": "swift:weather", "config": { "city": "Oslo" } }] +} +``` + +从 Release 下载二进制放进这个目录(下载的二进制带隔离属性:`rutis-host check` 会提示,信任它之后用 `xattr -d com.apple.quarantine <文件>` 清除)。宿主在所有二进制里找 `swift:<插件>`;两个二进制有同名插件时写 `swift-<二进制名>:<插件>`。设置与 `runtimes.go` 相同:二进制在第一次有行用到时启动,空闲 `idle` 秒(60)后停下,`"start": "eager"` 全部启动并一直运行,`restart`(默认 `no`、`on-failure`、`exit`)对每个运行时各自生效,换了二进制在它的运行时重启后生效。运行 Swift 二进制不需要 Swift 工具链。见 [rutis-host 与 rutis.json](rutis-host.md);Rust 宿主见 [在 Rust 应用里嵌入](rust-host.md)(rutis-loader 的 `SwiftResolver`、`SwiftRuntimes`)。 diff --git a/swift/README.md b/swift/README.md index faeaa43b..c4442a9d 100644 --- a/swift/README.md +++ b/swift/README.md @@ -1,8 +1,8 @@ -# Swift SDK (preview) +# Swift SDK [中文](README.zh-CN.md) -Write rutis plugins in Swift and run them as a macOS plugin binary. The package is the repository root's `Package.swift` (targets under `swift/rutis`); it follows the release train's `v` tags. Design: [Swift SDK](../docs/design-swift-sdk-2026-10-12.en.md). This is a preview: the guide, `rutis-host` support (`runtimes.swift`, `new --lang swift`) and remote connections come in later phases (design §15). +Write rutis plugins in Swift and run them as a macOS plugin binary. The package is the repository root's `Package.swift` (targets under `swift/rutis`); it follows the release train's `v` tags. Design: [Swift SDK](../docs/design-swift-sdk-2026-10-12.en.md). The guide: [Write a Swift plugin](../docs/guide/swift-plugin.en.md) (`rutis-host new --lang swift` makes a project). ```swift // Package.swift: .package(url: "https://github.com/arcships/rutis", from: "0.14.0") @@ -45,7 +45,7 @@ let weather = Rutis.define(Plugin( Rutis.serve(weather) ``` -- `swift build` makes the binary; ` --rutis-manifest` prints what it serves. rutis-loader runs it with `SwiftResolver` / `SwiftRuntimes` (rows `swift:`). +- `swift build` makes the binary; ` --rutis-manifest` prints what it serves. `rutis-host` runs it from `runtimes.swift` (rows `swift:`), Rust hosts with rutis-loader's `SwiftResolver` / `SwiftRuntimes`. - Plugin code takes no context parameter: the call chain travels with the Task. Each incoming call runs in a Task of its own; a synchronous call suspends the Task, never a thread. - Values: the SDK's own encoder and decoder, driven by `Codable` (never `JSONEncoder`). `Date`, `Data`, `Decimal`, `UUID`, `Set` and non-`String`-keyed dictionaries cross as rutis's tagged values; `Rutis.DateTime`, `Rutis.CalendarDate`, `Rutis.TimeOfDay` and `Rutis.BigInt` are the types Swift lacks. Instances of `@RutisService` types and closures cross by reference. - Errors cross with their name (the type's), message, `code` and `data` (stored properties of those names); a received error is a `RutisError`, matched by `name`. diff --git a/swift/README.zh-CN.md b/swift/README.zh-CN.md index 66300202..96c67c25 100644 --- a/swift/README.zh-CN.md +++ b/swift/README.zh-CN.md @@ -1,8 +1,8 @@ -# Swift SDK(预览) +# Swift SDK [English](README.md) -用 Swift 写 rutis 插件,编成 macOS 插件二进制运行。包是仓库根的 `Package.swift`(目标在 `swift/rutis` 下),版本跟发布线的 `v` tag。设计:[Swift SDK](../docs/design-swift-sdk-2026-10-12.md)。现在是预览:指南、`rutis-host` 支持(`runtimes.swift`、`new --lang swift`)和远程连接在后续阶段(设计 §十五)。 +用 Swift 写 rutis 插件,编成 macOS 插件二进制运行。包是仓库根的 `Package.swift`(目标在 `swift/rutis` 下),版本跟发布线的 `v` tag。设计:[Swift SDK](../docs/design-swift-sdk-2026-10-12.md)。指南:[写一个 Swift 插件](../docs/guide/swift-plugin.md)(`rutis-host new <名字> --lang swift` 创建项目)。 ```swift // Package.swift: .package(url: "https://github.com/arcships/rutis", from: "0.14.0") @@ -45,7 +45,7 @@ let weather = Rutis.define(Plugin( Rutis.serve(weather) ``` -- `swift build` 生成二进制;`<二进制> --rutis-manifest` 打印它提供的插件。rutis-loader 用 `SwiftResolver` / `SwiftRuntimes` 运行它(行名 `swift:<插件>`)。 +- `swift build` 生成二进制;`<二进制> --rutis-manifest` 打印它提供的插件。`rutis-host` 按 `runtimes.swift` 运行它(行名 `swift:<插件>`),Rust 宿主用 rutis-loader 的 `SwiftResolver` / `SwiftRuntimes`。 - 插件代码不传任何上下文参数:调用链随 Task 传递。每个进来的调用一个 Task;同步调用挂起 Task,不占线程。 - 值:SDK 自己的编码器与解码器,由 `Codable` 驱动(不用 `JSONEncoder`)。`Date`、`Data`、`Decimal`、`UUID`、`Set` 和键不是 `String` 的字典按 rutis 的带标记的值跨进程;`Rutis.DateTime`、`Rutis.CalendarDate`、`Rutis.TimeOfDay`、`Rutis.BigInt` 是 Swift 没有的类型。`@RutisService` 类型的实例与闭包按引用。 - 错误带着名字(类型名)、信息、`code` 与 `data`(同名存储属性)跨进程;收到的错误是 `RutisError`,按 `name` 匹配。 diff --git a/swift/rutis/Sources/Rutis/API/Serve.swift b/swift/rutis/Sources/Rutis/API/Serve.swift index 899628c5..49bd8524 100644 --- a/swift/rutis/Sources/Rutis/API/Serve.swift +++ b/swift/rutis/Sources/Rutis/API/Serve.swift @@ -36,6 +36,7 @@ extension Rutis { return } let (channel, project) = try parse(args) + Responsibility.claim() if channel.hasPrefix("listen:") || channel.hasPrefix("dial:") || channel.hasPrefix("ws:") || channel.hasPrefix("wss:") { throw RutisError(name: "Error", message: "remote channels (listen:, dial:) are not in this version of the Swift SDK: \(channel)") } diff --git a/swift/rutis/Sources/Rutis/Session/Responsibility.swift b/swift/rutis/Sources/Rutis/Session/Responsibility.swift new file mode 100644 index 00000000..f2bef62c --- /dev/null +++ b/swift/rutis/Sources/Rutis/Session/Responsibility.swift @@ -0,0 +1,66 @@ +// The plugin binary answers for its own access to what macOS protects +// (calendars, contacts, automation: TCC), whoever started it (design §8): +// the prompt names the binary and its Info.plist's usage descriptions, and +// what the user grants applies to the binary alone, not to the host or the +// terminal that started it. +// +// macOS attributes a process's requests to its "responsible" process, +// which a child inherits from its parent. A process cannot give that up +// itself, but it can run its own image again in place (posix_spawn with +// POSIX_SPAWN_SETEXEC: the same pid, the same descriptors, channel +// included), disclaiming the parent's responsibility as it does. The two +// functions are libsystem's (since macOS 10.14), not in its headers: looked +// up at run time, and when one is missing nothing changes. +#if os(macOS) +import Darwin +import Foundation + +enum Responsibility { + private typealias ResponsibleFor = @convention(c) (pid_t) -> pid_t + private typealias SetDisclaim = @convention(c) (UnsafeMutablePointer, Int32) -> Int32 + + /// Set in the image run again, so that it never runs itself a second + /// time. + static let variable = "RUTIS_RESPONSIBLE" + + private static func symbol(_ name: String, as _: T.Type) -> T? { + let all = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + return dlsym(all, name).map { unsafeBitCast($0, to: T.self) } + } + + /// The process responsible for this one, when the system says. + static func responsible() -> pid_t? { + symbol("responsibility_get_pid_responsible_for_pid", as: ResponsibleFor.self).map { $0(getpid()) } + } + + /// Unless this process is responsible for itself, run this binary again + /// in its place, responsible for itself; returns when it cannot. + static func claim() { + if ProcessInfo.processInfo.environment[variable] != nil { + unsetenv(variable) + return + } + guard let responsible = responsible(), responsible != getpid(), + let disclaim = symbol("responsibility_spawnattrs_setdisclaim", as: SetDisclaim.self) + else { return } + var attributes: posix_spawnattr_t? + guard posix_spawnattr_init(&attributes) == 0 else { return } + defer { posix_spawnattr_destroy(&attributes) } + guard posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETEXEC)) == 0, + disclaim(&attributes, 1) == 0 + else { return } + var path = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(path.count) + guard _NSGetExecutablePath(&path, &size) == 0 else { return } + setenv(variable, "1", 1) + let arguments = CommandLine.unsafeArgv + // Returns only when it failed: this image goes on as it was. + _ = posix_spawn(nil, path, nil, &attributes, arguments, environ) + unsetenv(variable) + } +} +#else +enum Responsibility { + static func claim() {} +} +#endif diff --git a/swift/rutis/Sources/RutisFixturePlugins/Multilang.swift b/swift/rutis/Sources/RutisFixturePlugins/Multilang.swift index f8ab5741..3db20404 100644 --- a/swift/rutis/Sources/RutisFixturePlugins/Multilang.swift +++ b/swift/rutis/Sources/RutisFixturePlugins/Multilang.swift @@ -129,11 +129,20 @@ public final class Ping: @unchecked Sendable { public func echo(_ text: String) -> String { "\(from) pong \(text)" } public func later(_ text: String) async -> String { "\(from) later \(text)" } public func crash() { exit(17) } + + /// This process and the one macOS holds responsible for its access to + /// protected data (TCC): the runtime answers for itself. + public func responsible() -> [Int] { + typealias ResponsibleFor = @convention(c) (pid_t) -> pid_t + let all = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + guard let symbol = dlsym(all, "responsibility_get_pid_responsible_for_pid") else { return [Int(getpid()), -1] } + return [Int(getpid()), Int(unsafeBitCast(symbol, to: ResponsibleFor.self)(getpid()))] + } } public let pingPlugin = Rutis.define(Plugin( name: "ping", - provides: ["ping": .of(Ping.self, sync: ["echo", "crash"])], + provides: ["ping": .of(Ping.self, sync: ["echo", "crash", "responsible"])], apply: { ctx in let binary = URL(fileURLWithPath: CommandLine.arguments[0]).lastPathComponent ctx.provide("ping", Ping(from: binary)) diff --git a/tests/e2e/src/residue.rs b/tests/e2e/src/residue.rs index c5616e31..d9451ad8 100644 --- a/tests/e2e/src/residue.rs +++ b/tests/e2e/src/residue.rs @@ -632,13 +632,19 @@ fn sockets(dir: &Path, residue: &mut Residue) { /// What is left in the hosts' temporary directory. Tools keep some files /// there on purpose, across runs; they are not residue: tsx its compile /// cache (`tsx-`), Node its own (`node-compile-cache`, which tsx turns -/// on), uv its locks (`uv-.lock`, in install scenarios). +/// on), uv its locks (`uv-.lock`, in install scenarios), the Swift +/// toolchain (`swift build` in a Swift project, run by its tests and by +/// `dev`) its build's directories (`TemporaryDirectory.`, +/// `swift-generated-sources`) and SwiftPM its locks (`_.lock`). fn temporary(tmp: &Path, residue: &mut Residue) { for entry in std::fs::read_dir(tmp).into_iter().flatten().flatten() { let name = entry.file_name().to_string_lossy().into_owned(); let kept = name.starts_with("tsx-") || name == "node-compile-cache" - || (name.starts_with("uv-") && name.ends_with(".lock")); + || (name.starts_with("uv-") && name.ends_with(".lock")) + || name.starts_with("TemporaryDirectory.") + || name == "swift-generated-sources" + || (name.starts_with('_') && name.ends_with(".lock")); if !kept { residue .found diff --git a/tests/e2e/tests/dev_loop.rs b/tests/e2e/tests/dev_loop.rs index 706e7097..e65eee65 100644 --- a/tests/e2e/tests/dev_loop.rs +++ b/tests/e2e/tests/dev_loop.rs @@ -10,7 +10,8 @@ //! from a registry: that is S9, #193). On Windows (#232) each host is in a //! Job Object, which the residue checks read, and Ctrl-C is Ctrl-Break to //! the host's console process group ([`Host::ctrl_c`]); Bun is not declared -//! for Windows (Bun design §10), so its test is ignored there. +//! for Windows (Bun design §10), so its test is ignored there; Swift runs +//! on macOS only (Swift SDK design §13). use std::path::{Path, PathBuf}; @@ -23,6 +24,7 @@ enum Kind { Python, Bun, Go, + Swift, } impl Kind { @@ -32,6 +34,7 @@ impl Kind { Kind::Python => "python", Kind::Bun => "bun", Kind::Go => "go", + Kind::Swift => "swift", } } @@ -41,6 +44,7 @@ impl Kind { Kind::Node | Kind::Bun => "src/index.ts", Kind::Python => "src/demo/__init__.py", Kind::Go => "plugin.go", + Kind::Swift => "Sources/DemoPlugin/Plugin.swift", } } @@ -52,6 +56,7 @@ impl Kind { Kind::Node | Kind::Bun => ts_version(n), Kind::Python => python_version(n), Kind::Go => go_version(n), + Kind::Swift => swift_version(n), } } @@ -61,7 +66,7 @@ impl Kind { lines[4] = match self { Kind::Node | Kind::Bun => "const const = 1", Kind::Python => "def broken(:", - Kind::Go => "func broken( {", + Kind::Go | Kind::Swift => "func broken( {", } .to_owned(); lines.join("\n") + "\n" @@ -74,15 +79,21 @@ impl Kind { Kind::Node => (Lang::Node, ""), // The src layout: the Python runtime's project is src/. Kind::Python => (Lang::Python, "src"), - Kind::Bun | Kind::Go => (Lang::Python, "dev"), + Kind::Bun | Kind::Go | Kind::Swift => (Lang::Python, "dev"), } } + /// A compiled language: an edit rebuilds the binary and restarts its + /// runtime instead of reloading the rows. + fn compiled(self) -> bool { + matches!(self, Kind::Go | Kind::Swift) + } + /// What `rutis.dev.json` adds for the probe. fn runtimes(self) -> Value { match self { Kind::Node | Kind::Python => Value::Null, - Kind::Bun | Kind::Go => json!({ "py": { "project": "dev" } }), + Kind::Bun | Kind::Go | Kind::Swift => json!({ "py": { "project": "dev" } }), } } } @@ -194,6 +205,46 @@ var Plugin = rutis.Define(rutis.Plugin[Config]{{ ) } +/// The template's Plugin.swift: its lines go to stderr, as Go's. +fn swift_version(n: u32) -> String { + format!( + r#"// The demo rutis plugin. +import Foundation +import Rutis + +@RutisConfig +public struct Config: Decodable {{ + /// how to greet + public var greeting: String? +}} + +@RutisService +public final class Greeter: @unchecked Sendable {{ + let greeting: String + + init(greeting: String) {{ self.greeting = greeting }} + + public func hello(_ name: String) -> String {{ "\(greeting), \(name)! (v{n})" }} +}} + +func say(_ line: String) {{ + FileHandle.standardError.write(Data((line + "\n").utf8)) +}} + +public let plugin = Rutis.define(Plugin( + name: "demo", + inject: [], + config: Config.self, + apply: {{ ctx, config in + say("demo v{n} applied \(CommandLine.arguments[0])") + ctx.provide("greeter", Greeter(greeting: config.greeting ?? "Hello")) + ctx.effect {{ say("demo v{n} cleanup") }} + }} +)) +"# + ) +} + /// The Python on PATH: `python3`, `python` on Windows. fn python() -> &'static str { if cfg!(windows) { @@ -279,6 +330,22 @@ fn prepare(s: &Scenario, kind: Kind) { ) }); } + Kind::Swift => { + // A path dependency's package is named after its directory: + // this checkout as `rutis`. + s.link("rutis", &repo()); + s.edit("demo/Package.swift", |manifest| { + let start = manifest + .find(".package(url: \"https://github.com/arcships/rutis\"") + .expect("the rutis dependency"); + let end = start + manifest[start..].find(')').unwrap() + 1; + format!( + "{}.package(path: \"../rutis\"){}", + &manifest[..start], + &manifest[end..] + ) + }); + } } } @@ -308,6 +375,7 @@ fn template_tests(s: &Scenario, kind: Kind) -> Host { ) } Kind::Go => s.program("demo", "go", ["test", "./..."], GO_ENV), + Kind::Swift => s.program("demo", "swift", ["test"], &[]), } } @@ -325,6 +393,9 @@ fn edited(kind: Kind, host: &mut Host, probe: &Probe) { Kind::Go => { host.expect("go-demo: rebuilt and restarted"); } + Kind::Swift => { + host.expect("swift-demo: rebuilt and restarted"); + } _ => { host.expect("demo: reloaded"); host.expect("probe: reloaded"); @@ -336,14 +407,14 @@ fn edited(kind: Kind, host: &mut Host, probe: &Probe) { /// After a broken edit: the error line, which names the source file. fn failed(kind: Kind, host: &mut Host) -> String { let (what, prefix) = match kind { - Kind::Go => ( + Kind::Go | Kind::Swift => ( "the failed build", "demo: the build failed; the last build keeps running", ), _ => ("the failed reload", "demo: cannot reload: "), }; let line = host.wait_for(what, |line| line.starts_with(prefix)); - if kind != Kind::Go { + if !kind.compiled() { // Every row reloads; the probe's does, and it starts again. host.expect("probe: reloaded"); // `wait_for` takes lines in any order: the probe's start comes later. @@ -425,7 +496,7 @@ fn dev_loop(kind: Kind) { if !located(&error) { dev.wait_for(&format!("the error's location ({file}, line 5)"), located); } - if kind != Kind::Go { + if !kind.compiled() { probe.started(&mut dev); } assert_eq!(greet(&probe, &mut dev), json!("Hello, Ada! (v2)")); @@ -440,14 +511,18 @@ fn dev_loop(kind: Kind) { edited(kind, &mut dev, &probe); dev.expect("demo v3 applied "); assert_eq!(greet(&probe, &mut dev), json!("Hello, Ada! (v3)")); - if kind == Kind::Go { + if kind.compiled() { // Only the build that runs is kept. - let builds: Vec<_> = std::fs::read_dir(s.path("demo/.rutis/go")) - .expect(".rutis/go") + let builds = format!("demo/.rutis/{}", kind.lang()); + let builds: Vec<_> = std::fs::read_dir(s.path(&builds)) + .expect(&builds) .flatten() .map(|entry| entry.file_name()) .collect(); assert_eq!(builds.len(), 1, "builds left: {builds:?}"); + // Only the plugin's runtime restarted: the probe's Python runtime + // ran on, never down. + assert_eq!(dev.count("runtime py"), 0, "the Python runtime restarted"); } // 8. Ctrl-C: v3 is cleaned up once, the probe stops, exit code 0. @@ -490,3 +565,15 @@ fn the_bun_plugin_development_loop() { fn the_go_plugin_development_loop() { dev_loop(Kind::Go); } + +/// risk: A2, A3, A4, A7, B2 (Swift: a rebuild and a restart of its runtime +/// only, as Go's) +/// guarantee: host/stop-on-signal, host/dev-reload, host/dev-failed-reload-keeps-old, host/new, host/check +#[test] +#[cfg_attr( + not(target_os = "macos"), + ignore = "Swift plugin binaries are macOS only (Swift SDK design §13)" +)] +fn the_swift_plugin_development_loop() { + dev_loop(Kind::Swift); +} From 71f4b486be90ced4fc15a35cb89332c110665683 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Mon, 19 Oct 2026 17:59:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(swift):=20Command=20Line=20Tools=20?= =?UTF-8?q?=E4=B8=8A=20e2e=20=E5=8F=AA=E6=9E=84=E5=BB=BA=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=EF=BC=88=E6=B2=A1=E6=9C=89=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=EF=BC=89=EF=BC=9B=E6=8C=87=E5=8D=97=E5=86=99?= =?UTF-8?q?=E6=98=8E=20swift=20test=20=E9=9C=80=E8=A6=81=20Xcode=EF=BC=88#?= =?UTF-8?q?387=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/swift-plugin.en.md | 2 +- docs/guide/swift-plugin.md | 2 +- tests/e2e/tests/dev_loop.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/guide/swift-plugin.en.md b/docs/guide/swift-plugin.en.md index eb87bc1b..2ffd3141 100644 --- a/docs/guide/swift-plugin.en.md +++ b/docs/guide/swift-plugin.en.md @@ -2,7 +2,7 @@ [中文](swift-plugin.md) -From creating a project to publishing it, and to a host using it. Needs macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later, or its Command Line Tools); the SDK itself is written in the Swift 5 language mode, and so is the project `new` makes. +From creating a project to publishing it, and to a host using it. Needs macOS 14 or later and Xcode 16 or later (the Command Line Tools alone build and run plugins, but `swift test` needs Xcode's test frameworks); the SDK itself is written in the Swift 5 language mode, and so is the project `new` makes. Swift plugins follow the same conventions as TypeScript, Python and Go plugins (see the [plugin API](plugin-api.en.md)); as with Go, **a Swift plugin is compiled into a binary, and a binary is one runtime process in the host**. A binary holds one or several plugins; a host can run many binaries at once, starting each only when a row uses it. Swift is the language for what macOS offers its own programs: calendars and reminders (EventKit), contacts, accessibility and Apple events automation, screen capture, Core ML. diff --git a/docs/guide/swift-plugin.md b/docs/guide/swift-plugin.md index 329e5898..a3db7479 100644 --- a/docs/guide/swift-plugin.md +++ b/docs/guide/swift-plugin.md @@ -2,7 +2,7 @@ [English](swift-plugin.en.md) -从创建项目到发布,再到被宿主使用。需要 macOS 14 或更高,以及 Swift 6 工具链(Xcode 16 或更高,或它的 Command Line Tools);SDK 本身按 Swift 5 语言模式编写,`new` 生成的项目也是。 +从创建项目到发布,再到被宿主使用。需要 macOS 14 或更高,以及 Xcode 16 或更高(只有 Command Line Tools 也能构建和运行插件,但 `swift test` 要用 Xcode 的测试框架);SDK 本身按 Swift 5 语言模式编写,`new` 生成的项目也是。 Swift 插件和 TypeScript、Python、Go 插件的写法与规则相同(见 [插件 API](plugin-api.md));和 Go 一样,**Swift 插件编译成二进制,一个二进制就是宿主里的一个运行时进程**。一个二进制可以带一个或几个插件;宿主可以同时运行很多个二进制,只在有行用到时才启动它。Swift 用来接入 macOS 给本机程序的能力:日历与提醒(EventKit)、通讯录、辅助功能与 Apple 事件自动化、屏幕录制、Core ML。 diff --git a/tests/e2e/tests/dev_loop.rs b/tests/e2e/tests/dev_loop.rs index e65eee65..7b70f457 100644 --- a/tests/e2e/tests/dev_loop.rs +++ b/tests/e2e/tests/dev_loop.rs @@ -375,10 +375,26 @@ fn template_tests(s: &Scenario, kind: Kind) -> Host { ) } Kind::Go => s.program("demo", "go", ["test", "./..."], GO_ENV), + // The Command Line Tools (the self-hosted runners' toolchain) have + // neither XCTest nor Swift Testing's macro plugin: there the test + // target cannot build, and only the plugin is built. + Kind::Swift if command_line_tools() => { + s.program("demo", "swift", ["build", "--target", "DemoPlugin"], &[]) + } Kind::Swift => s.program("demo", "swift", ["test"], &[]), } } +/// Whether the active developer directory is the Command Line Tools, not +/// Xcode. +fn command_line_tools() -> bool { + std::process::Command::new("xcode-select") + .arg("-p") + .output() + .map(|output| String::from_utf8_lossy(&output.stdout).contains("CommandLineTools")) + .unwrap_or(false) +} + fn greet(probe: &Probe, host: &mut Host) -> Value { probe .call(host, "greeter", "hello", json!(["Ada"]))