diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82616a9..2b50e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,30 @@ jobs: run: cargo check --workspace --exclude floatile-clock-wasm --all-targets --locked - name: Clippy run: cargo clippy --workspace --exclude floatile-clock-wasm --all-targets --locked -- -D warnings + - name: Rust SDK API docs + if: runner.os == 'Linux' + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc -p floatile-sdk -p floatile-sdk-macros -p floatile-ui-schema --no-deps --locked + - name: Set up TypeScript SDK toolchain + if: runner.os == 'Linux' + uses: actions/setup-node@v7 + with: + node-version: '26.8.0' + - name: Set up pnpm + if: runner.os == 'Linux' + uses: pnpm/action-setup@v5 + with: + version: '11.3.0' + - name: TypeScript SDK generated contract + if: runner.os == 'Linux' + run: | + pnpm install --dir sdk/typescript --frozen-lockfile + cargo run -p floatile-cli --locked -- schema ui target/floatile-ui-registry.json + node sdk/typescript/scripts/generate-components.mjs target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check + pnpm --dir sdk/typescript typecheck + pnpm --dir sdk/typescript test + pnpm --dir sdk/typescript build - name: Build WASI guest (hermetic integration tests) # 集成测试读取 target/wasm32-wasip2/debug 下的组件;先构建保证干净 # checkout 上测试不依赖执行顺序或测试内并发构建。 diff --git a/.github/workflows/typescript-runtime-candidate.yml b/.github/workflows/typescript-runtime-candidate.yml new file mode 100644 index 0000000..2089463 --- /dev/null +++ b/.github/workflows/typescript-runtime-candidate.yml @@ -0,0 +1,98 @@ +name: TypeScript runtime candidate + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/typescript-runtime-candidate.yml" + - "conformance/**" + - "crates/floatile-runtime/**" + - "sdk/typescript/**" + - "spikes/typescript-runtime/**" + - "wit/**" + +permissions: + contents: read + +concurrency: + group: typescript-runtime-${{ github.ref }} + cancel-in-progress: true + +env: + COMPONENTIZE_QJS_SHA: 7788644697ed08a841ad0910d4e99772c6fe7132 + CARGO_INCREMENTAL: 0 + +jobs: + quickjs: + name: QuickJS candidate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout Floatile + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: floatile + persist-credentials: false + + - name: Checkout fixed componentize-qjs candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: andreiltd/componentize-qjs + ref: refs/pull/76/head + path: componentize-qjs + persist-credentials: false + + - name: Verify candidate identity + shell: bash + working-directory: componentize-qjs + run: test "$(git rev-parse HEAD)" = "$COMPONENTIZE_QJS_SHA" + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: wasm32-wasip2 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26.8.0 + + - name: Set up pnpm + uses: pnpm/action-setup@v5 + with: + version: 11.3.0 + + - name: Build fixed componentize-qjs CLI + working-directory: componentize-qjs + run: cargo build --release -p componentize-qjs-cli --features opt-size + + - name: Install locked TypeScript toolchains + shell: bash + working-directory: floatile + run: | + pnpm install --dir sdk/typescript --frozen-lockfile + pnpm install --dir spikes/typescript-runtime --frozen-lockfile + + - name: Build Floatile TypeScript component + shell: bash + working-directory: floatile + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + candidate="$(cd ../componentize-qjs && pwd -W)/target/release/componentize-qjs.exe" + else + candidate="$(cd ../componentize-qjs && pwd)/target/release/componentize-qjs" + fi + FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 \ + FLOATILE_COMPONENTIZE_QJS_BIN="$candidate" \ + pnpm --dir spikes/typescript-runtime build:quickjs + + - name: Run Linux behavior and security vectors + if: runner.os == 'Linux' + working-directory: floatile + env: + FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER: "1" + run: pnpm --dir spikes/typescript-runtime test:quickjs diff --git a/.gitignore b/.gitignore index b5eb749..d0193d4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # 本机 cargo 配置(sccache 路径、wrapper 等本机专属,不入库) .cargo/ node_modules/ +sdk/typescript/dist/ spikes/typescript-runtime/generated/ **/*.rs.bk *.log @@ -31,4 +32,3 @@ Desktop.ini *~ *.swp *.swo - diff --git a/conformance/sdk-lifecycle-v1.json b/conformance/sdk-lifecycle-v1.json index ead4fb8..d688363 100644 --- a/conformance/sdk-lifecycle-v1.json +++ b/conformance/sdk-lifecycle-v1.json @@ -2,6 +2,13 @@ "schemaVersion": 1, "engineApiVersion": "1.2.0", "vectors": [ + { + "id": "start-invalid-input", + "callback": "start", + "guestError": "invalid-input", + "message": "conformance start invalid input", + "expectedHostOutcome": "rejected" + }, { "id": "start-rejected", "callback": "start", @@ -9,10 +16,24 @@ "message": "conformance start rejection", "expectedHostOutcome": "rejected" }, + { + "id": "start-internal", + "callback": "start", + "guestError": "internal", + "message": null, + "expectedHostOutcome": "rejected" + }, { "id": "event-invalid-input", "callback": "event", "guestError": "invalid-input", + "message": "conformance event invalid input", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-rejected", + "callback": "event", + "guestError": "rejected", "message": "conformance event rejection", "expectedHostOutcome": "rejected" }, diff --git a/conformance/sdk-security-v1.json b/conformance/sdk-security-v1.json new file mode 100644 index 0000000..ea77184 --- /dev/null +++ b/conformance/sdk-security-v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "engineApiVersion": "1.2.0", + "vectors": [ + { + "id": "broker-deny", + "mode": "deny", + "trigger": "start", + "expectedHostOutcome": "completed", + "hostSurvives": true + }, + { + "id": "invalid-state-patch", + "mode": "bad-patch", + "trigger": "start", + "expectedHostOutcome": "completed-without-state-update", + "hostSurvives": true + }, + { + "id": "fuel-exhaustion", + "mode": "loop", + "trigger": "event", + "expectedHostOutcome": "failed", + "hostSurvives": true + }, + { + "id": "wall-clock-timeout", + "mode": "loop", + "trigger": "event", + "expectedHostOutcome": "timed-out", + "hostSurvives": true + }, + { + "id": "memory-limit", + "mode": "alloc", + "trigger": "start", + "expectedHostOutcome": "failed", + "hostSurvives": true + } + ] +} diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs index a1d266e..f6d8e32 100644 --- a/crates/floatile-cli/src/conformance.rs +++ b/crates/floatile-cli/src/conformance.rs @@ -11,6 +11,10 @@ pub const LIFECYCLE_SUITE_JSON: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../conformance/sdk-lifecycle-v1.json" )); +pub const SECURITY_SUITE_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" +)); #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -30,6 +34,24 @@ pub struct LifecycleVector { pub expected_host_outcome: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySuite { + pub schema_version: u32, + pub engine_api_version: String, + pub vectors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityVector { + pub id: String, + pub mode: String, + pub trigger: String, + pub expected_host_outcome: String, + pub host_survives: bool, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConformanceReport { @@ -37,6 +59,7 @@ pub struct ConformanceReport { pub status: &'static str, pub suite: &'static str, pub contract: LifecycleSuite, + pub security_contract: SecuritySuite, pub warnings: Vec, } @@ -64,15 +87,57 @@ pub fn lifecycle_report() -> Result { let contract: LifecycleSuite = serde_json::from_str(LIFECYCLE_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; validate(&contract)?; + let security_contract: SecuritySuite = + serde_json::from_str(SECURITY_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; + validate_security(&security_contract)?; + if security_contract.engine_api_version != contract.engine_api_version { + return Err(ConformanceError::InvalidVector( + "conformance contracts 的 engineApiVersion 不一致".to_owned(), + )); + } Ok(ConformanceReport { schema_version: OUTPUT_SCHEMA_VERSION, status: "ok", suite: "sdk-lifecycle-v1", contract, + security_contract, warnings: Vec::new(), }) } +fn validate_security(suite: &SecuritySuite) -> Result<(), ConformanceError> { + if suite.schema_version != 1 { + return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); + } + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + if !ids.insert(vector.id.as_str()) { + return Err(ConformanceError::InvalidVector( + "security vector id 重复".to_owned(), + )); + } + if !matches!(vector.trigger.as_str(), "start" | "event") { + return Err(ConformanceError::InvalidVector( + "security trigger 不受支持".to_owned(), + )); + } + if !matches!( + vector.expected_host_outcome.as_str(), + "completed" | "completed-without-state-update" | "failed" | "timed-out" + ) { + return Err(ConformanceError::InvalidVector( + "security expectedHostOutcome 不受支持".to_owned(), + )); + } + if !vector.host_survives { + return Err(ConformanceError::InvalidVector( + "security vector 必须要求 hostSurvives".to_owned(), + )); + } + } + Ok(()) +} + fn validate(suite: &LifecycleSuite) -> Result<(), ConformanceError> { if suite.schema_version != 1 { return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); @@ -121,7 +186,9 @@ mod tests { assert_eq!(report.suite, "sdk-lifecycle-v1"); assert_eq!(report.contract.schema_version, 1); assert_eq!(report.contract.engine_api_version, "1.2.0"); - assert_eq!(report.contract.vectors.len(), 3); + assert_eq!(report.contract.vectors.len(), 6); + assert_eq!(report.security_contract.schema_version, 1); + assert_eq!(report.security_contract.vectors.len(), 5); Ok(()) } } diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index 0c08715..e00250e 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -10,6 +10,7 @@ pub mod dev; pub mod inspect; pub mod install; pub mod instance; +pub mod migrate; pub mod output; pub mod package; pub mod preview; @@ -31,11 +32,12 @@ pub use instance::{ InstanceCommandError, InstanceView, configure_instance, create_instance, delete_instance, get_instance, list_instances, rollback_instance, set_instance_desired_state, }; +pub use migrate::{MigrationError, MigrationReport, migrate_project}; pub use output::{CommandErrorReport, CommandWarning, OUTPUT_SCHEMA_VERSION}; pub use package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; pub use preview::{PreviewError, PreviewReport, PreviewSession, preview_project}; pub use project::{ - ProjectConfig, ProjectError, generate_manifest, generate_template, parse_floatile_toml, + ProjectConfig, ProjectError, SdkCfg, generate_manifest, generate_template, parse_floatile_toml, }; pub use run::{RunError, RunReport, default_run_paths, run_project}; pub use test::{ diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index 5e2e21f..66d6429 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -5,8 +5,8 @@ use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ - CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, - preview, project, run, test, trust, + CommandErrorReport, build, check, conformance, dev, inspect, install, instance, migrate, + package, preview, project, run, test, trust, }; use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId, PermissionChangeKind}; @@ -14,7 +14,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -32,6 +32,7 @@ fn main() -> ExitCode { "preview" => cmd_preview(&args[2..]), "run" => cmd_run(&args[2..]), "schema" => cmd_schema(&args[2..]), + "migrate" => cmd_migrate(&args[2..]), "conformance" => cmd_conformance(&args[2..]), other => { eprintln!("未知命令: {other}"); @@ -40,6 +41,41 @@ fn main() -> ExitCode { } } +fn cmd_migrate(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + let write = args.iter().any(|argument| argument == "--write"); + let positionals = match author_positionals(args, &[], &["--write"], 1) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FMIGRATE_ARGUMENT", &detail, json, true), + }; + let project_dir = positionals + .first() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + match migrate::migrate_project(&project_dir, write) { + Ok(report) => { + if json { + println!("{}", serialize_json(&report)); + } else { + println!( + "migrate: PASS mode={} changes={} changed={}", + report.mode, + report.changes.len(), + report.changed + ); + for change in report.changes { + println!( + " code={} file={} {}", + change.code, change.file, change.description + ); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_trust(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let positionals = match author_positionals(args, &["--db"], &[], 3) { @@ -111,6 +147,15 @@ fn cmd_conformance(args: &[String]) -> ExitCode { vector.expected_host_outcome ); } + for vector in report.security_contract.vectors { + println!( + " {} trigger={} outcome={} host-survives={}", + vector.id, + vector.trigger, + vector.expected_host_outcome, + vector.host_survives + ); + } } ExitCode::SUCCESS } @@ -624,18 +669,33 @@ fn print_instance_usage() { ); } -/// `floatile schema `:由单一源生成并输出 manifest.json 的 -/// 独立 JSON Schema 产物,供外部工具/编辑器校验 manifest,避免手写平行 schema。 +/// 由单一源生成 manifest schema 或 UI component registry,供 SDK/codegen 使用。 fn cmd_schema(args: &[String]) -> ExitCode { - let Some(path) = args.first().map(PathBuf::from) else { - eprintln!("用法: floatile schema "); - return ExitCode::from(2); + let (kind, path) = match args { + [path] => ("manifest", PathBuf::from(path)), + [kind, path] if matches!(kind.as_str(), "manifest" | "ui") => { + (kind.as_str(), PathBuf::from(path)) + } + _ => { + eprintln!("用法: floatile schema [manifest|ui] "); + return ExitCode::from(2); + } + }; + let text = if kind == "ui" { + serde_json::to_string_pretty(&floatile_ui_schema::component_registry_contract()) + } else { + serde_json::to_string_pretty(&floatile_core::manifest_json_schema()) + }; + let text = match text { + Ok(text) => text, + Err(_) => { + eprintln!("schema 序列化失败"); + return ExitCode::FAILURE; + } }; - let schema = floatile_core::manifest_json_schema(); - let text = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_owned()); match std::fs::write(&path, text) { Ok(()) => { - println!("已写出 manifest JSON Schema 到 {}", path.display()); + println!("已写出 {kind} schema 到 {}", path.display()); ExitCode::SUCCESS } Err(e) => { diff --git a/crates/floatile-cli/src/migrate.rs b/crates/floatile-cli/src/migrate.rs new file mode 100644 index 0000000..ca004f1 --- /dev/null +++ b/crates/floatile-cli/src/migrate.rs @@ -0,0 +1,190 @@ +//! Versioned author-project migrations. The default mode is read-only. + +use std::io::Write; +use std::path::Path; + +use serde::Serialize; +use thiserror::Error; + +use crate::OUTPUT_SCHEMA_VERSION; +use crate::project::parse_floatile_toml; + +const MAX_PROJECT_CONFIG_BYTES: u64 = 256 * 1024; +const SDK_SECTION: &str = "\n[sdk]\nlanguage = \"rust\"\nversion = \"0.1.0\"\n"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationChange { + pub code: &'static str, + pub file: &'static str, + pub description: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationReport { + pub schema_version: u32, + pub status: &'static str, + pub mode: &'static str, + pub changed: bool, + pub target_sdk_version: &'static str, + pub changes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Error)] +pub enum MigrationError { + #[error("项目配置缺失")] + MissingConfig, + #[error("项目配置不是普通文件")] + UnsafeConfig, + #[error("项目配置超过迁移预算")] + ConfigTooLarge, + #[error("项目配置无效")] + InvalidConfig, + #[error("无法读取项目配置")] + Read, + #[error("无法写入项目配置")] + Write, +} + +impl MigrationError { + pub fn code(&self) -> &'static str { + match self { + Self::MissingConfig => "FMIGRATE_CONFIG_MISSING", + Self::UnsafeConfig => "FMIGRATE_CONFIG_UNSAFE", + Self::ConfigTooLarge => "FMIGRATE_CONFIG_SIZE", + Self::InvalidConfig => "FMIGRATE_CONFIG_INVALID", + Self::Read => "FMIGRATE_READ", + Self::Write => "FMIGRATE_WRITE", + } + } +} + +pub fn migrate_project(project_dir: &Path, write: bool) -> Result { + let path = project_dir.join("floatile.toml"); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + MigrationError::MissingConfig + } else { + MigrationError::Read + } + })?; + if !metadata.file_type().is_file() { + return Err(MigrationError::UnsafeConfig); + } + if metadata.len() > MAX_PROJECT_CONFIG_BYTES { + return Err(MigrationError::ConfigTooLarge); + } + + let source = std::fs::read_to_string(&path).map_err(|_| MigrationError::Read)?; + parse_floatile_toml(&source).map_err(|_| MigrationError::InvalidConfig)?; + let document: toml::Value = + toml::from_str(&source).map_err(|_| MigrationError::InvalidConfig)?; + let needs_sdk = document.get("sdk").is_none(); + let changes = if needs_sdk { + vec![MigrationChange { + code: "FMIGRATE_SDK_EXPLICIT", + file: "floatile.toml", + description: "显式记录 Rust SDK language/version,为双语言构建保留单一项目事实", + }] + } else { + Vec::new() + }; + + if write && needs_sdk { + let mut migrated = source; + if !migrated.ends_with('\n') { + migrated.push('\n'); + } + migrated.push_str(SDK_SECTION.trim_start_matches('\n')); + replace_recoverably(&path, migrated.as_bytes())?; + } + + Ok(MigrationReport { + schema_version: OUTPUT_SCHEMA_VERSION, + status: "ok", + mode: if write { "write" } else { "dry-run" }, + changed: write && needs_sdk, + target_sdk_version: env!("CARGO_PKG_VERSION"), + changes, + warnings: Vec::new(), + }) +} + +fn replace_recoverably(path: &Path, bytes: &[u8]) -> Result<(), MigrationError> { + let parent = path.parent().ok_or(MigrationError::Write)?; + let suffix = std::process::id(); + let staging = parent.join(format!(".floatile.toml.migrate-{suffix}")); + let backup = parent.join(format!(".floatile.toml.backup-{suffix}")); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|_| MigrationError::Write)?; + if file.write_all(bytes).is_err() || file.sync_all().is_err() { + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + drop(file); + + if std::fs::rename(path, &backup).is_err() { + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + if std::fs::rename(&staging, path).is_err() { + let _ = std::fs::rename(&backup, path); + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + std::fs::remove_file(&backup).map_err(|_| MigrationError::Write) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn legacy_project() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "floatile-migrate-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("floatile.toml"), + "[plugin]\nid = \"dev.floatile.legacy\"\nname = \"Legacy\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + dir + } + + #[test] + fn dry_run_reports_without_writing() { + let dir = legacy_project(); + let before = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + let report = migrate_project(&dir, false).unwrap(); + assert_eq!(report.mode, "dry-run"); + assert!(!report.changed); + assert_eq!(report.changes.len(), 1); + assert_eq!( + std::fs::read_to_string(dir.join("floatile.toml")).unwrap(), + before + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn write_adds_explicit_sdk_once() { + let dir = legacy_project(); + let report = migrate_project(&dir, true).unwrap(); + assert!(report.changed); + let migrated = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + assert!(migrated.contains("[sdk]")); + assert_eq!(migrate_project(&dir, true).unwrap().changes.len(), 0); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/floatile-cli/src/project.rs b/crates/floatile-cli/src/project.rs index f5e59fb..98634be 100644 --- a/crates/floatile-cli/src/project.rs +++ b/crates/floatile-cli/src/project.rs @@ -22,6 +22,8 @@ use serde::Deserialize; pub struct ProjectConfig { pub plugin: PluginCfg, #[serde(default)] + pub sdk: SdkCfg, + #[serde(default)] pub widget: WidgetCfg, #[serde(default)] pub permissions: BTreeMap, @@ -31,6 +33,31 @@ pub struct ProjectConfig { pub publisher: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct SdkCfg { + #[serde(default = "default_sdk_language")] + pub language: String, + #[serde(default = "default_sdk_version")] + pub version: String, +} + +impl Default for SdkCfg { + fn default() -> Self { + Self { + language: default_sdk_language(), + version: default_sdk_version(), + } + } +} + +fn default_sdk_language() -> String { + "rust".to_owned() +} + +fn default_sdk_version() -> String { + env!("CARGO_PKG_VERSION").to_owned() +} + #[derive(Debug, Clone, Deserialize)] pub struct PluginCfg { pub id: String, @@ -208,8 +235,8 @@ pub fn generate_manifest(config: &ProjectConfig) -> Result std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "floatile-migrate-cmd-{}-{label}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("floatile.toml"), + "[plugin]\nid = \"dev.floatile.legacy\"\nname = \"Legacy\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + dir +} + +#[test] +fn migrate_defaults_to_read_only_dry_run() { + let dir = legacy_project("dry-run"); + let before = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "migrate", + dir.to_str().unwrap(), + "--json", + "--no-interactive", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["mode"], "dry-run"); + assert_eq!(report["changed"], false); + assert_eq!(report["changes"][0]["code"], "FMIGRATE_SDK_EXPLICIT"); + assert_eq!( + std::fs::read_to_string(dir.join("floatile.toml")).unwrap(), + before + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn migrate_write_is_explicit_and_idempotent() { + let dir = legacy_project("write"); + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "migrate", + dir.to_str().unwrap(), + "--write", + "--json", + "--no-interactive", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["mode"], "write"); + assert_eq!(report["changed"], true); + let migrated = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + assert_eq!(migrated.matches("[sdk]").count(), 1); + + let second = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["migrate", dir.to_str().unwrap(), "--write", "--json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&second.stdout).unwrap(); + assert_eq!(report["changed"], false); + assert_eq!(report["changes"], serde_json::json!([])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn migrate_rejects_unknown_options() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["migrate", "--apply", "--json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let report: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(report["code"], "FMIGRATE_ARGUMENT"); +} diff --git a/crates/floatile-cli/tests/schema_cmd.rs b/crates/floatile-cli/tests/schema_cmd.rs new file mode 100644 index 0000000..f9ffa21 --- /dev/null +++ b/crates/floatile-cli/tests/schema_cmd.rs @@ -0,0 +1,45 @@ +#![allow(clippy::unwrap_used)] + +use std::process::Command; + +#[test] +fn ui_schema_command_emits_the_single_source_registry() { + let output = + std::env::temp_dir().join(format!("floatile-ui-registry-{}.json", std::process::id())); + let _ = std::fs::remove_file(&output); + let result = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["schema", "ui", output.to_str().unwrap()]) + .output() + .unwrap(); + assert!(result.status.success(), "stderr: {:?}", result.stderr); + let contract: serde_json::Value = + serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert_eq!(contract["schemaVersion"], 1); + assert_eq!(contract["uiApiVersion"], floatile_ui_schema::UI_API_VERSION); + assert!( + contract["components"] + .as_array() + .unwrap() + .iter() + .any(|component| component["name"] == "Text") + ); + let _ = std::fs::remove_file(output); +} + +#[test] +fn manifest_schema_legacy_argument_remains_supported() { + let output = std::env::temp_dir().join(format!( + "floatile-manifest-schema-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&output); + let result = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["schema", output.to_str().unwrap()]) + .output() + .unwrap(); + assert!(result.status.success(), "stderr: {:?}", result.stderr); + let schema: serde_json::Value = + serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert_eq!(schema["$schema"], "http://json-schema.org/draft-07/schema#"); + let _ = std::fs::remove_file(output); +} diff --git a/crates/floatile-platform/src/metrics.rs b/crates/floatile-platform/src/metrics.rs index cff3b09..9ad8293 100644 --- a/crates/floatile-platform/src/metrics.rs +++ b/crates/floatile-platform/src/metrics.rs @@ -92,21 +92,52 @@ fn macos_process_metrics() -> Result { #[cfg(target_os = "linux")] fn linux_process_metrics() -> Result { - const SCHEDSTAT: &str = "/proc/self/schedstat"; + const TASKS: &str = "/proc/self/task"; + const TASK_SCHEDSTAT: &str = "/proc/self/task/*/schedstat"; const STATUS: &str = "/proc/self/status"; - let schedstat = std::fs::read_to_string(SCHEDSTAT).map_err(|source| MetricsError::Read { - source_name: SCHEDSTAT, + let tasks = std::fs::read_dir(TASKS).map_err(|source| MetricsError::Read { + source_name: TASKS, source, })?; + let mut cpu_nanoseconds = 0u64; + let mut sampled_threads = 0usize; + for task in tasks { + let task = task.map_err(|source| MetricsError::Read { + source_name: TASKS, + source, + })?; + let path = task.path().join("schedstat"); + let schedstat = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => { + return Err(MetricsError::Read { + source_name: TASK_SCHEDSTAT, + source, + }); + } + }; + let thread_cpu = parse_cpu_nanoseconds(&schedstat).ok_or(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + })?; + cpu_nanoseconds = cpu_nanoseconds + .checked_add(thread_cpu) + .ok_or(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + })?; + sampled_threads += 1; + } + if sampled_threads == 0 { + return Err(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + }); + } let status = std::fs::read_to_string(STATUS).map_err(|source| MetricsError::Read { source_name: STATUS, source, })?; - let cpu_nanoseconds = parse_cpu_nanoseconds(&schedstat).ok_or(MetricsError::Invalid { - source_name: SCHEDSTAT, - })?; let rss_bytes = parse_rss_bytes(&status).ok_or(MetricsError::Invalid { source_name: STATUS, })?; @@ -134,6 +165,8 @@ fn parse_rss_bytes(status: &str) -> Option { #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Barrier}; #[test] fn parses_schedstat_cpu_nanoseconds() { @@ -141,6 +174,37 @@ mod tests { assert_eq!(parse_cpu_nanoseconds("invalid 789 10\n"), None); } + #[test] + #[allow(clippy::expect_used)] + fn process_cpu_includes_worker_threads() { + let barrier = Arc::new(Barrier::new(3)); + let stop = Arc::new(AtomicBool::new(false)); + let workers: Vec<_> = (0..2) + .map(|_| { + let barrier = Arc::clone(&barrier); + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + barrier.wait(); + while !stop.load(Ordering::Relaxed) { + std::hint::spin_loop(); + } + }) + }) + .collect(); + barrier.wait(); + let before = process_metrics().expect("process metrics before worker load"); + std::thread::sleep(Duration::from_millis(100)); + let after = process_metrics().expect("process metrics after worker load"); + stop.store(true, Ordering::Relaxed); + for worker in workers { + worker.join().expect("worker should stop"); + } + assert!( + after.cpu_time > before.cpu_time, + "worker CPU must be included in process metrics" + ); + } + #[test] fn parses_status_rss_kib_as_bytes() { let status = "Name:\tfloatile-shell\nVmRSS:\t 219240 kB\nThreads:\t2\n"; diff --git a/crates/floatile-runtime/tests/security.rs b/crates/floatile-runtime/tests/security.rs index e28e801..db61b20 100644 --- a/crates/floatile-runtime/tests/security.rs +++ b/crates/floatile-runtime/tests/security.rs @@ -45,6 +45,24 @@ struct LifecycleVector { expected_host_outcome: String, } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + mode: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + fn lifecycle_suite() -> LifecycleSuite { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -53,6 +71,31 @@ fn lifecycle_suite() -> LifecycleSuite { .expect("lifecycle conformance vectors must parse") } +fn security_suite() -> &'static SecuritySuite { + static SUITE: std::sync::OnceLock = std::sync::OnceLock::new(); + SUITE.get_or_init(|| { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("security conformance vectors must parse") + }) +} + +fn security_vector(id: &str) -> &'static SecurityVector { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + suite + .vectors + .iter() + .find(|vector| vector.id == id) + .unwrap_or_else(|| panic!("missing security vector {id}")) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -164,11 +207,20 @@ fn mem_audit_persistence() -> (Arc>, floatile_services::AuditListen /// 未声明能力调用 → Broker 拒绝 + 审计落 SQLite + 宿主存活。 #[tokio::test(flavor = "multi_thread")] async fn denied_capability_persists_audit_and_host_survives() { + let vector = security_vector("broker-deny"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "completed"); + assert!(vector.host_survives); let (store, listener) = mem_audit_persistence(); let manager = WidgetManager::new() .unwrap() .with_audit_listener(Some(listener)); - let handle = spawn_evil(&manager, 1, evil_grants_none(1), json!({"mode": "deny"})); + let handle = spawn_evil( + &manager, + 1, + evil_grants_none(1), + json!({"mode": vector.mode}), + ); // 所有未声明能力调用被 Broker 拒绝并被插件吞掉(不中断实例)。 handle.start().await.expect("deny 模式 start 应成功"); @@ -269,12 +321,19 @@ async fn storage_operation_round_trips_through_guest_contract() { /// 超限/类型错误/未知字段 State Patch → 被拒,宿主存活,状态不部分改写。 #[tokio::test(flavor = "multi_thread")] async fn bad_state_patch_rejected_and_host_survives() { + let vector = security_vector("invalid-state-patch"); + assert_eq!(vector.trigger, "start"); + assert_eq!( + vector.expected_host_outcome, + "completed-without-state-update" + ); + assert!(vector.host_survives); let manager = WidgetManager::new().unwrap(); let mut handle = spawn_evil( &manager, 2, evil_grants_none(2), - json!({"mode": "bad-patch"}), + json!({"mode": vector.mode}), ); // 三类恶意 patch 全部被宿主拒绝;error 被插件吞掉,实例不中断。 handle.start().await.expect("bad-patch 模式 start 应成功"); @@ -301,9 +360,18 @@ async fn bad_state_patch_rejected_and_host_survives() { /// 无限 CPU 循环 → fuel 耗尽 trap 终止实例;宿主与其他实例存活。 #[tokio::test(flavor = "multi_thread")] async fn infinite_loop_is_fuel_trapped_and_host_survives() { + let vector = security_vector("fuel-exhaustion"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); // 事件触发无限循环;实例共享单一 fuel 预算,循环会耗尽余量并 trap。 let manager = WidgetManager::new().unwrap(); - let handle = spawn_evil(&manager, 3, evil_grants_none(3), json!({"mode": "loop"})); + let handle = spawn_evil( + &manager, + 3, + evil_grants_none(3), + json!({"mode": vector.mode}), + ); handle .start() .await @@ -331,11 +399,20 @@ async fn infinite_loop_is_fuel_trapped_and_host_survives() { /// 超大 fuel 不能绕过墙钟预算;epoch deadline 必须及时终止本实例。 #[tokio::test(flavor = "multi_thread")] async fn infinite_loop_is_wall_clock_timed_out_and_peer_survives() { + let vector = security_vector("wall-clock-timeout"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "timed-out"); + assert!(vector.host_survives); let manager = WidgetManager::new() .unwrap() .with_fuel_per_call(u64::MAX) .with_call_timeout(Duration::from_millis(25)); - let handle = spawn_evil(&manager, 11, evil_grants_none(11), json!({"mode": "loop"})); + let handle = spawn_evil( + &manager, + 11, + evil_grants_none(11), + json!({"mode": vector.mode}), + ); handle.start().await.expect("loop 模式 start 应成功"); let peer = spawn_evil(&manager, 12, evil_grants_none(12), json!({"mode": "deny"})); peer.start().await.expect("同一引擎的其他实例应启动"); @@ -372,8 +449,17 @@ async fn infinite_loop_is_wall_clock_timed_out_and_peer_survives() { /// 超限线性内存申请 → StoreLimits 终止实例;宿主存活。 #[tokio::test(flavor = "multi_thread")] async fn memory_alloc_over_limit_traps_instance_but_host_survives() { + let vector = security_vector("memory-limit"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let manager = WidgetManager::new().unwrap(); // 默认每实例 16 MiB 上限 - let handle = spawn_evil(&manager, 4, evil_grants_none(4), json!({"mode": "alloc"})); + let handle = spawn_evil( + &manager, + 4, + evil_grants_none(4), + json!({"mode": vector.mode}), + ); // start 里申请 64 MiB > 16 MiB 上限 → 实例 trap,start 失败。 let start_result = handle.start().await; assert!( diff --git a/crates/floatile-runtime/tests/typescript_clock_spike.rs b/crates/floatile-runtime/tests/typescript_clock_spike.rs index 598ee26..5eb7420 100644 --- a/crates/floatile-runtime/tests/typescript_clock_spike.rs +++ b/crates/floatile-runtime/tests/typescript_clock_spike.rs @@ -12,13 +12,73 @@ use floatile_core::capability::{ narrow_instance, }; use floatile_core::types::{InstanceId, PluginId}; +use floatile_plugin_api::exports::floatile::widget::widget_contract::{UiEvent, WidgetEvent}; use floatile_runtime::harness::WidgetHarness; -use floatile_runtime::{WidgetConfig, WidgetManager}; +use floatile_runtime::{InstanceError, WidgetConfig, WidgetManager}; use serde_json::json; #[path = "support/clock_behavior.rs"] mod clock_behavior; +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleSuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleVector { + id: String, + callback: String, + message: Option, + expected_host_outcome: String, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + mode: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + +fn lifecycle_suite() -> LifecycleSuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("lifecycle conformance vectors must parse") +} + +fn security_suite() -> SecuritySuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("security conformance vectors must parse") +} + +fn security_vector<'a>(suite: &'a SecuritySuite, id: &str) -> &'a SecurityVector { + suite + .vectors + .iter() + .find(|vector| vector.id == id) + .unwrap_or_else(|| panic!("missing security vector {id}")) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -137,6 +197,10 @@ async fn measure_instances(count: u64) { .expect("4 秒内应收到每个实例的首个 tick"); } let first_tick = started.elapsed(); + let stable_started = Instant::now(); + let stable_before = floatile_platform::process_metrics().ok(); + tokio::time::sleep(Duration::from_secs(3)).await; + let stable_elapsed = stable_started.elapsed(); let measured = floatile_platform::process_metrics().ok(); while let Some(handle) = handles.pop() { handle.shutdown().await.expect("shutdown TypeScript clock"); @@ -145,13 +209,21 @@ async fn measure_instances(count: u64) { let rss_delta_bytes = baseline .zip(measured) .map(|(before, after)| after.rss_bytes.saturating_sub(before.rss_bytes)); + let stable_cpu_delta = stable_before + .zip(measured) + .map(|(before, after)| after.cpu_time.saturating_sub(before.cpu_time)); + let stable_cpu_percent = + stable_cpu_delta.map(|cpu| 100.0 * cpu.as_secs_f64() / stable_elapsed.as_secs_f64()); println!( - "{{\"instances\":{count},\"componentBytes\":{},\"startupMs\":{},\"allFirstTicksMs\":{},\"rssBytes\":{},\"rssDeltaBytes\":{}}}", + "{{\"instances\":{count},\"componentBytes\":{},\"startupMs\":{},\"allFirstTicksMs\":{},\"rssBytes\":{},\"rssDeltaBytes\":{},\"stableSampleMs\":{},\"stableCpuMs\":{},\"stableCpuPercentOneCore\":{}}}", wasm.len(), startup.as_millis(), first_tick.as_millis(), rss_bytes.map_or_else(|| "null".into(), |value| value.to_string()), - rss_delta_bytes.map_or_else(|| "null".into(), |value| value.to_string()) + rss_delta_bytes.map_or_else(|| "null".into(), |value| value.to_string()), + stable_elapsed.as_millis(), + stable_cpu_delta.map_or_else(|| "null".into(), |value| value.as_millis().to_string()), + stable_cpu_percent.map_or_else(|| "null".into(), |value| format!("{value:.3}")) ); } @@ -164,34 +236,202 @@ async fn typescript_clock_matches_reference_behavior() { #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_denied_timer_is_brokered_and_instance_survives() { + let suite = security_suite(); + let vector = security_vector(&suite, "broker-deny"); + assert_eq!(vector.mode, "deny"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "completed"); + assert!(vector.host_survives); clock_behavior::assert_timer_denied(harness(false)).await; } #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] -async fn typescript_clock_timeout_isolated_from_peer() { +async fn typescript_clock_invalid_patch_matches_shared_vector() { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + let vector = security_vector(&suite, "invalid-state-patch"); + assert_eq!(vector.trigger, "start"); + assert_eq!( + vector.expected_host_outcome, + "completed-without-state-update" + ); + assert!(vector.host_survives); + + let manager = WidgetManager::new().expect("创建共享 engine"); + let mut handle = manager + .spawn(widget_config( + InstanceId(211), + component_bytes(), + &serde_json::json!({"mode": vector.mode}).to_string(), + )) + .expect("bad patch fixture spawn"); + handle + .start() + .await + .expect("bad patch is a recoverable host rejection"); + let update = tokio::time::timeout(Duration::from_millis(300), handle.ui_updates().recv()).await; + assert!( + update.is_err(), + "rejected patch must not publish State: {update:?}" + ); + handle + .shutdown() + .await + .expect("bad patch instance should stop"); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_lifecycle_errors_match_shared_vectors() { + let wasm = component_bytes(); + let manager = WidgetManager::new().expect("创建共享 engine"); + let suite = lifecycle_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + + for (index, vector) in suite.vectors.iter().enumerate() { + let instance = InstanceId(201 + u64::try_from(index).expect("bounded vector index")); + let handle = manager + .spawn(widget_config( + instance, + wasm.clone(), + &serde_json::json!({"mode": format!("conformance-{}", vector.id)}).to_string(), + )) + .expect("TypeScript conformance fixture spawn"); + let result = if vector.callback == "start" { + handle.start().await + } else { + handle.start().await.expect("event vector should start"); + handle + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await + }; + assert_eq!(vector.expected_host_outcome, "rejected"); + assert!( + matches!(result, Err(InstanceError::Rejected(ref message)) + if vector.message.as_ref().is_none_or(|expected| message.contains(expected))), + "TypeScript conformance vector {} should remain a guest rejection, got {result:?}", + vector.id + ); + } + + let survivor = manager + .spawn(widget_config(InstanceId(209), wasm, "{}")) + .expect("survivor spawn"); + survivor + .start() + .await + .expect("host survives TypeScript rejection"); + survivor.shutdown().await.expect("survivor should stop"); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_fuel_exhaustion_matches_shared_vector() { + let suite = security_suite(); + let vector = security_vector(&suite, "fuel-exhaustion"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let wasm = component_bytes(); let manager = WidgetManager::new().expect("创建共享 engine"); let looping = manager .spawn(widget_config( InstanceId(101), wasm.clone(), - r#"{"mode":"loop"}"#, + &serde_json::json!({"mode": vector.mode}).to_string(), )) .expect("loop fixture spawn"); let peer = manager .spawn(widget_config(InstanceId(102), wasm, "{}")) .expect("peer spawn"); - assert!(looping.start().await.is_err(), "无限循环必须被预算终止"); + looping + .start() + .await + .expect("event loop fixture should start"); + let result = looping + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await; + assert!( + matches!(result, Err(InstanceError::Failed(_))), + "无限循环必须耗尽 fuel: {result:?}" + ); peer.start().await.expect("peer start 应成功"); peer.shutdown().await.expect("peer shutdown 应成功"); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_wall_timeout_matches_shared_vector() { + let suite = security_suite(); + let vector = security_vector(&suite, "wall-clock-timeout"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "timed-out"); + assert!(vector.host_survives); + + let wasm = component_bytes(); + let manager = WidgetManager::new() + .expect("创建共享 engine") + .with_fuel_per_call(u64::MAX) + .with_call_timeout(Duration::from_millis(25)); + let looping = manager + .spawn(widget_config( + InstanceId(221), + wasm.clone(), + &serde_json::json!({"mode": vector.mode}).to_string(), + )) + .expect("wall timeout fixture spawn"); + let peer = manager + .spawn(widget_config(InstanceId(222), wasm, "{}")) + .expect("peer spawn"); + looping + .start() + .await + .expect("event loop fixture should start"); + peer.start().await.expect("peer start should succeed"); + let result = tokio::time::timeout( + Duration::from_secs(1), + looping.handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })), + ) + .await + .expect("epoch deadline should beat the host fallback"); + assert!( + matches!(result, Err(InstanceError::Failed(_))), + "墙钟预算必须终止实例: {result:?}" + ); + peer.shutdown() + .await + .expect("peer should survive wall timeout"); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_memory_limit_isolated_from_peer() { + let suite = security_suite(); + let vector = security_vector(&suite, "memory-limit"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let constrained = harness_builder(true) + .config_json(serde_json::json!({"mode": vector.mode}).to_string()) .max_memory(1024 * 1024) .build() .expect("低内存 fixture spawn"); diff --git a/crates/floatile-sdk/tests/conformance.rs b/crates/floatile-sdk/tests/conformance.rs index a762cc7..b1275ca 100644 --- a/crates/floatile-sdk/tests/conformance.rs +++ b/crates/floatile-sdk/tests/conformance.rs @@ -23,6 +23,23 @@ struct Vector { expected_host_outcome: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + fn suite() -> Suite { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -50,6 +67,14 @@ fn widget_error(vector: &Vector) -> WidgetError { } } +fn security_suite() -> SecuritySuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("SDK security conformance vectors must be valid JSON") +} + #[test] fn lifecycle_vectors_match_the_generated_wit_contract() { let suite = suite(); @@ -74,3 +99,25 @@ fn lifecycle_vectors_match_the_generated_wit_contract() { "vectors must cover every WIT widget-error variant" ); } + +#[test] +fn security_vectors_are_language_independent_and_host_survival_is_mandatory() { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!(suite.engine_api_version, ENGINE_API_VERSION); + assert_eq!(suite.vectors.len(), 5); + + let mut ids = BTreeSet::new(); + for vector in suite.vectors { + assert!(ids.insert(vector.id), "duplicate security vector id"); + assert!(matches!(vector.trigger.as_str(), "start" | "event")); + assert!(matches!( + vector.expected_host_outcome.as_str(), + "completed" | "completed-without-state-update" | "failed" | "timed-out" + )); + assert!( + vector.host_survives, + "security vectors must preserve host survival" + ); + } +} diff --git a/crates/floatile-ui-schema/src/lib.rs b/crates/floatile-ui-schema/src/lib.rs index df173b9..b731277 100644 --- a/crates/floatile-ui-schema/src/lib.rs +++ b/crates/floatile-ui-schema/src/lib.rs @@ -19,7 +19,10 @@ pub mod validate; pub use error::UiSchemaError; pub use ir::{Binding, Component, EmittedEvent, EventSchema, PropValue, StateSchema, UiDocument}; pub use patch::{MAX_PATCH_BYTES, MAX_STATE_BYTES, MAX_UPDATE_RATE_PER_SEC, merge_patch}; -pub use registry::{ChildrenPolicy, ComponentKind, ComponentSpec, JsonType, PropSchema}; +pub use registry::{ + ChildrenPolicy, ComponentKind, ComponentSpec, JsonType, PropSchema, RegistryContract, + contract as component_registry_contract, +}; pub use schema::{JsonSchema, validate_value}; pub use validate::validate_document; diff --git a/crates/floatile-ui-schema/src/registry.rs b/crates/floatile-ui-schema/src/registry.rs index e206160..783dc83 100644 --- a/crates/floatile-ui-schema/src/registry.rs +++ b/crates/floatile-ui-schema/src/registry.rs @@ -7,11 +7,14 @@ use std::sync::LazyLock; +use serde::Serialize; + use crate::error::UiSchemaError; use crate::ir::PropValue; /// 允许的字面量 JSON 类型。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum JsonType { String, Boolean, @@ -51,7 +54,8 @@ impl JsonType { } /// 单个 prop 的 schema。 -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] pub struct PropSchema { pub name: &'static str, pub types: &'static [JsonType], @@ -63,7 +67,8 @@ pub struct PropSchema { } /// 子组件策略。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum ChildrenPolicy { Forbidden, One, @@ -71,7 +76,8 @@ pub enum ChildrenPolicy { } /// 组件类别。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum ComponentKind { Element, If, @@ -79,7 +85,8 @@ pub enum ComponentKind { } /// 组件规格。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ComponentSpec { pub name: &'static str, /// 首次引入该组件的 UI API 1.x minor。 @@ -167,6 +174,23 @@ pub fn components() -> &'static [ComponentSpec] { ®ISTRY } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryContract<'a> { + pub schema_version: u32, + pub ui_api_version: &'static str, + pub components: &'a [ComponentSpec], +} + +/// Machine-readable single-source contract consumed by SDK code generators. +pub fn contract() -> RegistryContract<'static> { + RegistryContract { + schema_version: 1, + ui_api_version: crate::UI_API_VERSION, + components: components(), + } +} + fn build_registry() -> Vec { let mut specs = Vec::new(); element(&mut specs, "Row", ChildrenPolicy::Many, &[], &[]); @@ -468,7 +492,12 @@ fn element_since( events: &'static [&'static str], ) { let mut all = Vec::with_capacity(COMMON_ELEMENT_PROPS.len() + props.len()); - all.extend_from_slice(COMMON_ELEMENT_PROPS); + all.extend( + COMMON_ELEMENT_PROPS + .iter() + .filter(|common| !props.iter().any(|specific| specific.name == common.name)) + .copied(), + ); all.extend_from_slice(props); out.push(ComponentSpec { name, @@ -628,4 +657,27 @@ mod tests { assert!(column.find_prop("padding").is_some()); assert!(column.find_prop("gap").is_some()); } + + #[test] + fn machine_contract_serializes_the_registry_source() { + let value = serde_json::to_value(contract()).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["uiApiVersion"], crate::UI_API_VERSION); + let specs = value["components"].as_array().unwrap(); + assert_eq!(specs.len(), components().len()); + assert!(specs.iter().any(|spec| spec["name"] == "Text")); + for spec in components() { + let unique = spec + .props + .iter() + .map(|prop| prop.name) + .collect::>(); + assert_eq!( + unique.len(), + spec.props.len(), + "duplicate props in {}", + spec.name + ); + } + } } diff --git a/docs/README.md b/docs/README.md index a243972..f81180b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ | 分发与信任 | `architecture/decisions/0005-package-signing-and-trust.md`、`plugin-sdk/manifest-v1.md` | 签名格式、publisher trust、撤销、更新或回滚策略变化 | | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | -| 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | +| 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md`、`plugin-sdk/migration-guide.md` | Rust/TypeScript API、组件、CLI、诊断、迁移、测试或 Agent 契约变化 | | SDK 一致性向量 | `plugin-sdk/conformance-kit.md`、`../conformance/` | SDK lifecycle、错误、行为或跨语言 conformance 格式变化 | | 插件契约 | `plugin-sdk/manifest-v1.md`、`plugin-sdk/wit-api-v1.md`、`plugin-sdk/ui-ir-v1.md` | manifest/WIT/UI IR/包格式变化 | | 平台事实 | `platform-matrix/platform-matrix.md` | 获得新的实测证据 | diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 79946e8..fee3e96 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -20,7 +20,7 @@ Rust 参考时钟的 `build_ftui` 生成,以免在 UI schema codegen 落地前 - `@bytecodealliance/jco` 1.31.0 + ComponentizeJS 0.22.0 + TypeScript 7.0.2。 - 支持普通 TypeScript/JavaScript;`jco guest-types` 从仓库 WIT 生成严格 guest types。 -- `jco componentize --disable all` 可移除全部 WASI feature,组件只导入七个 +- `jco componentize --disable all` 可移除全部 WASI feature,组件只导入当前 world 的九个 `floatile:widget/*` host interface。 - 官方 AOT/Weval 模式也纳入对照。 @@ -111,8 +111,45 @@ advisory 所称的 4.2.2,spike lock 以本地禁用桩移除 AOT 链;不得 - release 单实例 startup 273 ms、首 tick 1,277 ms、RSS 增量 135,327,744 B; - release 10 实例 startup 2,401 ms、全部首 tick 3,402 ms、RSS 增量 662,474,752 B。 +2026-08-28 又从 PR #76 的固定 head +`7788644697ed08a841ad0910d4e99772c6fe7132` 构建 `componentize-qjs-cli --features opt-size`,并将 +spike 同步到当前 `floatile:widget@1.2.0`/`uiApiVersion = 1.6.0`。生成组件精确白名单为九个 +`floatile:widget/*@1.2.0` host interface,零 WASI import,并重新通过四个行为/隔离测试和包预算: + +- component 1,053,110 B,package 440,106 B; +- release 单实例 startup 227 ms、首 tick 1,229 ms、RSS 增量 90,034,176 B; +- release 10 实例 startup 1,293 ms、全部首 tick 2,308 ms、RSS 增量 313,122,816 B。 + +随后将 QuickJS 参考时钟改为消费 private `sdk/typescript` 的 +`defineWidget`/`createWidgetContract`,不再手写 resource export glue。组件 1,081,238 B、package +453,512 B,六条共享 lifecycle error 向量均由真实 SDK → adapter → Wasmtime 链路识别为 guest +rejection;共享安全向量的 Broker deny、invalid patch、fuel、墙钟预算、StoreLimits 与 peer 存活也由 +同一 TypeScript component 实测通过。 + +修正 Linux 进程 CPU 采样为汇总 `/proc/self/task/*/schedstat` 后,同机 release 冷运行与 3 秒稳定窗口为: + +- 单实例 startup 154 ms、首 tick 1,167 ms、RSS 增量 88,014,848 B;稳定态 CPU 2 ms / 3,005 ms, + 即单核 0.077%; +- 10 实例 startup 1,365 ms、全部首 tick 2,377 ms、RSS 增量 324,501,504 B;稳定态 CPU + 8 ms / 3,002 ms,即单核 0.284%。 + +许可预审区分两类闭包:嵌入 component 的 `componentize-qjs-runtime` normal dependency tree 中 +`componentize-qjs` 为 Apache-2.0、`rquickjs`/`rquickjs-core`/`rquickjs-sys` 为 MIT,其余已识别项属于 +仓库现有 allowlist;来自 wasm-tools git SHA 的 `wit-dylib-ffi` 继承 workspace 的 +`Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT`。但完整 CLI 构建期依赖还经 `ureq` 引入 +CDLA-Permissive-2.0 的 `webpki-roots`,按 Floatile 当前 `deny.toml` 会拒绝。构建工具与最终 component +的 NOTICE 生成也尚未自动化,因此许可门仍未通过,不新增例外。 + +这些数据来自同一 Linux 测试机的一次串行冷运行;3 秒窗口是 Linux 稳定态 CPU 候选证据,不替代 +长时压力或 Windows/macOS 资源证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 + +GitHub Actions [run 33155960160](https://github.com/powercess/floatile/actions/runs/33155960160) +又从同一固定 SHA 在 Ubuntu、macOS 和 Windows 构建候选 CLI,并在三个系统生成及校验 Floatile +TypeScript component;Ubuntu 额外执行上述完整行为和安全向量。该矩阵证明候选的三宿主构建可复现, +不把 Linux 性能数字外推为另外两个系统的资源结论。 + 这些数据显著优于本 ADR 的 StarlingMonkey 对照,但尚不改变 no-go 决策:修复未进入上游发布版, -Windows/macOS 构建、稳定态 CPU、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime +Windows/macOS 稳定态资源证据、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime 初始化函数 `init`,虽不增加 host capability,仍需在生产契约门中决定是否允许或由 adapter 隐藏。 ## 后果与下一步 @@ -122,6 +159,6 @@ Windows/macOS 构建、稳定态 CPU、许可/NOTICE 仍未完成;QuickJS comp - 代价:P0 的 TypeScript clock 与公共 SDK 继续阻塞。 - 本 PR:已将 QuickJS 问题最小化为任意 method 参数的 receiver 错位,形成可直接提交上游的修复与 回归测试,并把 spike 改为 StarlingMonkey/QuickJS 共用同一宿主行为向量。 -- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 构建、稳定态 CPU、 +- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 稳定态资源证据、 QuickJS/生成 component 的许可与 NOTICE、额外 `init` export 契约处理;全部通过后新建后继 ADR, 再决定是否启动公共 TypeScript SDK。 diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 40389ec..59b67d2 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -11,7 +11,7 @@ Wayland 产品承诺、许可与分发方式。普通实现细节不需要 ADR |---|---|---| | [ADR-0001](0001-unified-plugin-ui.md) | Accepted | 插件使用统一 Floatile UI IR;Slint 仅为宿主实现,P0/MVP 不接受第三方 `.slint` | | [ADR-0002](0002-runtime-plugin-ui-rendering.md) | Implemented | 运行时第三方插件 UI 用 `slint-interpreter` 编译 renderer 生成的源码 | -| [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | 当前 StarlingMonkey 资源门失败、QuickJS 契约门失败;暂不发布 TypeScript SDK | +| [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | StarlingMonkey 资源门失败;QuickJS 未发布修复候选已过 Linux 契约门,但发布/三平台/许可门未完成 | | [ADR-0004](0004-brokered-async-operations.md) | Implemented | 长任务采用 Broker 化 Operation ID、元数据完成信号与 capability-specific typed result;v1.1 已接入首个 `storage:read` typed contract | | [ADR-0005](0005-package-signing-and-trust.md) | Accepted | detached DSSE/Ed25519 签名规范;宿主持有 publisher trust;权限授权与 anti-rollback 独立强制 | diff --git a/docs/architecture/init-plan.md b/docs/architecture/init-plan.md index 53b1056..ee0878a 100644 --- a/docs/architecture/init-plan.md +++ b/docs/architecture/init-plan.md @@ -125,8 +125,12 @@ - 先用 ADR 选择 TypeScript adapter/runtime;禁止公开非标准 TypeScript 子集或 Broker 外 ambient API。 - 与 Rust 共用 UI/component/capability/error/behavior vectors;实现 TSX 构建期 View 和同一 WIT world。 - 验收:Rust/TypeScript clocks 行为一致;单/10 实例 CPU/RSS/冷启动/包大小和三平台构建记录。 -- 当前结论:ADR-0003 已完成 Linux 候选 spike,但 StarlingMonkey 资源门失败、componentize-qjs 0.4.3 - 契约门失败,因此 **no-go**;公共 SDK/TSX/CLI 模板尚未开始,F11 不得标记完成。 +- 当前结论:ADR-0003 已完成候选 spike;StarlingMonkey 资源门失败,componentize-qjs 0.4.3 + 契约门失败。上游未发布修复候选已通过 Ubuntu/macOS/Windows 组件构建,并在 Linux 通过共享 + 契约/恶意输入/隔离/资源复测;private TypeScript SDK、Rust Registry 同源 UI codegen、WIT adapter + 和 ESM/declarations 包边界已完成。上游发布、许可/NOTICE 与额外 `init` export 仍未完成,因此生产 + runtime 保持 **no-go**,CLI TypeScript 模板与公共 SDK 不启用;F11 的仓库内双语言契约证据已具备, + 但产品发布门仍不得标记完成。 ### S6 — `.floatile` 包 + 安装 diff --git a/docs/architecture/technology-stack.md b/docs/architecture/technology-stack.md index d344b50..ef05317 100644 --- a/docs/architecture/technology-stack.md +++ b/docs/architecture/technology-stack.md @@ -13,7 +13,7 @@ | 平台 API | windows-sys 0.52.x + x11rb 0.13.x(`randr`、`shape`)+ objc2 0.6.x(`app-kit`/`foundation`)+ mach2 0.6.x | 平台句柄、Windows 窗口操作、X11 compositor/SHAPE/EWMH/RandR/热键探测,以及 macOS NSWindow/NSScreen/进程指标与 Carbon 全局热键;只允许 `floatile-platform` 直接依赖。 | | 插件 ABI | WIT + WASM Component Model,guest `wasm32-wasip2` | 版本化接口、无原生句柄;`wit/` 为唯一源。 | | 插件 runtime | Wasmtime 47 + wasmtime-wasi p2(S5b 已引入,空 WASI 上下文实现零 ambient) | Component Model、异步调用、fuel 与资源限制;引入时固定兼容版本组。 | -| TypeScript adapter | ADR-0003 no-go | StarlingMonkey 功能通过但资源门失败;componentize-qjs 0.4.3 在 `handle-event(variant)` trap。暂不发布 TypeScript SDK,后继候选仍须保持普通 TypeScript 语义、同一 WIT/Broker。 | +| TypeScript adapter | private SDK + ADR-0003 no-go runtime | TypeScript 作者契约、Rust Registry 同源 UI builder、WIT 1.2 adapter 与共享 conformance vectors 已实现;componentize-qjs 未发布修复候选已过三系统构建和 Linux 行为/安全/资源门,但上游发布、许可/NOTICE 与额外 `init` export 未决。暂不启用 CLI 模板或公共发布。 | | 异步 | Tokio(runtime/services 已引入,S5b) | 后台 I/O/runtime;Slint 主线程只跑事件循环。 | | 存储 | SQLite + rusqlite bundled | 单文件、事务、跨平台;migration 前向追加(v1 layout 表已落地) | | 序列化/错误 | serde、serde_json、thiserror;manifest JSON Schema 单源使用 schemars + jsonschema | 契约类型、结构化校验错误;类型→schema 同源生成独立 manifest.schema.json 产物 | diff --git a/docs/architecture/workspace-and-crates.md b/docs/architecture/workspace-and-crates.md index cd3a28a..a2acb64 100644 --- a/docs/architecture/workspace-and-crates.md +++ b/docs/architecture/workspace-and-crates.md @@ -218,7 +218,7 @@ schemas/ manifest-v1.schema.json floatile-ui-v1.schema.json sdk/ - typescript/ # @floatile/sdk;引入前需 runtime ADR + typescript/ # @floatile/sdk 构建期 UI/codegen;private,runtime 仍需后继 ADR plugins/ clock-wasm/ # Rust 参考插件 clock-typescript/ # TypeScript adapter 通过后加入 diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md index 235eef6..04e066a 100644 --- a/docs/plugin-sdk/conformance-kit.md +++ b/docs/plugin-sdk/conformance-kit.md @@ -1,6 +1,6 @@ # SDK Conformance Kit -> 状态:Implemented(Rust SDK 与 host runtime 自动化验证;TypeScript 尚未实现) +> 状态:Implemented(Rust SDK、TypeScript 作者层与 host runtime 自动化验证;TypeScript runtime 尚未实现) > 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 `conformance/` 保存语言无关、版本化的 JSON 向量。Rust SDK、未来 TypeScript SDK 与宿主 runtime @@ -18,7 +18,7 @@ - `message`:该 variant 携带的稳定测试 payload,`internal` 为 `null`; - `expectedHostOutcome`:宿主错误分类,不是语言专用异常名。 -当前向量覆盖 `invalid-input`、`rejected` 与 `internal` 全部 WIT guest error。runtime 使用真实 +当前向量覆盖 `start/event × invalid-input/rejected/internal` 的完整组合。runtime 使用真实 WASM Component 执行同一批向量,必须把它们分类为 guest `Rejected`,并证明随后仍可启动和停止同行 实例。trap、fuel、epoch timeout、内存、Broker deny、Operation 和 State Patch 的既有安全测试将在后续 PP-M7 切片逐步登记为同目录的版本化向量。 @@ -26,6 +26,17 @@ PP-M7 切片逐步登记为同目录的版本化向量。 新增语言 SDK 时,第一步必须解析这些文件并拒绝未知 `schemaVersion`、callback、error 或 outcome; 不能用“等价的本地测试”代替共享向量。 +private `@floatile/sdk` 作者层已直接消费 lifecycle 与 security JSON:六种 lifecycle error 映射到同一 +WIT variant,未知 JavaScript 异常只降为无 payload 的 `internal`,不会泄漏异常文本;五类 security +vector 保留为 adapter/runtime 的强制 host-survival 门。真实 TypeScript Component 执行仍未通过,不能 +把作者层解析测试等同于 runtime 证据。 + `floatile conformance --json --no-interactive` 会校验 CLI 内嵌的当前 suite 并输出稳定的 schema v1 报告;language adapter、CI 与 Agent 应消费该命令,而不是依赖仓库路径。`--deny-warnings` 与其他作者 命令语义一致;当前 suite 没有 warning。 + +## 安全与隔离向量 + +`sdk-security-v1.json` 固定 Broker 默认拒绝、非法 State Patch 原子拒绝、fuel 耗尽、墙钟超时和 +线性内存上限五类宿主结果。每条向量都要求同行实例或后继实例存活;语言 adapter 不得把 trap、超时 +或内存失败降级成业务拒绝,也不得用语言专用 mock 代替真实 Component/host 执行。 diff --git a/docs/plugin-sdk/manifest-v1.md b/docs/plugin-sdk/manifest-v1.md index 6789ac3..70761a2 100644 --- a/docs/plugin-sdk/manifest-v1.md +++ b/docs/plugin-sdk/manifest-v1.md @@ -107,6 +107,10 @@ id = "dev.floatile.clock" name = "World Clock" version = "0.1.0" +[sdk] +language = "rust" +version = "0.1.0" + [widget] default_size = [240, 120] min_size = [160, 80] @@ -117,6 +121,10 @@ max_per_minute = 60 max_active = 2 ``` +`sdk.language` 当前接受作者工具链登记值并写入 manifest `build.sdk`;它只用于构建诊断,不能改变 +权限或运行语义。旧项目缺少 `[sdk]` 时按 `rust@0.1.0` 兼容读取,`floatile migrate` 默认 dry-run +报告补齐动作,只有显式 `--write` 才修改作者配置。 + 不可从代码安全推导的字段必须显式声明。CLI 检出的 capability 只是候选:代码使用但未声明为 error;声明但未使用为 warning;CLI 不自动扩大权限。 diff --git a/docs/plugin-sdk/migration-guide.md b/docs/plugin-sdk/migration-guide.md new file mode 100644 index 0000000..c267261 --- /dev/null +++ b/docs/plugin-sdk/migration-guide.md @@ -0,0 +1,46 @@ +# Floatile SDK 迁移指南 + +> 状态:Implemented(Rust SDK 0.1 项目模型;TypeScript adapter 尚未实现) +> 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 + +本文记录作者可执行的 SDK、UI API、Engine API 与 manifest 迁移。迁移不得改变安装授权、绕过 +Permission Broker,或直接编辑生成的 `manifest.json`、`widget.ftui` 和 WIT bindings。 + +## 自动迁移契约 + +```text +floatile migrate --json --no-interactive +floatile migrate --write --json --no-interactive +``` + +默认命令是只读 dry-run,输出 `schemaVersion=1`、目标 SDK 版本及有序 change 列表。只有显式 +`--write` 才修改作者文件;重复执行必须返回空 change。CI 应先运行 dry-run,再由作者审查并决定是否 +写入。`--deny-warnings` 可与其他作者命令一致使用。 + +迁移输入视为不可信:`floatile.toml` 必须是小于 256 KiB 的普通 UTF-8 文件并通过当前 parser;缺失、 +符号链接、超限或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。写入采用同目录 staging 和 backup, +替换失败时恢复原文件,不留下半写 TOML。 + +## 0.1 隐式 Rust SDK → 显式 SDK + +早期项目没有 SDK 段,CLI 兼容解释为 `rust@0.1.0`。迁移后为: + +```toml +[sdk] +language = "rust" +version = "0.1.0" +``` + +该字段生成 manifest 的诊断性 `build.sdk/sdkVersion`,不能改变授权或 runtime 语义。显式 language +是后续 TypeScript 模板复用同一项目 schema 的前置条件,不代表 TypeScript runtime 已通过 ADR 门。 + +## 版本轴处理 + +- SDK major:必须使用对应迁移规则和 conformance suite;未知 major 拒绝自动写入。 +- Engine API:WIT 是唯一事实源;不得用源码改写绕过 host/guest major 不兼容。 +- UI API:组件和 schema 由 `floatile-ui-schema` 单源生成;major 迁移必须提供确定性源码建议。 +- manifest:作者迁移 `floatile.toml`,生成物在 `build/check` 时重新生成,禁止原地修补包。 +- 权限:迁移只能保留或收窄声明。新增/扩大权限必须由作者编辑并在安装升级时重新确认。 + +当前只有上述 0.1 项目模型迁移。未来每条规则必须同时增加 dry-run、write、幂等、失败恢复及 CLI JSON +测试,并在本文件记录来源版本、目标版本和不可自动处理项。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index d37fc98..bef7bb7 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -227,7 +227,7 @@ Detected capabilities: 退出码与诊断 code 稳定;日志文本不是自动化接口。 -当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance`;`test` 用 +当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance/migrate`;`test` 用 `floatile-runtime::harness` 对已构建包跑无头生命周期冒烟。`instance` 子命令提供单一 JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、`--store`、 `--config`/`--config-file`与 `--no-interactive`。`inspect` 在输出前复用完整包安全校验,提供 manifest、 @@ -269,7 +269,13 @@ State 更新数和 deny 审计数。当前 `advance_time` 使用真实 Tokio 短 Rust SDK 包内包含由根 `wit/floatile-widget.wit` 机械同步的发行快照,仓库测试要求二者逐字节一致; 干净目录测试从 `floatile-sdk`、`floatile-sdk-macros` 与 `floatile-ui-schema` 的独立 Cargo 包快照解析 模板依赖,不使用仓库内部 path。许可 ADR 通过前这些包只用于仓库内可发布性验证,不授权上传 registry。 -`migrate` 仍未实现,属于后续 SDK/API 稳定化工作,不在 PP-M4 Rust 作者闭环范围内。 +`migrate` 使用版本化 JSON 报告且默认只读 dry-run;当前第一条真实迁移将旧项目隐式的 Rust SDK +选择补成 `[sdk] language/version`,为 Rust/TypeScript 共用项目模型建立显式事实。只有 `--write` +修改文件,重复执行无变化;缺失、超限、非普通文件或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。 +版本轴与不可自动迁移边界见 [`migration-guide.md`](migration-guide.md)。 +TypeScript SDK 已建立 private 的构建期 UI 层:组件函数和 prop/binding 类型由 +`floatile schema ui` 的 registry contract 机械生成,并在 CI 中做 stale、strict typecheck 与 canonical +IR builder 测试。它尚未选择或打包 runtime,也未获准发布;生命周期 adapter 仍受第 11 节工具链门约束。 ## 8. 诊断格式 @@ -323,6 +329,8 @@ load/start/State 更新/shutdown + 宿主存活)并输出稳定 JSON。`advanc 每个模板包含简短 `AGENTS.md`,只说明项目文件、允许命令和不可编辑生成物。SDK 发布: - 组件、属性、事件、capability、错误、CLI 输出的版本化 JSON schema; +- `floatile schema ui ` 从 `floatile-ui-schema` registry 输出版本化组件/prop/event 契约, + TypeScript codegen 不维护平行组件列表; - 每个能力一个最小、可编译示例;不提供大而全 demo 作为唯一资料; - `floatile check --json --no-interactive` 的确定性输出; - `floatile preview --screenshot --ui-tree `; diff --git a/docs/plugin-sdk/ui-ir-v1.md b/docs/plugin-sdk/ui-ir-v1.md index 79e9a5d..84662f8 100644 --- a/docs/plugin-sdk/ui-ir-v1.md +++ b/docs/plugin-sdk/ui-ir-v1.md @@ -169,7 +169,9 @@ Extension: Canvas Path(通过 renderer spike 后再启用) 每个组件条目包含稳定名称、introduced minor、props schema、allowed children/slots、events、accessibility 要求和 renderer test vector。新增 optional prop/component 可以 bump minor;删除/改语义必须 bump major。 -组件 registry 是单一机器源。README、Rust/TypeScript API 或 Slint adapter 中的手写列表不是事实源。 +组件 registry 是单一机器源。`floatile schema ui ` 机械序列化同一 registry,供 +TypeScript codegen、编辑器和 Agent 消费;README、Rust/TypeScript API 或 Slint adapter 中的手写 +列表不是事实源。 ## 8. Style、theme 与 animation diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index 174d429..ebf0be6 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -173,7 +173,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M4 | Rust 作者闭环 | 已完成(自动化契约与 Xvfb 验证;公开发布受许可门阻断) | 可发布方式待许可决定的 SDK 解析、生成模板修复、dev/test/preview/build/install/run/inspect | 干净目录中的示例插件无需仓库私有路径即可完成全流程;JSON 契约有测试 | SDK、CLI、runtime、shell、docs | | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | -| PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | +| PP-M7 | SDK 与语言生态 | 已完成(自动化契约验证;TypeScript 生产启用受运行时发布与许可门阻断) | Rust API 稳定化;TypeScript 作者 SDK 与候选 adapter;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | | PP-M8 | 分发与信任 | 已完成(自动化契约验证;公开分发受许可门阻断) | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -218,11 +218,15 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 `widget.ftui`,证明参考插件覆盖上述监控 UI 契约且包内不含第三方 `.slint`/HTML;schema、renderer、 SDK 与 shell 的契约/真实 Slint 无头编译测试均通过。真实屏幕阅读器行为、跨平台视觉与完整无障碍仍属 PP-M10 环境验收,不把它们误记为 PP-M6 自动化契约证据; -- PP-M7 已启动 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT - `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle JSON 覆盖全部 - guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` - 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 - TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; +- PP-M7 已完成 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT + `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle/security JSON + 由 Rust SDK、TypeScript SDK 与真实 host/WASM 共同消费。`floatile conformance --json` 输出同一 + suite;Rust API 文档以 warning-free CI 为门,迁移指南和可恢复、幂等的 `migrate` 已落地。private + TypeScript 作者包从 Rust UI Registry 生成类型化 builder,输出 ESM/declarations,并经 lifecycle + adapter 生成 WIT 1.2 component;固定 QuickJS 修复候选在 Linux 通过完整行为、恶意输入、Broker + deny、fuel/epoch/内存隔离与资源证据,在 Ubuntu/macOS/Windows hosted runner 通过同一组件构建。 + 这满足仓库内双语言同契约退出门,但不等于生产启用:上游修复尚未合并发布、许可/NOTICE 门未过, + 因此 TypeScript CLI 模板、公共 SDK 和分发继续关闭; - PP-M8 已由 ADR-0005 固定 detached DSSE/Ed25519 与宿主持有的 publisher/key trust。CLI 受信安装 强制当前信任、撤销、anti-rollback 和权限扩大显式确认;SQLite journal 可恢复 rename 与水位之间的 崩溃窗口。显式回滚只重绑 stopped 实例到重新验签、migration 兼容且不恢复旧权限的历史 @@ -231,7 +235,8 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 Connection grant 是实例级宿主状态,安装与回滚均保持原绑定不变;任何后续换绑仍走 PP-M5 独立授权。 全 workspace 自动化、`wasm32-wasip2` SDK、advisories/bans/sources 已通过;Windows/macOS/Wayland 分发实测属于 PP-M10,公开产物继续由 NFR-LEGAL-01 与 Slint license gate 阻断; -- TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; +- TypeScript runtime 的 ADR-0003 仍是生产 no-go;PP-M7 的作者契约和候选端到端证据已经完成,但 + runtime 选型、公共发布及分发授权属于必须继续保持关闭的外部门禁; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 因此最近的 PR 顺序应优先建设通用底座: diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 30dc8a1..85c8cc1 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -62,7 +62,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | 布局/存储 | 部分验证 | 核心层已有强类型 monitor/DPI/物理坐标模型和平台无关恢复算法;SQLite v2 migration 已持久化物理尺寸、scale factor 与 `lost_monitor`;shell 已接入启动保存/恢复(位置/尺寸/模式)、拖动/缩放/模式/热键/退出保存与显示器变化重恢复,Xvfb+Openbox 与 macOS 单屏重启恢复已实测;真实多屏/DPI/热插拔与 Windows 实机验证待做 | | 插件实例内核(PP-M1) | 已实现,部分验证 | SQLite v4 实例模型和 `floatile instance create/list/get/configure/start/stop/delete` 已打通;创建/配置与 shell 恢复共用安装目录完整性和 Config JSON Schema 复验。shell 控制面提供安装/实例列表、Schema 驱动配置、desired 启停/删除、observed `starting/running/failed/stopped` 与手动 retry;SQLite、安装文件和配置解析均在有界后台 worker。runtime 只有在 WASM `start()` 成功后才报告 running,晚期退出按稳定 code 隔离。Linux X11/Xvfb 自动证据已验证同包双窗口、单实例安装缺失失败隔离和恢复后手动 retry;Windows、macOS、Wayland 控制面交互与真实桌面多窗口仍未验证。 | | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | -| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | +| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;PP-M7 已完成 Rust fallible lifecycle、稳定作者 prelude、API 文档、迁移指南和 `migrate`,并让 Rust/TypeScript SDK、真实 host/WASM 共同消费 lifecycle/security vectors;private TypeScript 包由 Rust UI Registry 生成 builder,输出 ESM/declarations,通过 WIT 1.2 adapter 与固定 QuickJS 修复候选完成 Linux 行为/恶意输入/隔离/资源复测及 Ubuntu/macOS/Windows 组件构建。ADR-0003 对生产 runtime 仍为 no-go:上游修复未发布、许可/NOTICE 与额外 `init` export 未决,因此 CLI TypeScript 模板、公共 SDK 和分发保持关闭;动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | | 包工具链 | 已实现,部分验证 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、原子安装与可恢复 journal;`floatile-core` 提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划;`floatile-store` 持久化 publisher/key trust、anti-rollback 水位与原子回滚审计。CLI 受信安装强制签名/撤销/anti-rollback,权限扩大需显式接受;stopped 实例可回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation并保持最高水位。`install.json` 明确记录 `unsigned|trusted`,Shell 对 trusted 安装在持久实例启动与 retry 前按当前 trust 重新验签,对 unsigned 保留显式本地开发降级路径;三宿主分发运行与公开发布仍受平台证据及许可门禁约束。 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index f3cfb3f..6273826 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -77,8 +77,8 @@ impl Widget for Evil { } fn start(&mut self, ctx: &mut Context) -> WidgetResult { - if self.mode == "conformance-start-rejected" { - return Err(WidgetError::Rejected("conformance start rejection".into())); + if let Some(error) = conformance_error(&self.mode, "start") { + return Err(error); } match self.mode.as_str() { // loop 不进 start:让实例先成功启动,测试再以事件触发无限循环,fuel 才 trap。 @@ -96,13 +96,8 @@ impl Widget for Evil { } fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult { - if self.mode == "conformance-event-invalid-input" { - return Err(WidgetError::InvalidInput( - "conformance event rejection".into(), - )); - } - if self.mode == "conformance-event-internal" { - return Err(WidgetError::Internal); + if let Some(error) = conformance_error(&self.mode, "event") { + return Err(error); } match event { EvilEvent::Trigger => match self.mode.as_str() { @@ -122,6 +117,20 @@ impl Widget for Evil { } } +fn conformance_error(mode: &str, callback: &str) -> Option { + let error = mode.strip_prefix(&format!("conformance-{callback}-"))?; + match error { + "invalid-input" => Some(WidgetError::InvalidInput(format!( + "conformance {callback} invalid input" + ))), + "rejected" => Some(WidgetError::Rejected(format!( + "conformance {callback} rejection" + ))), + "internal" => Some(WidgetError::Internal), + _ => None, + } +} + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] impl Evil { /// 调用两个未声明能力;每次失败都记录(固有能力 log:write 会成功并留审计)。 diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000..40121f8 --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,18 @@ +# `@floatile/sdk` TypeScript author surface + +> Internal PP-M7 implementation. The package remains `private` until a TypeScript Component runtime passes +> ADR, resource, isolation, cross-platform, and licensing gates. + +The build-time UI API is generated from `floatile-ui-schema` rather than a handwritten component list: + +```text +floatile schema ui target/floatile-ui-registry.json +node sdk/typescript/scripts/generate-components.mjs \ + target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check +``` + +`pnpm typecheck` verifies generated props and author-facing bindings. `pnpm test` checks that builders emit the +same canonical `widget.ftui` component shape consumed by the Rust SDK and host validator. `pnpm build` emits ESM +JavaScript plus `.d.ts`/declaration maps to `dist/`; CI compiles that author-consumable boundary in addition to +checking the source. This package does not select or bundle a JavaScript runtime and is not authorized for +registry publication. diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000..9bff9a0 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,24 @@ +{ + "name": "@floatile/sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "pnpm run build && tsc --noEmit", + "test": "pnpm run build && node --test --experimental-strip-types tests/*.test.ts" + }, + "devDependencies": { + "@types/node": "24.10.1", + "typescript": "7.0.2" + } +} diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml new file mode 100644 index 0000000..18b85d8 --- /dev/null +++ b/sdk/typescript/pnpm-lock.yaml @@ -0,0 +1,240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + +packages: + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.16.0: {} diff --git a/sdk/typescript/scripts/generate-components.mjs b/sdk/typescript/scripts/generate-components.mjs new file mode 100644 index 0000000..8ade066 --- /dev/null +++ b/sdk/typescript/scripts/generate-components.mjs @@ -0,0 +1,53 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const [input, output, mode] = process.argv.slice(2); +if (!input || !output) { + throw new Error("usage: generate-components.mjs [--check]"); +} + +const registry = JSON.parse(await readFile(input, "utf8")); +if (registry.schemaVersion !== 1 || typeof registry.uiApiVersion !== "string") { + throw new Error("unsupported UI registry contract"); +} + +const scalar = (types) => [...new Set(types.map((type) => { + if (type === "string") return "string"; + if (type === "number" || type === "integer") return "number"; + if (type === "boolean") return "boolean"; + if (type === "null") return "null"; + return "JsonValue"; +}))].join(" | "); + +let source = `// Generated from floatile-ui-schema ${registry.uiApiVersion}; do not edit.\n`; +source += 'import { component } from "../view.js";\n'; +source += 'import type { Bindable, JsonValue, View } from "../view.js";\n\n'; +source += `export const UI_API_VERSION = ${JSON.stringify(registry.uiApiVersion)} as const;\n\n`; +for (const spec of registry.components) { + if (spec.kind !== "element") continue; + const interfaceName = `${spec.name}Props`; + source += `export interface ${interfaceName} {\n`; + for (const prop of spec.props) { + const base = scalar(prop.types); + const type = prop.allowBinding ? `Bindable<${base}>` : base; + source += ` readonly ${prop.name}${prop.optional ? "?" : ""}: ${type};\n`; + } + if (spec.children === "forbidden") { + source += " readonly children?: never;\n"; + } else if (spec.children === "one") { + source += " readonly children: View;\n"; + } else { + source += " readonly children?: View | readonly View[];\n"; + } + source += "}\n"; + source += `export function ${spec.name}(props: ${interfaceName}): View {\n`; + source += ` return component(${JSON.stringify(spec.name)}, props);\n`; + source += "}\n\n"; +} +source = `${source.trimEnd()}\n`; + +if (mode === "--check") { + const existing = await readFile(output, "utf8"); + if (existing !== source) throw new Error("generated TypeScript components are stale"); +} else { + await writeFile(output, source); +} diff --git a/sdk/typescript/src/generated/components.ts b/sdk/typescript/src/generated/components.ts new file mode 100644 index 0000000..43b98ce --- /dev/null +++ b/sdk/typescript/src/generated/components.ts @@ -0,0 +1,266 @@ +// Generated from floatile-ui-schema 1.6.0; do not edit. +import { component } from "../view.js"; +import type { Bindable, JsonValue, View } from "../view.js"; + +export const UI_API_VERSION = "1.6.0" as const; + +export interface RowProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Row(props: RowProps): View { + return component("Row", props); +} + +export interface ColumnProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Column(props: ColumnProps): View { + return component("Column", props); +} + +export interface StackProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Stack(props: StackProps): View { + return component("Stack", props); +} + +export interface GridProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly columns?: number; + readonly children?: View | readonly View[]; +} +export function Grid(props: GridProps): View { + return component("Grid", props); +} + +export interface ScrollProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Scroll(props: ScrollProps): View { + return component("Scroll", props); +} + +export interface ResponsiveProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly breakpoint: number; + readonly children?: View | readonly View[]; +} +export function Responsive(props: ResponsiveProps): View { + return component("Responsive", props); +} + +export interface TextProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly text: Bindable; + readonly style?: string; + readonly colorToken?: string; + readonly children?: never; +} +export function Text(props: TextProps): View { + return component("Text", props); +} + +export interface IconProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly name: Bindable; + readonly size?: number; + readonly children?: never; +} +export function Icon(props: IconProps): View { + return component("Icon", props); +} + +export interface ImageProps { + readonly padding?: number; + readonly gap?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly asset: string; + readonly width?: number; + readonly height?: number; + readonly children?: never; +} +export function Image(props: ImageProps): View { + return component("Image", props); +} + +export interface ButtonProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly label: Bindable; + readonly children?: never; +} +export function Button(props: ButtonProps): View { + return component("Button", props); +} + +export interface ToggleProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly checked: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Toggle(props: ToggleProps): View { + return component("Toggle", props); +} + +export interface ProgressProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly value: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Progress(props: ProgressProps): View { + return component("Progress", props); +} + +export interface BadgeProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly label: Bindable; + readonly tone?: string; + readonly children?: never; +} +export function Badge(props: BadgeProps): View { + return component("Badge", props); +} + +export interface GaugeProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly value: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Gauge(props: GaugeProps): View { + return component("Gauge", props); +} + +export interface ListProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly items?: Bindable; + readonly children?: View | readonly View[]; +} +export function List(props: ListProps): View { + return component("List", props); +} + +export interface SparklineProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly values: Bindable; + readonly label: Bindable; + readonly tone?: string; + readonly children?: never; +} +export function Sparkline(props: SparklineProps): View { + return component("Sparkline", props); +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000..4b02765 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,4 @@ +export * from "./view.js"; +export * from "./widget.js"; +export * from "./runtime.js"; +export * from "./generated/components.js"; diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts new file mode 100644 index 0000000..5ebdfa1 --- /dev/null +++ b/sdk/typescript/src/runtime.ts @@ -0,0 +1,100 @@ +import type { JsonObject, JsonValue } from "./view.js"; +import { + lowerWidgetError, + type WidgetContext, + type WidgetDefinition, +} from "./widget.js"; + +export interface RuntimeWidgetInit { + readonly configJson: string; + readonly initialStateJson: string; +} + +export type RuntimeWidgetEvent = + | { readonly tag: "ui"; readonly val: { readonly name: string; readonly payloadJson: string } } + | { readonly tag: "timer"; readonly val: number } + | { readonly tag: "mode-changed"; readonly val: "edit" | "show" } + | { readonly tag: "config-changed"; readonly val: string } + | { readonly tag: "theme-changed"; readonly val: string } + | { readonly tag: "operation-completed"; readonly val: JsonValue } + | { readonly tag: "suspend" } + | { readonly tag: "resume" }; + +export interface RuntimeHost { + updateState(patchJson: string): void; +} + +export interface WidgetContractAdapter { + decodeEvent(event: RuntimeWidgetEvent): Event | undefined; +} + +export interface WidgetContractConstructor { + new(init: RuntimeWidgetInit): { + start(): void; + handleEvent(event: RuntimeWidgetEvent): void; + stop(): void; + }; +} + +export interface WidgetContract { + readonly WidgetInstance: WidgetContractConstructor; +} + +function parseState(json: string): State { + const value: unknown = JSON.parse(json); + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new TypeError("initial State must be a JSON object"); + } + return value as State; +} + +function invoke(callback: () => void): void { + try { + callback(); + } catch (error) { + throw lowerWidgetError(error); + } +} + +export function createWidgetContract( + definition: WidgetDefinition, + host: RuntimeHost, + adapter: WidgetContractAdapter, +): WidgetContract { + return { + WidgetInstance: class { + readonly #context: WidgetContext; + #state: State; + + constructor(init: RuntimeWidgetInit) { + this.#state = parseState(init.initialStateJson); + const config: unknown = JSON.parse(init.configJson); + this.#context = { + config: config as JsonValue, + state: { + update: (patch) => { + host.updateState(JSON.stringify(patch)); + this.#state = { ...this.#state, ...patch }; + }, + }, + }; + } + + start(): void { + if (definition.start) invoke(() => definition.start?.(this.#context)); + } + + handleEvent(event: RuntimeWidgetEvent): void { + if (!definition.event) return; + const decoded = adapter.decodeEvent(event); + if (decoded !== undefined) { + invoke(() => definition.event?.(decoded, this.#context)); + } + } + + stop(): void { + if (definition.stop) invoke(() => definition.stop?.(this.#context)); + } + }, + }; +} diff --git a/sdk/typescript/src/view.ts b/sdk/typescript/src/view.ts new file mode 100644 index 0000000..39d2ce4 --- /dev/null +++ b/sdk/typescript/src/view.ts @@ -0,0 +1,51 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = object; + +export interface StateBinding { + readonly bind: string; +} + +export interface ItemBinding { + readonly item: string; +} + +export type Binding = StateBinding | ItemBinding; +export type Bindable = T | Binding; + +export interface EmittedEvent { + readonly emit: string; + readonly payload?: JsonValue; +} + +export interface View { + readonly type: string; + readonly props?: Readonly>; + readonly children?: readonly View[]; + readonly events?: Readonly>; +} + +export function state(path: string): StateBinding { + if (!path.startsWith("$.")) throw new TypeError("state binding must start with $."); + return { bind: path }; +} + +export function item(field: string): ItemBinding { + if (field.length === 0) throw new TypeError("item binding must not be empty"); + return { item: field }; +} + +export function component(type: string, input: T): View { + const { children: childInput, ...props } = input as T & { readonly children?: View | readonly View[] }; + const children = childInput === undefined + ? [] + : Array.isArray(childInput) ? childInput : [childInput]; + const filtered = Object.fromEntries( + Object.entries(props).filter(([, value]) => value !== undefined), + ) as Record; + return { + type, + ...(Object.keys(filtered).length === 0 ? {} : { props: filtered }), + ...(children.length === 0 ? {} : { children }), + }; +} diff --git a/sdk/typescript/src/widget.ts b/sdk/typescript/src/widget.ts new file mode 100644 index 0000000..f5edb4c --- /dev/null +++ b/sdk/typescript/src/widget.ts @@ -0,0 +1,59 @@ +import type { JsonObject, JsonValue, View } from "./view.js"; + +export type WidgetErrorKind = "invalid-input" | "rejected" | "internal"; + +export class WidgetError extends Error { + readonly kind: WidgetErrorKind; + + private constructor(kind: WidgetErrorKind, message?: string) { + super(message ?? "internal widget error"); + this.name = "WidgetError"; + this.kind = kind; + } + + static invalidInput(message: string): WidgetError { + return new WidgetError("invalid-input", message); + } + + static rejected(message: string): WidgetError { + return new WidgetError("rejected", message); + } + + static internal(): WidgetError { + return new WidgetError("internal"); + } +} + +export type WitWidgetError = + | { readonly tag: "invalid-input"; readonly val: string } + | { readonly tag: "rejected"; readonly val: string } + | { readonly tag: "internal" }; + +export function lowerWidgetError(error: unknown): WitWidgetError { + if (!(error instanceof WidgetError)) return { tag: "internal" }; + if (error.kind === "internal") return { tag: "internal" }; + return { tag: error.kind, val: error.message }; +} + +export interface StateContext { + update(patch: Partial): void; +} + +export interface WidgetContext { + readonly state: StateContext; + readonly config: JsonValue; +} + +export interface WidgetDefinition { + readonly state: State; + readonly view: (state: Readonly) => View; + readonly start?: (context: WidgetContext) => void; + readonly event?: (event: Event, context: WidgetContext) => void; + readonly stop?: (context: WidgetContext) => void; +} + +export function defineWidget( + definition: WidgetDefinition, +): WidgetDefinition { + return Object.freeze(definition); +} diff --git a/sdk/typescript/tests/conformance.test.ts b/sdk/typescript/tests/conformance.test.ts new file mode 100644 index 0000000..4c60b55 --- /dev/null +++ b/sdk/typescript/tests/conformance.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { WidgetError, lowerWidgetError } from "../dist/index.js"; + +interface LifecycleVector { + readonly id: string; + readonly callback: "start" | "event"; + readonly guestError: "invalid-input" | "rejected" | "internal"; + readonly message: string | null; + readonly expectedHostOutcome: "rejected"; +} + +interface LifecycleSuite { + readonly schemaVersion: 1; + readonly engineApiVersion: string; + readonly vectors: readonly LifecycleVector[]; +} + +const lifecycle = JSON.parse( + await readFile(new URL("../../../conformance/sdk-lifecycle-v1.json", import.meta.url), "utf8"), +) as LifecycleSuite; + +interface SecuritySuite { + readonly schemaVersion: 1; + readonly engineApiVersion: string; + readonly vectors: readonly { + readonly id: string; + readonly trigger: "start" | "event"; + readonly expectedHostOutcome: string; + readonly hostSurvives: true; + }[]; +} + +const security = JSON.parse( + await readFile(new URL("../../../conformance/sdk-security-v1.json", import.meta.url), "utf8"), +) as SecuritySuite; + +test("TypeScript errors lower to every shared lifecycle vector", () => { + assert.equal(lifecycle.schemaVersion, 1); + assert.equal(lifecycle.engineApiVersion, "1.2.0"); + assert.equal(lifecycle.vectors.length, 6); + for (const vector of lifecycle.vectors) { + const error = vector.guestError === "invalid-input" + ? WidgetError.invalidInput(vector.message ?? "") + : vector.guestError === "rejected" + ? WidgetError.rejected(vector.message ?? "") + : WidgetError.internal(); + const lowered = lowerWidgetError(error); + assert.equal(lowered.tag, vector.guestError, vector.id); + if ("val" in lowered) assert.equal(lowered.val, vector.message, vector.id); + assert.equal(vector.expectedHostOutcome, "rejected"); + } +}); + +test("unknown JavaScript exceptions do not leak through the WIT boundary", () => { + assert.deepEqual(lowerWidgetError(new Error("secret host path")), { tag: "internal" }); +}); + +test("TypeScript adapter inherits every shared host-survival requirement", () => { + assert.equal(security.schemaVersion, 1); + assert.equal(security.engineApiVersion, lifecycle.engineApiVersion); + assert.deepEqual( + security.vectors.map((vector) => vector.id), + ["broker-deny", "invalid-state-patch", "fuel-exhaustion", "wall-clock-timeout", "memory-limit"], + ); + assert.ok(security.vectors.every((vector) => vector.hostSurvives)); +}); diff --git a/sdk/typescript/tests/runtime.test.ts b/sdk/typescript/tests/runtime.test.ts new file mode 100644 index 0000000..25298e8 --- /dev/null +++ b/sdk/typescript/tests/runtime.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createWidgetContract, + defineWidget, + WidgetError, + type RuntimeWidgetEvent, +} from "../dist/index.js"; + +interface State { + readonly count: number; +} + +test("contract adapter commits the local State mirror only after host acceptance", () => { + const patches: string[] = []; + const widget = defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + event: (amount, context) => { + assert.deepEqual(context.config, { mode: "test" }); + context.state.update({ count: amount }); + }, + }); + const contract = createWidgetContract( + widget, + { updateState: (patch) => patches.push(patch) }, + { decodeEvent: (event) => event.tag === "timer" ? event.val : undefined }, + ); + const instance = new contract.WidgetInstance({ configJson: '{"mode":"test"}', initialStateJson: '{"count":0}' }); + instance.handleEvent({ tag: "timer", val: 2 }); + assert.deepEqual(patches, ['{"count":2}']); +}); + +test("contract adapter lowers author and unknown lifecycle errors", () => { + const event = { tag: "resume" } satisfies RuntimeWidgetEvent; + const contract = createWidgetContract( + defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + event: () => { throw WidgetError.rejected("busy"); }, + }), + { updateState: () => undefined }, + { decodeEvent: (raw) => raw }, + ); + const instance = new contract.WidgetInstance({ configJson: "{}", initialStateJson: '{"count":0}' }); + assert.throws(() => instance.handleEvent(event), (error) => { + assert.deepEqual(error, { tag: "rejected", val: "busy" }); + return true; + }); + + const unknown = createWidgetContract( + defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + start: () => { throw new Error("secret"); }, + }), + { updateState: () => undefined }, + { decodeEvent: () => undefined }, + ); + const unknownInstance = new unknown.WidgetInstance({ configJson: "{}", initialStateJson: '{"count":0}' }); + assert.throws(() => unknownInstance.start(), (error) => { + assert.deepEqual(error, { tag: "internal" }); + return true; + }); +}); diff --git a/sdk/typescript/tests/view.test.ts b/sdk/typescript/tests/view.test.ts new file mode 100644 index 0000000..d80b170 --- /dev/null +++ b/sdk/typescript/tests/view.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Column, Text, state } from "../dist/index.js"; + +test("generated builders emit canonical Floatile UI IR", () => { + const view = Column({ + padding: 16, + gap: 8, + children: Text({ text: state("$.time"), style: "title" }), + }); + assert.deepEqual(view, { + type: "Column", + props: { padding: 16, gap: 8 }, + children: [{ type: "Text", props: { text: { bind: "$.time" }, style: "title" } }], + }); +}); + +test("state bindings reject ambiguous paths", () => { + assert.throws(() => state("time"), /must start/); +}); diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json new file mode 100644 index 0000000..886e21f --- /dev/null +++ b/sdk/typescript/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["tests/**/*.ts"] +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000..0e1bc58 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/spikes/typescript-runtime/README.md b/spikes/typescript-runtime/README.md index 7fafc44..d670609 100644 --- a/spikes/typescript-runtime/README.md +++ b/spikes/typescript-runtime/README.md @@ -7,7 +7,7 @@ 固定工具链:Node.js ≥20、pnpm 11.3.0、`@bytecodealliance/jco` 1.31.0、 ComponentizeJS/StarlingMonkey、`componentize-qjs` 0.4.3、TypeScript 7.0.2。两个后端生成的 -组件只能导入 `floatile:widget/*`,仍由 Permission Broker 仲裁;StarlingMonkey 构建关闭全部 +组件只能导入当前 WIT world 明确列出的 `floatile:widget/*`,仍由 Permission Broker 仲裁;StarlingMonkey 构建关闭全部 WASI feature,QuickJS 构建使用 `--stub-wasi --opt-size --sync`。 Weval/AOT 已被 ADR-0003 判定体积和 RSS 更差;其 npm 链还包含未发布修复版的 `decompress` advisory,因此 lock 用本地禁用桩替换 Weval,构建脚本不提供 AOT 路径。 @@ -30,13 +30,20 @@ pnpm test:quickjs ``` 发布版 `componentize-qjs` 0.4.3 的 `test:quickjs` 预期在带参数 resource method 处失败。 -若验证上游候选修复,可在构建时设置绝对路径 +若验证上游 PR #76 固定 head +`7788644697ed08a841ad0910d4e99772c6fe7132` 的候选修复,可在从该提交构建 CLI 后设置绝对路径 `FLOATILE_COMPONENTIZE_QJS_BIN=/path/to/componentize-qjs`;输出固定为 `clock-typescript-quickjs.wasm`,同一组 Rust host tests 通过 `FLOATILE_TYPESCRIPT_CLOCK_WASM` 选择组件,不复制行为语义。 +QuickJS 参考时钟消费 `sdk/typescript` 构建产物的 `defineWidget`/`createWidgetContract`,六条共享 +lifecycle error 向量会真实穿过 SDK、adapter 和 Wasmtime host;它不是只做 Node 对象形状测试。 +同一 host suite 还从 `sdk-security-v1.json` 驱动 Broker deny、invalid patch、fuel exhaustion、epoch +墙钟预算和 StoreLimits/peer 隔离。普通 JavaScript 堆分配失败是可捕获异常,不冒充 Wasmtime +线性内存 trap;内存向量由宿主 StoreLimits 验证。 -`pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick 与 RSS(CPU 需在外层用 -`time -p`/平台 profiler 采样)。最小 receiver/参数复现位于 +`pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick、RSS,以及首 tick 后 3 秒窗口的 +进程 CPU 毫秒/单核百分比。Linux 指标汇总所有 `/proc/self/task/*/schedstat`,不是只读调用线程; +其他平台仍以 `floatile-platform::process_metrics` 的平台实现为准。最小 receiver/参数复现位于 `repro/quickjs-resource-method/`。QuickJS 对照与资源数据记录在 ADR-0003;在修复尚未发布且 -许可/三平台门未完成前结论仍为 no-go,因此公共 SDK、TSX View codegen、CLI TypeScript 模板 -仍未开始。 +许可/三平台门未完成前结论仍为 no-go。仓库中的 private TypeScript SDK/UI codegen 只用于推进 +共享契约和测试,CLI TypeScript 模板与可发布 SDK 仍不得启用。 diff --git a/spikes/typescript-runtime/scripts/build-backend.mjs b/spikes/typescript-runtime/scripts/build-backend.mjs index 1450d24..04c130d 100644 --- a/spikes/typescript-runtime/scripts/build-backend.mjs +++ b/spikes/typescript-runtime/scripts/build-backend.mjs @@ -18,6 +18,18 @@ const executable = (name) => "node_modules/.bin", process.platform === "win32" ? `${name}.CMD` : name, ); +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const localToolOptions = { + cwd: spikeDir, + shell: process.platform === "win32", + stdio: "inherit", +}; + +execFileSync(pnpm, ["--dir", "sdk/typescript", "build"], { + cwd: workspace, + shell: process.platform === "win32", + stdio: "inherit", +}); execFileSync(process.execPath, ["scripts/prepare.mjs"], { cwd: spikeDir, @@ -35,14 +47,11 @@ execFileSync( "--strict", "--quiet", ], - { cwd: spikeDir, stdio: "inherit" }, + localToolOptions, ); if (backend === "starlingmonkey") { - execFileSync(executable("tsc"), ["--noEmit"], { - cwd: spikeDir, - stdio: "inherit", - }); + execFileSync(executable("tsc"), ["--noEmit"], localToolOptions); execFileSync( executable("jco"), [ @@ -57,7 +66,7 @@ if (backend === "starlingmonkey") { "--out", output, ], - { cwd: spikeDir, stdio: "inherit" }, + localToolOptions, ); } else if (process.env.FLOATILE_COMPONENTIZE_QJS_BIN) { execFileSync( @@ -68,7 +77,7 @@ if (backend === "starlingmonkey") { "--js", resolve(spikeDir, "src/clock-quickjs.js"), "--module-root", - spikeDir, + workspace, "--output", output, "--world", @@ -86,7 +95,7 @@ if (backend === "starlingmonkey") { witPath: resolve(workspace, "wit"), jsSource: readFileSync(sourcePath, "utf8"), jsPath: sourcePath, - moduleRoot: spikeDir, + moduleRoot: workspace, world: "floatile-widget", stubWasi: true, optSize: true, diff --git a/spikes/typescript-runtime/scripts/verify-component.mjs b/spikes/typescript-runtime/scripts/verify-component.mjs index 34de1dd..c98ff1f 100644 --- a/spikes/typescript-runtime/scripts/verify-component.mjs +++ b/spikes/typescript-runtime/scripts/verify-component.mjs @@ -21,6 +21,7 @@ const jco = resolve( const world = execFileSync(jco, ["wit", componentPath], { cwd: spikeDir, encoding: "utf8", + ...(process.platform === "win32" ? { shell: true } : {}), }); const imports = [...world.matchAll(/^\s*import\s+([^;]+);$/gm)].map( @@ -29,20 +30,36 @@ const imports = [...world.matchAll(/^\s*import\s+([^;]+);$/gm)].map( const exports = [...world.matchAll(/^\s*export\s+([^;]+);$/gm)].map( (match) => match[1], ); +const widgetVersion = "1.2.0"; +const expectedImports = new Set( + [ + "host-ui", + "host-log", + "host-clock", + "host-timer", + "host-operation", + "host-storage", + "host-http", + "host-metrics", + "host-theme", + ].map((name) => `floatile:widget/${name}@${widgetVersion}`), +); const unexpected = imports.filter( - (name) => !name.startsWith("floatile:widget/"), + (name) => !expectedImports.has(name), ); if (unexpected.length > 0) { - throw new Error(`ambient imports are forbidden: ${unexpected.join(", ")}`); + throw new Error(`unexpected imports are forbidden: ${unexpected.join(", ")}`); } -if (imports.length !== 7) { - throw new Error(`expected the seven WIT host interfaces, got ${imports.length}`); +const missing = [...expectedImports].filter((name) => !imports.includes(name)); +if (missing.length > 0) { + throw new Error(`missing WIT host interfaces: ${missing.join(", ")}`); } -if (!world.includes("export floatile:widget/widget-contract@1.0.0;")) { - throw new Error("component does not export Floatile widget-contract@1.0.0"); +const widgetContract = `floatile:widget/widget-contract@${widgetVersion}`; +if (!world.includes(`export ${widgetContract};`)) { + throw new Error(`component does not export ${widgetContract}`); } const additionalExports = exports.filter( - (name) => name !== "floatile:widget/widget-contract@1.0.0", + (name) => name !== widgetContract, ); if (/^\s*import\s+wasi:/m.test(world)) { throw new Error("WASI imports are forbidden for the TypeScript adapter"); @@ -53,7 +70,7 @@ if (componentBytes > 16 * 1024 * 1024) { throw new Error(`component exceeds the 16 MiB entry limit: ${componentBytes}`); } const ui = JSON.parse(readFileSync(uiPath, "utf8")); -if (ui.uiApiVersion !== "1.0.0") { +if (ui.uiApiVersion !== "1.6.0") { throw new Error(`unexpected uiApiVersion: ${ui.uiApiVersion}`); } diff --git a/spikes/typescript-runtime/src/clock-quickjs.js b/spikes/typescript-runtime/src/clock-quickjs.js index efee6a5..1ec348c 100644 --- a/spikes/typescript-runtime/src/clock-quickjs.js +++ b/spikes/typescript-runtime/src/clock-quickjs.js @@ -1,21 +1,46 @@ -import hostClock from "floatile:widget/host-clock@1.0.0"; -import hostLog from "floatile:widget/host-log@1.0.0"; -import hostTimer from "floatile:widget/host-timer@1.0.0"; -import hostUi from "floatile:widget/host-ui@1.0.0"; +import hostClock from "floatile:widget/host-clock@1.2.0"; +import hostLog from "floatile:widget/host-log@1.2.0"; +import hostTimer from "floatile:widget/host-timer@1.2.0"; +import hostUi from "floatile:widget/host-ui@1.2.0"; -class WidgetInstance { - constructor(init) { - // constructor 只建立内存状态,不调用任何 host capability。 - this.initialState = JSON.parse(init.initialStateJson); - const config = JSON.parse(init.configJson); - this.mode = typeof config.mode === "string" ? config.mode : undefined; +import { + createWidgetContract, + defineWidget, + WidgetError, +} from "../../../sdk/typescript/dist/index.js"; + +function conformanceError(config, callback) { + const mode = config?.mode; + if (mode === `conformance-${callback}-invalid-input`) { + return WidgetError.invalidInput(`conformance ${callback} invalid input`); + } + if (mode === `conformance-${callback}-rejected`) { + return WidgetError.rejected(`conformance ${callback} rejection`); } + if (mode === `conformance-${callback}-internal`) { + return WidgetError.internal(); + } + return undefined; +} - start() { - if (this.mode === "loop") { - // 安全向量:验证同一 JS runtime 仍受 Wasmtime fuel/epoch 预算约束。 +const clock = defineWidget({ + state: { running: false, time: "" }, + view: () => ({ type: "Text" }), + + start(context) { + const error = conformanceError(context.config, "start"); + if (error) throw error; + const mode = context.config?.mode; + if (mode === "loop-start") { while (true) { - this.initialState; + context.config; + } + } + if (mode === "bad-patch") { + try { + context.state.update({ unexpected: true }); + } catch { + // 宿主拒绝后 SDK 不提交本地 State mirror。 } } try { @@ -29,12 +54,19 @@ class WidgetInstance { } catch { // Permission Broker 的 deny 仍由宿主审计。 } - } + }, - handleEvent(event) { + event(event, context) { + const error = conformanceError(context.config, "event"); + if (error) throw error; + if (context.config?.mode === "loop") { + while (true) { + event; + } + } if (event.tag === "ui" && event.val.name === "start") { try { - hostUi.updateState(JSON.stringify({ running: true })); + context.state.update({ running: true }); } catch { // State 权威校验在宿主;拒绝不回写本地镜像。 } @@ -50,7 +82,7 @@ class WidgetInstance { const time = `${pad(hour)}:${pad(minute)}:${pad(second)}`; try { - hostUi.updateState(JSON.stringify({ time })); + context.state.update({ time }); } catch { // 保持与 Rust clock 的 best-effort State Patch 语义一致。 } @@ -65,7 +97,7 @@ class WidgetInstance { // 一次性 timer 重新调度失败时停止 tick,但实例存活。 } } - } + }, stop() { try { @@ -73,7 +105,11 @@ class WidgetInstance { } catch { // stop 是有预算的尽力清理通知。 } - } -} + }, +}); -export const widgetContract = { WidgetInstance }; +export const widgetContract = createWidgetContract( + clock, + { updateState: (patchJson) => hostUi.updateState(patchJson) }, + { decodeEvent: (event) => event }, +); diff --git a/spikes/typescript-runtime/src/clock.ts b/spikes/typescript-runtime/src/clock.ts index f7f25bc..11cdb61 100644 --- a/spikes/typescript-runtime/src/clock.ts +++ b/spikes/typescript-runtime/src/clock.ts @@ -1,11 +1,11 @@ -import { now } from "floatile:widget/host-clock@1.0.0"; -import { log } from "floatile:widget/host-log@1.0.0"; -import { schedule } from "floatile:widget/host-timer@1.0.0"; -import { updateState } from "floatile:widget/host-ui@1.0.0"; +import { now } from "floatile:widget/host-clock@1.2.0"; +import { log } from "floatile:widget/host-log@1.2.0"; +import { schedule } from "floatile:widget/host-timer@1.2.0"; +import { updateState } from "floatile:widget/host-ui@1.2.0"; import type { WidgetEvent, WidgetInit, -} from "floatile:widget/widget-contract@1.0.0"; +} from "floatile:widget/widget-contract@1.2.0"; class WidgetInstance { private readonly initialState: unknown;