From 9015a629d2218e474f1f4189092c25849cd16a81 Mon Sep 17 00:00:00 2001 From: ShiningRay Date: Wed, 16 Sep 2026 07:08:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8C=85=E8=A3=85=E8=BD=BD=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=20App=20=E4=B8=8E=E5=8C=85=E5=86=85=20Servic?= =?UTF-8?q?e=E2=80=94=E2=80=94=E7=AC=AC=E4=B8=89=E6=96=B9=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=8F=AF=E8=87=AA=E5=B8=A6=E5=A4=9A=E4=B8=AA=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让第三方 .emz 包能成为一个完整应用(而非只能一个 App),这是 AgentOS 桌面 包独立分发的前置能力: - AppHost#evaluate! 放宽为注册 entry 定义的**全部** Emerald::App 子类(去掉 app_id 必须等于包 id 的硬断言,保留「至少一个新 App 子类」);reopen/reload 场景经包 id → 类列表的内存映射回查(重开类不触发 inherited) - Emerald::Service 加子类追踪(inherited + service_subclasses,照 App 写法); AppHost 暴露 defined_services(本轮 scan/reload 新定义的 Service 子类, 含 reopen 回查),DesktopShell 新增 register_package_services:注册进 ServiceHub 并幂等 activate_startup;启动扫描与 install_and_register、 on_app_source_saved 三处调用 - 新增能力面 ctx[:launch_app](->(id, argv = {}) { launch_app(id, **argv) }): 包内 App 拉起兄弟窗口的正规入口(此前 ctx 里只有裸 AppRegistry, launch 只建实例不开窗) - 贡献命令可指向包内窗口:contributes.commands[] 新增可选 app 字段, 取值链 cmd[:app] → 该包首个已注册 App → 包 id;无可用 App 时跳过并告警 - 卸载时清编译缓存(invalidate_cache),examples/*.emz 进 .gitignore - SPEC §4.1 同步:多 App / 包内 Service / 命令 app 字段 --- .gitignore | 1 + docs/SPEC-package-format.md | 19 ++++- lib/emerald/pkg/apphost.rb | 68 +++++++++++++--- lib/emerald/service.rb | 16 ++++ lib/emerald/shell.rb | 79 ++++++++++++++++-- test/pkg_apphost_test.rb | 139 ++++++++++++++++++++++++++++++- test/shell_e7_test.rb | 158 +++++++++++++++++++++++++++++++++++- 7 files changed, 454 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index e4e0bc2..7bbbd97 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ Gemfile.lock examples/*.js examples/standalone/*.js +examples/*.emz diff --git a/docs/SPEC-package-format.md b/docs/SPEC-package-format.md index 15ed951..7697374 100644 --- a/docs/SPEC-package-format.md +++ b/docs/SPEC-package-format.md @@ -48,7 +48,9 @@ hello.emz ``` - `spec`:本标准版本号,解析器按它分流;未知 `spec` 拒绝安装。 -- `id`:全小写 `[a-z][a-z0-9-]*`,安装后作为目录名与注册键;同 id 重装 = 更新。 +- `id`:全小写 `[a-z][a-z0-9-]*`,安装后作为目录名、安装记录(lock)与编译 + 缓存的键;同 id 重装 = 更新。(包内 App 在 App 注册表里的键是各自的 `app_id`, + 与包 id 独立,见 §4.1。) - `version`:semver。`min_runtime` 不满足 → 拒绝并提示"应用需要更新/系统需要升级"。 - `permissions`:v1 仅占位不执行(系统无沙箱,见 PLAN 非目标),但作者必须 如实声明意图(如 `vfs:write`、`net`),为将来强制执行与审计留数据。 @@ -62,7 +64,7 @@ hello.emz "kind": "app", "entry": "src/main.rb", "contributes": { - "commands": [{ "id": "hello.say", "title": "问好", "hotkey": "meta+h" }], + "commands": [{ "id": "hello.say", "title": "问好", "hotkey": "meta+h", "app": "hello" }], "file_types": [".hello"], "menus": [{ "path": "应用/工具", "command": "hello.say" }] }, @@ -71,8 +73,19 @@ hello.emz } ``` -- `entry` 指向的源码必须定义一个 `Emerald::App` 子类并完成 manifest 四宏中 +- `entry` 指向的源码**至少定义一个** `Emerald::App` 子类并完成 manifest 四宏中 与 manifest.json 不冲突的部分;两者冲突时 **manifest.json 为准**(声明式优先)。 + 一个包可以定义**多个** `App` 子类(例如一个桌面包同时提供总览窗、列表窗、 + 收件箱),装载时全部注册;包 `id` 只作安装/卸载/缓存/更新记录的键,**不要求 + 等于任何 App 的 app_id**。 +- `entry` 也可定义 `Emerald::Service` 子类(无 UI 的常驻逻辑,见 §4.3 的服务 + 形态约定):宿主在安装与启动扫描时把包内 Service 子类注册进 ServiceHub, + 并按其自身的激活声明(如 `activation on_startup: true`)激活——包内不自启。 + 与 App 相同,多 App 包的包 `id` 与 App 的 app_id 各自独立,命令归约见下。 +- `contributes.commands[]` 每项:`id`(必备,形如 `pkg.action`)、`title`、 + 可选 `hotkey`、可选 `app`——`app` 指明该命令应打开的 App(多 App 包用它把 + 命令落到具体窗口);缺省时取**包内首个已注册的 App**,包内一个 App 都没 + 注册成功则该命令不接线(宿主发一条 warning,不使整体安装失败)。 - v1 只支持单文件 entry;多文件(包内 `require` 经虚拟 $LOAD_PATH)v1.1。 ### 4.2 Agent(运行于 AgentOS) diff --git a/lib/emerald/pkg/apphost.rb b/lib/emerald/pkg/apphost.rb index bc7e87e..bcecbf1 100644 --- a/lib/emerald/pkg/apphost.rb +++ b/lib/emerald/pkg/apphost.rb @@ -6,7 +6,8 @@ module Pkg # /Applications 扫描与源码应用装载(docs/PLAN.md §3.10;D11/D12): # lock(installed.json)为扫描清单 → 读 entry 源码 → 编译缓存命中判定 #(键 = 源码 sha256 + Opal 版本,SPEC「源码为正本、产物为可丢弃缓存」)→ - # 求值定义 App 子类 → 注册 AppRegistry。 + # 求值定义 App 子类 → 注册 AppRegistry(包内 Service 子类另经 + # defined_services 交给 shell 装载)。 # # IO 边界全部注入(D5 同款,纯 CRuby 可测): # - compiler: callable(ruby_source, app_id) → 可执行文本 @@ -17,11 +18,23 @@ module Pkg # # 单应用失败绝不拖垮整机(PLAN §8:用户改坏 /Applications ≠ 系统崩): # scan_one 逐条 rescue,结果进 reports 供 shell 通知。 + # + # 多 App 包(AgentOS 桌面:1 个 Service + 4 个窗口 App):entry 定义的 + # 全部 Emerald::App 子类一次注册(不再要求 app_id == 包 id),包 → App id + # 的映射经 package_app_ids 供宿主接线贡献命令;entry 里定义的 + # Emerald::Service 子类经 defined_services 交给 shell 注册 ServiceHub + # (宿主能力,包内不做自启 hack)。两张映射都留在进程内存里,reopen + # (同进程重复 eval,inherited 不再触发)时据此回查上一次的类列表。 class AppHost APPS_DIR = '/Applications' CACHE_DIR = '/System/Cache' attr_reader :reports + # 本轮 scan/reload 交付的 Service 子类:新定义的 + reopen(同进程重复 + # eval,不再触发 inherited)时按包 id 从内存映射回查的(scan/reload 开始 + # 即重置;求值失败的包不计入)。shell 据此注册 ServiceHub(其 register + # 幂等,重复交付无副作用)。 + attr_reader :defined_services # 编译/加载边界可后置注入(shell 测试注入伪实现;浏览器由 shell 构造时给) attr_accessor :compiler, :loader @@ -32,11 +45,23 @@ def initialize(vfs:, lock:, compiler: nil, loader: nil, opal_version: self.class @loader = loader @opal_version = opal_version @reports = [] + @defined_services = [] + @package_apps = {} # 包 id(String) => [App 类](reopen 场景的回查映射) + @package_services = {} # 包 id(String) => [Service 类](同上,供 defined_services 回查) + end + + # 包 id → 该包 entry 定义的 App id 列表(顺序 = entry 里的定义顺序)。 + # 宿主接线用:多 App 包的包 id 不是任何 App 的 app_id,贡献命令须归约到 + # 包内窗口 App(shell 的 contributes 接线)。未装载过(bundled 跳过求值 / + # 包不存在)的包返回 [],调用侧自行保底。 + def package_app_ids(id) + @package_apps[id.to_s].to_a.map(&:app_id) end # 扫描全部已装应用并注册;返回 reports(shell 据此发通知)。 def scan(registry) @reports = [] + @defined_services = [] @lock.entries.each { |entry| scan_one(registry, entry) } @reports end @@ -48,6 +73,7 @@ def reload(registry, id) entry = @lock.get(id) return report(id, :skipped, '未安装') unless entry + @defined_services = [] scan_one(registry, entry, force: true) end @@ -90,10 +116,9 @@ def scan_one(registry, entry, force: false) executable = compile!(id, src, sha) detail = '编译' end - klass = evaluate!(executable, id) - # reload(force)也可能来自「装了但从未注册成功」的应用,此时要注册; - # reopen 场景 registered? 为真,跳过(重复 register 会 raise) - registry.register(klass) unless registered?(registry, id) + klasses = evaluate!(executable, id) + # 多 App 包逐个注册;同 app_id 已注册的跳过(内置优先 / 前一轮已注册) + klasses.each { |klass| registry.register(klass) unless registered?(registry, klass.app_id) } report(id, :registered, detail) rescue StandardError => e # 单应用失败不拖垮整机(坏包/坏源码/缺编译器都到这);本次失败的 @@ -112,19 +137,36 @@ def compile!(id, src, sha) js end - # 求值可执行文本 → 应用类。新定义子类直接取;reopen(reload)场景按 - # app_id 回查(manifest 为准:entry 的类必须声明与包 id 一致的 app_id)。 + # 求值可执行文本 → 本次装载的 App 类列表。 + # 注册「entry 定义的全部新 App 子类」(多 App 包:单次求值收齐所有窗口 + # App;单应用包即长度 1)——不再要求 app_id 与包 id 一致,包 id 只是 + # 安装/卸载/缓存的键。reopen(reload)不产生新子类,按包 id 从内存映射 + # 回查上一次的类列表(进程内存,非持久化)。 + # 同时收集本次交付的 Service 子类(defined_services 的填充点)。 def evaluate!(executable, id) raise 'AppHost 需要注入 loader' unless @loader - before = Emerald::App.app_subclasses.dup + apps_before = Emerald::App.app_subclasses.dup + services_before = Emerald::Service.service_subclasses.dup @loader.call(executable) - fresh = Emerald::App.app_subclasses - before - klass = fresh.first || Emerald::App.app_subclasses.reverse.find { |k| k.app_id == id.to_sym } - raise Json::Invalid, "entry 未定义 app_id 为 #{id} 的 Emerald::App 子类" if klass.nil? - raise Json::Invalid, "entry 声明的 app_id (#{klass.app_id}) 与包 id (#{id}) 不一致" unless klass.app_id == id.to_sym + fresh = Emerald::App.app_subclasses - apps_before + klasses = fresh.empty? ? @package_apps[id.to_s].to_a : fresh + raise Json::Invalid, "entry 未定义 Emerald::App 子类(包 #{id})" if klasses.empty? + + @package_apps[id.to_s] = klasses + @defined_services += package_services(id.to_s, services_before) + klasses + end - klass + # 本次求值交付的 Service 子类:新定义的;reopen(同进程重复 eval, + # inherited 不再触发)则按包 id 回查上一次的类列表——照 @package_apps 的 + # 做法,否则 reload 后 defined_services 恒为空、包内 Service 漏装。两边都 + # 空则记空表(包内确实没有服务类)。 + def package_services(id, services_before) + fresh = Emerald::Service.service_subclasses - services_before + services = fresh.empty? ? @package_services[id].to_a : fresh + @package_services[id] = services + services end def registered?(registry, id) diff --git a/lib/emerald/service.rb b/lib/emerald/service.rb index c82c98f..51927f5 100644 --- a/lib/emerald/service.rb +++ b/lib/emerald/service.rb @@ -38,6 +38,22 @@ def normalize_ext(ext) end end + # ── 子类追踪(包内 Service 装载:AppHost 求值 entry 后捕获其中新定义的 + # 服务类,DesktopShell 据此注册 ServiceHub)──────────────────────── + # 全部 Emerald::Service 子类,插入序 = 定义序;对齐 Emerald::App 的 + # app_subclasses 写法(citrine/beryl 均未定义 inherited,挂接安全, + # 仍调 super 保持可叠加)。恒以基类接收者查询(Emerald::Service.service_subclasses)。 + class << self + def service_subclasses + @service_subclasses ||= [] + end + + def inherited(subclass) + service_subclasses << subclass + super + end + end + def activate(ctx) @ctx = ctx @activated = true diff --git a/lib/emerald/shell.rb b/lib/emerald/shell.rb index 6691f55..5792630 100644 --- a/lib/emerald/shell.rb +++ b/lib/emerald/shell.rb @@ -46,8 +46,9 @@ class DesktopShell < Citrine::Component # 启动序列(渲染器外 = F6 安全区):与桌面无关的服务构造交给 # Emerald::Runtime(独立宿主同源,行为零分叉)——shell 只补桌面侧: - # 注册表/窗口管理器 → 注入 :launcher/:apps/:open_file → 内置应用注册 → - # 预装包 seed → /Applications 扫描注册 → 贡献点接线 → 全局快捷键 → + # 注册表/窗口管理器 → 注入 :launcher/:apps/:open_file/:launch_app → + # 内置应用注册 → 预装包 seed → /Applications 扫描注册 + 包内 Service + # 装载 → 贡献点接线 → 全局快捷键 → # 首渲染前应用主题(防闪变;Runtime 构造时已应用一次,此处按原序列收口)。 def initialize super() @@ -70,9 +71,14 @@ def initialize @services[:apps] = @registry # 别名:设置应用等按「应用列表」语义读取 @services[:open_file] = ->(path) { open_file_with(path) } @services[:reload_source] = ->(path) { on_app_source_saved(path) } + # 正规开窗服务(包内 App 经 ctx[:launch_app] 拉起兄弟窗口的能力面): + # 调用形态 launcher.call(:some_app, { endpoint: 'x' })——argv 收成 Hash, + # 展开为关键字参数的动作留在宿主侧(包内只见「id + 参数 Hash」一条契约)。 + @services[:launch_app] = ->(id, argv = {}) { launch_app(id, **argv) } register_builtin_apps seed_preinstalled_packages scan_installed_apps + register_package_services register_contributions register_system_commands @hub.activate_startup(@services) @@ -108,9 +114,24 @@ def scan_installed_apps end end + # 包内 Service 装载:本轮 scan/reload 中包 entry 定义的 Service 子类逐个 + # 注册 ServiceHub(包内不自启——激活是宿主能力),再幂等 activate_startup + # (on_startup 声明的服务随包安装/系统启动即拉起服务进程)。 + def register_package_services + @apphost.defined_services.each do |klass| + next if @hub.services.any? { |svc| svc.class == klass } + + @hub.register(klass) + end + @hub.activate_startup(@services) + end + # 包 manifest 贡献点接线(SPEC §4.1):commands → CommandRegistry + - # 快捷键;file_types → FileTypeRouter。v1 所有命令都归约为「打开该应用」 - # (命令体真正跑包内代码列 v1.1,见 PLAN 实施记录)。 + # 快捷键;file_types → FileTypeRouter。v1 所有命令都归约为「打开该应用的 + # 窗口」(命令体真正跑包内代码列 v1.1,见 PLAN 实施记录)——多 App 包的 + # 包 id 不是任何 App 的 app_id,归约目标按 contributed_command_app 三级 + # 取值;包内没有可开窗口的 App 时该命令不接线,改为一条 warning 通知 + # (不抛错:坏包不拖垮整机,见 PLAN §8)。 def register_contributions @installer.list.each do |entry| next unless entry['kind'] == 'app' @@ -121,10 +142,16 @@ def register_contributions rescue StandardError next end + declared_apps = command_app_fields(manifest) manifest.commands.each do |cmd| next if @commands.command?(cmd[:id]) - app_id = id.to_sym + app_id = contributed_command_app(id, declared_apps[cmd[:id]]) + if app_id.nil? + @notify.push("应用 #{id} 的命令 #{cmd[:id]} 未接线:包内没有可打开的应用窗口", + kind: :warning) + next + end @commands.register(cmd[:id], title: cmd[:title], hotkey: cmd[:hotkey]) { launch_app(app_id) } Emerald.hotkey.register(cmd[:hotkey]) { @commands.run(cmd[:id]) } if cmd[:hotkey] end @@ -132,6 +159,39 @@ def register_contributions end end + # 贡献命令的开窗目标(SPEC §4.1):cmd[:app](显式指明)→ 该包首个已注册 + # App(多 App 包的默认归约)→ 包 id(单 App 包 / bundled 预装包:类已随 + # bundle 定义,本次未求值、无映射)。解析不出已注册的 App 返回 nil,调用侧 + # 跳过并通知。 + def contributed_command_app(package_id, declared) + if declared + sym = declared.to_sym + # 显式指明却不在注册表(装载失败 / 被内置顶掉)→ 不静默开错窗口 + return registered_app?(sym) ? sym : nil + end + + @apphost.package_app_ids(package_id).find { |aid| registered_app?(aid) } || + (registered_app?(package_id.to_sym) ? package_id.to_sym : nil) + end + + def registered_app?(app_id) + @registry.apps.any? { |a| a[:id] == app_id } + end + + # contributes.commands[].app(可选,SPEC §4.1)→ { 命令 id => app 名 }。 + # Manifest#commands 的模型面只含 {id,title,hotkey}(声明式模型的公共面), + # 接线目标属宿主细节,这里从 manifest.data 回查(未知字段 Manifest 照收)。 + def command_app_fields(manifest) + list = manifest.data.dig('contributes', 'commands') + return {} unless list.is_a?(Array) + + list.each_with_object({}) do |c, h| + next unless c.is_a?(Hash) && c['id'].is_a?(String) && c['app'].is_a?(String) + + h[c['id']] = c['app'] + end + end + # 系统级命令:每个注册应用一个 app.(菜单/启动器/快捷键三处可达) def register_system_commands @registry.apps.each do |app| @@ -164,6 +224,7 @@ def install_and_register result = yield id = result[:manifest].id report = @apphost.reload(@registry, id) + register_package_services register_contributions case result[:status] when :installed then @notify.push("#{result[:manifest].name} 已安装", kind: :success) @@ -192,18 +253,21 @@ def install_vfs_emz(path) install_and_register { @installer.install_file(path, bytes) } end - # 卸载:先关该应用全部窗口(复用关闭链路),再删包与 lock + # 卸载:先关该应用全部窗口(复用关闭链路),再删包与 lock,最后清编译 + # 缓存(同 id 重装/换源时不复用旧产物;缓存的键是 id,包目录已删) def uninstall_package(id) @registry.each_running.select { |i| i.class.app_id == id.to_sym } .each { |i| close_window(i.win_id) } ok = @installer.uninstall(id) + @apphost.invalidate_cache(id) @commands.unregister("app.#{id}") @notify.push(ok ? "已卸载 #{id}" : "未安装 #{id}", kind: ok ? :success : :warning) ok end # Editor 保存钩子(services[:reload_source]):改动 /Applications 下的 - # 包源码 → 重编 + reopen 热更新该应用类 + # 包源码 → 重编 + reopen 热更新该应用类。包内 Service 与安装路径同款收口 + #(register_package_services:改源码新定义的服务同样进 hub)。 def on_app_source_saved(path) return unless path.start_with?("#{Emerald::Pkg::Installer::APPS_DIR}/") @@ -211,6 +275,7 @@ def on_app_source_saved(path) return if app_id.nil? || app_id.empty? report = @apphost.reload(@registry, app_id) + register_package_services if report[:status] == :failed @notify.push("应用 #{app_id} 重载失败(保留上一版本):#{report[:message]}", kind: :error) else diff --git a/test/pkg_apphost_test.rb b/test/pkg_apphost_test.rb index 3578e3a..cd6904f 100644 --- a/test/pkg_apphost_test.rb +++ b/test/pkg_apphost_test.rb @@ -28,6 +28,9 @@ def teardown Beryl::Timer.backend = nil Beryl::Timer.cancel_backend = nil remove_hello! + %i[DeskWorld DeskAgent DeskLink].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name, false) + end end def remove_hello! @@ -43,6 +46,33 @@ def default_src "class Hello < Emerald::App\n app_id :hello\nend\n" end + # 多 App 包夹具(AgentOS 桌面形态:包 id 与各 App id 都不同 + 一个服务) + DESK_MANIFEST = { + 'spec' => 1, 'kind' => 'app', 'id' => 'desk', 'name' => 'Desk', + 'version' => '0.1.0', 'entry' => 'src/main.rb', + }.freeze + + def desk_src + <<~RUBY + class DeskWorld < Emerald::App + app_id :desk_world + end + + class DeskAgent < Emerald::App + app_id :desk_agent + end + + class DeskLink < Emerald::Service + activation on_startup: true + end + RUBY + end + + def install_desk(src = desk_src) + files = { 'manifest.json' => JSON.generate(DESK_MANIFEST), 'src/main.rb' => src } + Emerald::Pkg::Installer.new(vfs: @vfs, lock: @lock).install_dir('./desk', files) + end + def new_host(opal_version: @opal, compiler: default_compiler, loader: default_loader) A.new(vfs: @vfs, lock: @lock, compiler: compiler, loader: loader, opal_version: opal_version) end @@ -152,12 +182,22 @@ def test_same_id_already_registered_skipped_builtin_priority assert_includes last_report[:message], '已注册' end - def test_app_id_mismatch_fails_isolated + # 多 App 包:entry 声明的 app_id 与包 id 不必一致(包 id 只作安装/缓存键) + def test_app_id_need_not_match_package_id install_hello("class Hello < Emerald::App\n app_id :other\nend\n") @host = new_host @host.scan(@registry) + assert_equal :registered, last_report[:status] + assert_includes @registry.apps.map { |a| a[:id] }, :other + end + + # 「至少一个新 App 子类」的要求保留:entry 一个 App 类都没定义才算失败 + def test_entry_without_app_subclass_fails_isolated + install_hello("class Hello\nend\n") + @host = new_host + @host.scan(@registry) assert_equal :failed, last_report[:status] - assert_includes last_report[:message], '不一致' + assert_includes last_report[:message], '未定义' assert_empty @registry.apps # 失败不拖垮整机 end @@ -176,6 +216,101 @@ def test_invalidate_cache_removes_artifacts refute @vfs.exist?('/System/Cache/hello.json') end + # ── 多 App 包与包内 Service(AgentOS 桌面形态)──────── + def test_multi_app_package_registers_every_app_subclass + install_desk + @host = new_host + @host.scan(@registry) + assert_equal :registered, last_report[:status] + assert_equal %i[hello desk_world desk_agent], @registry.apps.map { |a| a[:id] } + end + + def test_package_service_collected_into_defined_services + install_desk + @host = new_host + @host.scan(@registry) + assert_equal [DeskLink], @host.defined_services + assert DeskLink.activation_events[:startup], '包内服务自带激活声明(装载归 shell)' + end + + # 每轮 scan/reload 开始重置;本轮扫到的包按「新定义的类 → 包 id 内存映射」 + # 交付,两边都空则不入列(轮次之间不串场) + def test_defined_services_follow_current_scan_round + install_desk + @host = new_host + @host.scan(@registry) + assert_equal [DeskLink], @host.defined_services + + @host.scan(@registry) # 类已在(reopen 语义)→ 按包 id 回查映射仍交付 + assert_equal [DeskLink], @host.defined_services + assert_equal :registered, last_report[:status], '已注册的 App 跳过注册但不报错' + + @host.reload(@registry, 'hello') # 本轮只扫无 Service 的 hello → 重置后为空 + assert_empty @host.defined_services + end + + # reopen(同进程重复 eval,inherited 不再触发)后 defined_services 不得漏装 + # 包内 Service:Editor 热更新路径上宿主还要靠它重挂服务 + def test_defined_services_survives_reopen + install_desk + @host = new_host + @host.scan(@registry) + + @host.reload(@registry, 'desk') + assert_equal [DeskLink], @host.defined_services, 'reopen 后按包 id 回查服务类' + end + + def test_entry_without_app_subclass_contributes_no_service + install_desk("class DeskLink < Emerald::Service\n activation on_startup: true\nend\n") + @host = new_host + @host.scan(@registry) + assert_equal :failed, last_report[:status] + assert_empty @host.defined_services, '求值失败的包不得留下可装载的服务类' + end + + def test_reload_after_reopen_reuses_mapped_classes + install_desk + @host = new_host + @host.scan(@registry) + @vfs.write('/Applications/desk/src/main.rb', + "#{desk_src}\nclass DeskWorld\n def tag; :v2; end\nend\n") + + report = @host.reload(@registry, 'desk') + assert_equal :registered, report[:status], 'reopen 不产生新子类 → 按包 id 回查类列表' + assert_equal :v2, @registry.launch(:desk_world).tag + assert_equal [DeskLink], @host.defined_services, 'Service 同样按包 id 回查' + end + + # ── 包 → App id 映射(宿主接线贡献命令用)───────────── + def test_package_app_ids_lists_entry_apps_in_definition_order + install_desk + @host = new_host + @host.scan(@registry) + assert_equal %i[desk_world desk_agent], @host.package_app_ids('desk') + end + + def test_package_app_ids_empty_without_evaluation + install_desk + @host = new_host + assert_empty @host.package_app_ids('desk'), '未求值过(未 scan / bundled 跳过)→ 无映射' + assert_empty @host.package_app_ids('nope'), '未知包 → 空(调用侧自行保底)' + end + + def test_package_app_ids_survives_reopen + install_desk + @host = new_host + @host.scan(@registry) + @host.reload(@registry, 'desk') + assert_equal %i[desk_world desk_agent], @host.package_app_ids('desk') + end + + def test_package_app_ids_string_or_symbol_lookup + install_desk + @host = new_host + @host.scan(@registry) + assert_equal @host.package_app_ids('desk'), @host.package_app_ids(:desk) + end + # ── AppRegistry dispose → deactivate 链(§3.9 遗留修复)── def test_deactivate_runs_per_disposed_instance klass = Class.new(Emerald::App) do diff --git a/test/shell_e7_test.rb b/test/shell_e7_test.rb index d065552..dd01d7c 100644 --- a/test/shell_e7_test.rb +++ b/test/shell_e7_test.rb @@ -15,7 +15,9 @@ def setup end def teardown - Object.send(:remove_const, :ShE7App) if Object.const_defined?(:ShE7App, false) + %i[ShE7App ShE7World ShE7Agent ShE7DeskService ShE7LateService].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name, false) + end end # ── 预装(内置应用 = 预装应用)─────────────────────── @@ -86,6 +88,21 @@ def test_hot_reload_failure_keeps_previous_version_notified assert @shell.notify.count.positive? end + # 热更新路径也要挂包内 Service(与 install_and_register 一致):改源码新增的 + # Service 子类在 reload 后进 hub 并按其激活声明激活 + def test_editor_source_save_registers_new_package_service + install_desk_package + @shell.vfs.write('/Applications/she7-desk/src/main.rb', + "#{desk_src}\n" \ + "class ShE7LateService < Emerald::Service\n activation on_startup: true\nend\n") + + @shell.on_app_source_saved('/Applications/she7-desk/src/main.rb') + + svc = @shell.hub.services.find { |s| s.class == ShE7LateService } + refute_nil svc, '热更新新增的包内 Service 应注册进 ServiceHub' + assert svc.activated?, 'on_startup 声明随装载激活' + end + # ── 卸载 ─────────────────────────────────────────── def test_uninstall_closes_windows_and_removes_package install_she7 @@ -100,6 +117,99 @@ def test_uninstall_closes_windows_and_removes_package refute @shell.commands.command?('app.she7') end + def test_uninstall_invalidates_compile_cache + install_she7 + assert @shell.vfs.exist?('/System/Cache/she7.js') + + @shell.uninstall_package(:she7) + + refute @shell.vfs.exist?('/System/Cache/she7.js'), '卸载应清编译缓存' + refute @shell.vfs.exist?('/System/Cache/she7.json') + end + + # ── 多 App 包 + 包内 Service 装载(AgentOS 桌面形态)── + def test_install_package_with_multiple_apps_registers_all + install_desk_package + ids = @shell.registry.apps.map { |a| a[:id] } + assert_includes ids, :she7_world + assert_includes ids, :she7_agent + assert @shell.vfs.exist?('/System/Cache/she7-desk.js'), '多 App 包仍以包 id 为缓存键' + end + + def test_package_startup_service_registered_and_activated_with_shell_ctx + install_desk_package + svc = @shell.hub.services.find { |s| s.class == ShE7DeskService } + + refute_nil svc, '包内 Service 应由宿主注册进 ServiceHub' + assert svc.activated?, 'on_startup 声明的包内服务应随装载激活' + assert_same @shell.services, svc.last_ctx, '激活 ctx 即 shell 服务表' + end + + def test_register_package_services_is_idempotent + install_desk_package + @shell.register_package_services # 重复调用不得重复注册(hub 会 raise) + assert_equal 1, @shell.hub.services.count { |s| s.class == ShE7DeskService } + end + + # ── 贡献命令的多 App 包归约(SPEC §4.1)────────────── + # 包 id 不是任何 App 的 app_id:命令须落到包内窗口 App,否则运行即「未注册的应用」 + def test_multi_app_package_command_opens_package_window + install_desk_package + assert @shell.commands.command?('she7-desk.open') + + @shell.commands.run('she7-desk.open') + + assert @shell.registry.running?(:she7_world), '缺省取包内首个 App(定义序)' + assert_equal 1, @shell.wm.windows.size, '开出的窗口应已注册进窗口管理器' + end + + def test_contributed_command_app_field_selects_window + install_desk_package + @shell.commands.run('she7-desk.agent') + + assert @shell.registry.running?(:she7_agent), 'cmd[:app] 显式指明优先于包内首个 App' + refute @shell.registry.running?(:she7_world) + end + + def test_command_app_field_pointing_nowhere_skipped_with_warning + manifest = desk_manifest + manifest['contributes']['commands'][1]['app'] = 'she7_nope' + install_desk_package(manifest) + + refute @shell.commands.command?('she7-desk.agent'), '目标 App 未注册 → 命令不接线' + assert @shell.commands.command?('she7-desk.open'), '同包其它命令不受牵连' + assert notify_messages.any? { |m| m.include?('she7-desk.agent') }, '应发一条 warning' + end + + # 包内一个 App 都没注册成功(entry 求值失败):命令不接线 + warning,不抛错 + def test_command_without_any_registered_app_skipped_with_warning + install_desk_package(desk_manifest, "# 没有 App 类\n") + + refute @shell.commands.command?('she7-desk.open') + refute @shell.commands.command?('she7-desk.agent') + assert notify_messages.any? { |m| m.include?('she7-desk.open') }, '应发一条 warning' + end + + def notify_messages + @shell.notify.each.map { |note, _i| note['msg'] } + end + + # ── 开窗服务(包内 App 拉起兄弟窗口的能力面)────────── + def test_launch_app_service_opens_window_and_forwards_argv + install_she7("class ShE7App < Emerald::App\n app_id :she7\n def endpoint; argv[:endpoint]; end\nend\n") + inst = @shell.services[:launch_app].call(:she7, { endpoint: 'observer' }) + + assert_equal 'observer', inst.endpoint, 'argv Hash 应展开为关键字参数' + assert @shell.registry.running?(:she7) + assert_includes @shell.wm.windows, inst.win_id + end + + def test_launch_app_service_defaults_to_empty_argv + install_she7 + assert_equal({}, @shell.services[:launch_app].call(:she7).argv) + assert @shell.registry.running?(:she7) + end + # ── git 导入(浏览器无 fetcher → 明确报错通知)─────── def test_install_git_url_without_fetcher_notifies_error before = @shell.notify.count @@ -125,6 +235,52 @@ def install_she7(src = "class ShE7App < Emerald::App\n app_id :she7\nend\n") result end + # 多 App 包夹具(包 id 与各 App id 都不同:AgentOS 桌面形态),带一个服务; + # 两个贡献命令:一个不指明 app(归约到包内首个 App = World),一个显式 `app` + def desk_manifest + { + 'spec' => 1, 'kind' => 'app', 'id' => 'she7-desk', 'name' => 'E7 桌面', + 'version' => '0.1.0', 'entry' => 'src/main.rb', + 'contributes' => { + 'commands' => [ + { 'id' => 'she7-desk.open', 'title' => '打开桌面' }, + { 'id' => 'she7-desk.agent', 'title' => '打开 Agent 窗', 'app' => 'she7_agent' }, + ], + }, + } + end + + def desk_src + <<~RUBY + class ShE7World < Emerald::App + app_id :she7_world + end + + class ShE7Agent < Emerald::App + app_id :she7_agent + end + + class ShE7DeskService < Emerald::Service + activation on_startup: true + + attr_reader :last_ctx + + def activate(ctx) + super + @last_ctx = ctx + end + end + RUBY + end + + def install_desk_package(manifest = desk_manifest, src = desk_src) + bytes = build_zip('ShE7Desk' => [ + ['manifest.json', JSON.generate(manifest)], + ['src/main.rb', src], + ]) + @shell.install_package_bytes('./she7-desk.emz', bytes) + end + # 手写最小 zip(stored,够用即可;完整对拍见 pkg_zip_test.rb) def build_zip(by_top) u16 = ->(v) { [v].pack('v').bytes }