Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ build/
Gemfile.lock
examples/*.js
examples/standalone/*.js
examples/*.emz
19 changes: 16 additions & 3 deletions docs/SPEC-package-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),为将来强制执行与审计留数据。
Expand All @@ -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" }]
},
Expand All @@ -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)
Expand Down
68 changes: 55 additions & 13 deletions lib/emerald/pkg/apphost.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) → 可执行文本
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
# 单应用失败不拖垮整机(坏包/坏源码/缺编译器都到这);本次失败的
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions lib/emerald/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 72 additions & 7 deletions lib/emerald/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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'
Expand All @@ -121,17 +142,56 @@ 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
manifest.file_types.each { |ext| @router.register(ext, id) }
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.<id>(菜单/启动器/快捷键三处可达)
def register_system_commands
@registry.apps.each do |app|
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -192,25 +253,29 @@ 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}/")

app_id = path.split('/')[2]
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
Expand Down
Loading