diff --git a/examples/desktop.html b/examples/desktop.html
index 8c2bcd1..5cb8e89 100644
--- a/examples/desktop.html
+++ b/examples/desktop.html
@@ -233,6 +233,12 @@
.about-title { font-size: 20px; font-weight: 700; }
.about-version { color: var(--dim); font-variant-numeric: tabular-nums; }
.about-kernel { color: var(--dim); line-height: 1.6; }
+
+ /* 设置 · 应用管理 */
+ .settings-hint { color: var(--dim); font-size: 12px; }
+ .installed-app-row { display: flex; align-items: center; gap: 8px;
+ padding: 4px 6px; border-radius: var(--radius); }
+ .installed-app-row:hover { background: #ffffff0d; }
diff --git a/examples/desktop.rb b/examples/desktop.rb
index d2e0a08..cb1bcb9 100644
--- a/examples/desktop.rb
+++ b/examples/desktop.rb
@@ -18,3 +18,19 @@
# 浏览器验收便利(E7):console 里可经 window.EmeraldShell 调安装流
`window.EmeraldShell = shell` if defined?(Opal)
+# (包文件须能从 dev server 同源取到——放 examples/ 目录即可;file:// 打包
+# 产物受浏览器 CORS 限制,此入口仅在 dev server/http 下可用)
+if defined?(Opal)
+ %x{
+ var m = location.search.match(/[?&]install=([^&]+)/);
+ if (m) {
+ fetch(decodeURIComponent(m[1]))
+ .then(function(r) { return r.arrayBuffer(); })
+ .then(function(buf) {
+ var u = new Uint8Array(buf), bytes = new Array(u.length);
+ for (var i = 0; i < u.length; i++) { bytes[i] = u[i]; }
+ #{shell.install_package_bytes(`decodeURIComponent(m[1])`, `bytes`)};
+ });
+ }
+ }
+end
diff --git a/lib/emerald.rb b/lib/emerald.rb
index 51c128b..d20e69c 100644
--- a/lib/emerald.rb
+++ b/lib/emerald.rb
@@ -12,6 +12,7 @@ module Emerald
require 'citrine'
require 'beryl'
require_relative 'emerald/storage'
+require_relative 'emerald/file_pick'
require_relative 'emerald/settings'
require_relative 'emerald/vfs'
require_relative 'emerald/router'
diff --git a/lib/emerald/apps/settings.rb b/lib/emerald/apps/settings.rb
index eaf44e9..d0ddcba 100644
--- a/lib/emerald/apps/settings.rb
+++ b/lib/emerald/apps/settings.rb
@@ -49,6 +49,8 @@ class Settings < Emerald::App
state :density_open, default: false
state :wallpaper_open, default: false
state :clear_dialog_open, default: false
+ # 应用管理列表刷新计数:安装/卸载回调里 +1,驱动本区重渲染重读 installed_list
+ state :apps_rev, default: 0
# beryl 受控件的 value 约定传 Signal:这里把 store 的某个 key 包成
# 只读信号壳(.get → store.get,订阅语义原样保留)。
@@ -106,6 +108,7 @@ def view
appearance_section
wallpaper_section
storage_section
+ apps_management_section
about_section
nil
end
@@ -244,6 +247,61 @@ def format_bytes(n)
format('%.1f MB', kb / 1024.0)
end
+ # ── 应用管理(E7):安装 .emz / 已安装列表 / 卸载 ──────────
+ #
+ # 安装入口 = 浏览器原生文件选择器(Emerald::FilePick,仅 Opal);
+ # 卸载/安装动作经 services[:install_bytes]/[:uninstall] 回到 shell 的
+ # F6 守卫管线(装完自动扫描注册 + 通知)。apps_rev 计数驱动本区重读列表
+ # (installed_list 是普通 lambda 无订阅语义,受控 state 补上刷新边界)。
+
+ def apps_management_section
+ group('应用管理') do
+ apps_rev = signal(:apps_rev).get # 订阅:安装/卸载后重读列表
+ row(gap: 10) do
+ button(css_class: 'b-btn', on_click: ->(_e) { pick_and_install }) { '安装 .emz 应用包…' }
+ label(css_class: 'settings-hint') { '选择 .emz 包安装到 /Applications' }
+ end
+ installed_apps_rows(apps_rev)
+ nil
+ end
+ end
+
+ # 事件回调路径:FilePick 的 change/FileReader 都是原生异步回调,
+ # 不在 Effect 内——install_and_register 的 F6 守卫天然满足
+ def pick_and_install
+ installer = ctx && ctx[:install_bytes]
+ return unless installer
+
+ Emerald::FilePick.pick(accept: '.emz') do |filename, bytes|
+ installer.call(filename, bytes)
+ self.apps_rev += 1
+ end
+ end
+
+ def installed_apps_rows(_rev)
+ list = ctx && ctx[:installed_list] && ctx[:installed_list].call
+ if list.nil? || list.empty?
+ label(css_class: 'settings-hint') { '尚未安装第三方应用' }
+ return
+ end
+ list.each do |entry|
+ row(css_class: 'installed-app-row', gap: 8) do
+ label { "#{entry['id']} · v#{entry['version']}" }
+ label(css_class: 'settings-hint') { entry['bundled'] ? '预装' : '第三方' }
+ box(style: { flex: 1 })
+ unless entry['bundled']
+ button(css_class: 'b-btn danger', on_click: ->(_e) { uninstall_app(entry['id']) }) { '卸载' }
+ end
+ end
+ end
+ nil
+ end
+
+ def uninstall_app(id)
+ ctx[:uninstall]&.call(id)
+ self.apps_rev += 1
+ end
+
def about_section
group('关于') do
label { 'Emerald OS · 设置' }
diff --git a/lib/emerald/file_pick.rb b/lib/emerald/file_pick.rb
new file mode 100644
index 0000000..44bcd15
--- /dev/null
+++ b/lib/emerald/file_pick.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+# backtick_javascript: true
+
+module Emerald
+ # 浏览器原生文件选择器(仅 Opal):pick 触发系统文件对话框,选中后把文件
+ # 读成字节 Array 回给回调。Settings「应用管理」安装 .emz 的入口。
+ # CRuby 下返回 nil(无对话框可弹;单测只验证渲染,不验证选择流)。
+ module FilePick
+ class << self
+ # pick(accept: '.emz') { |filename, bytes| ... } → true(Opal)/ nil(CRuby)
+ def pick(accept: '*', &callback)
+ return nil unless defined?(Opal)
+
+ %x{
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = #{accept};
+ input.onchange = function() {
+ var f = input.files[0];
+ if (!f) { return; }
+ var reader = new FileReader();
+ reader.onload = function() {
+ var buf = new Uint8Array(reader.result);
+ var bytes = new Array(buf.length);
+ for (var i = 0; i < buf.length; i++) { bytes[i] = buf[i]; }
+ #{callback.call(`f.name`, `bytes`)};
+ };
+ reader.readAsArrayBuffer(f);
+ };
+ input.click();
+ }
+ true
+ end
+ end
+ end
+end
diff --git a/lib/emerald/pkg/zip.rb b/lib/emerald/pkg/zip.rb
index 98ac6ad..39325e2 100644
--- a/lib/emerald/pkg/zip.rb
+++ b/lib/emerald/pkg/zip.rb
@@ -2,10 +2,12 @@
module Emerald
module Pkg
- # 最小 ZIP 读取器(docs/SPEC-package-format.md §2 容器 .emz)。
+ # 最小 ZIP 读取器/写出器(docs/SPEC-package-format.md §2 容器 .emz)。
# 只读:解析 central directory → 逐条目回本地头取数据,
# 支持 stored(0) / deflate(8)——覆盖常规打包工具(zip/7z/GitHub archive)
- # 的输出。写入侧不需要(包由作者用常规工具打包)。
+ # 的输出。写入侧为 dogfood 打包工具链补的最小实现(Zip.write):
+ # 只产 stored(0) 条目——纯 Ruby 无 Zlib 依赖(字节底座须 CRuby/Opal 同构,
+ # 见 pkg.rb 头注),源文本包体积代价可接受;deflate 包的读取不受影响。
# 不支持:ZIP64、加密、多卷、data descriptor 以外的极端形态——全部明确报错。
# 输入输出均为字节 Array(Entries 的 data 已解压)。
class Zip
@@ -20,6 +22,7 @@ class Zip
SIG_EOCD = [0x50, 0x4b, 0x05, 0x06].freeze # 'PK\x05\x06'
EOCD_MIN = 22
COMMENT_MAX = 0xffff
+ FLAG_UTF8 = 0x0800 # 通用标志 bit 11:文件名/注释为 UTF-8
class << self
# 字节 Array(.emz 全量内容)=> [Entry](跳过目录条目)
@@ -38,6 +41,60 @@ def read(bytes)
entries
end
+ # [Entry](name UTF-8 名 / data 字节 Array)=> 字节 Array(合法 zip)。
+ # stored-only:CRC-32 用纯 Ruby 的 Crc32 计算;目录条目(data 为空、
+ # 名以 / 结尾)按 zip 惯例写 stored,读取侧本就跳过它们。
+ def write(entries)
+ body = []
+ central = []
+ Array(entries).each do |entry|
+ data = entry.data || []
+ nb = entry.name.to_s.b.bytes
+ raise Unsupported, '条目名含 NUL,无法写入 zip' if nb.include?(0)
+
+ crc = Crc32.of(data)
+ lho = body.size
+ body.concat(SIG_LOCAL)
+ .concat(u16le(20)) # version needed
+ .concat(u16le(FLAG_UTF8))
+ .concat(u16le(0)) # method: stored
+ .concat(u16le(0)).concat(u16le(0)) # mod time/date(不保留时间戳)
+ .concat(u32le(crc))
+ .concat(u32le(data.size)) # csize
+ .concat(u32le(data.size)) # usize
+ .concat(u16le(nb.size))
+ .concat(u16le(0)) # extra len
+ .concat(nb)
+ .concat(data)
+ central << [nb, data.size, crc, lho]
+ end
+
+ cd_offset = body.size
+ central.each do |nb, size, crc, lho|
+ body.concat(SIG_CENTRAL)
+ .concat(u16le(20)) # version made by
+ .concat(u16le(20)) # version needed
+ .concat(u16le(FLAG_UTF8))
+ .concat(u16le(0)) # method: stored
+ .concat(u16le(0)).concat(u16le(0)) # mod time/date
+ .concat(u32le(crc))
+ .concat(u32le(size)).concat(u32le(size))
+ .concat(u16le(nb.size))
+ .concat(u16le(0)).concat(u16le(0)) # extra/comment len
+ .concat(u16le(0)).concat(u16le(0)) # disk/internal attr
+ .concat(u32le(0)) # external attr
+ .concat(u32le(lho))
+ .concat(nb)
+ end
+ cd_size = body.size - cd_offset
+ body.concat(SIG_EOCD)
+ .concat(u16le(0)).concat(u16le(0)) # disk / cd 起始 disk
+ .concat(u16le(central.size)).concat(u16le(central.size))
+ .concat(u32le(cd_size)).concat(u32le(cd_offset))
+ .concat(u16le(0)) # comment len
+ body
+ end
+
private
# 从尾部扫描 EOCD(含 comment;长度一致性校验排除误命中)
@@ -103,10 +160,36 @@ def u32(bytes, pos)
bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24)
end
+ # 小端编码(写出侧;Opal 无 Array#pack——字节底座纪律同 pkg.rb 头注)
+ def u16le(v)
+ [v & 0xff, (v >> 8) & 0xff]
+ end
+
+ def u32le(v)
+ [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]
+ end
+
def raise_unsupported_zip64
raise Unsupported, 'ZIP64 包不支持(>4GB 或超 65535 条目)'
end
end
end
+
+ # CRC-32(ISO 3309 / IEEE 802.3,ZIP 的 CRC 形态):表驱动纯 Ruby 实现。
+ # Opal 无 Zlib——与 Inflate/Sha256 同构(CRuby 测试用 Zlib.crc32 对拍)。
+ module Crc32
+ POLY = 0xEDB88320
+ TABLE = (0..255).map do |i|
+ c = i
+ 8.times { c = (c & 1).zero? ? c >> 1 : (c >> 1) ^ POLY }
+ c
+ end.freeze
+
+ def self.of(bytes)
+ crc = 0xFFFFFFFF
+ bytes.each { |b| crc = (crc >> 8) ^ TABLE[(crc ^ b) & 0xFF] }
+ (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF
+ end
+ end
end
end
diff --git a/lib/emerald/shell.rb b/lib/emerald/shell.rb
index 6b70ce4..8126df2 100644
--- a/lib/emerald/shell.rb
+++ b/lib/emerald/shell.rb
@@ -75,6 +75,12 @@ def initialize
# 调用形态 launcher.call(:some_app, { endpoint: 'x' })——argv 收成 Hash,
# 展开为关键字参数的动作留在宿主侧(包内只见「id + 参数 Hash」一条契约)。
@services[:launch_app] = ->(id, argv = {}) { launch_app(id, **argv) }
+ # 包管理能力面(Settings「应用管理」区等宿主消费;事件回调专用——
+ # 经 F6 守卫的 install_and_register/uninstall_package 进入)
+ @services[:install_bytes] = ->(filename, bytes) { install_package_bytes(filename, bytes) }
+ @services[:install_git] = ->(url) { install_git_url(url) }
+ @services[:uninstall] = ->(id) { uninstall_package(id) }
+ @services[:installed_list] = -> { @installer.list }
register_builtin_apps
seed_preinstalled_packages
scan_installed_apps
@@ -425,7 +431,11 @@ def file_icon(node)
# 图标块:纵向(图形/emoji + 文字),单击选中(受控选择集)、双击启动
def icon_tile(key, glyph:, name:, on_open:)
- box(css_class: icon_tile_class(key),
+ # css_class 用响应式 Proc(G-2):选中态只 patch 本节点类名、不重建节点——
+ # 立即求值会让第一击的选中重渲染替换整个 tile,第二击落在全新 DOM 节点上,
+ # 浏览器连击计数清零 → 原生 dblclick 永不触发 → 双击启动失灵(表现为
+ # 「图标高亮了但窗口不开」)
+ box(css_class: -> { icon_tile_class(key) },
on_click: ->(_e) { self.selected_icons = [key] },
on_dblclick: on_open) do
box(css_class: 'd-icon-glyph') { glyph }
diff --git a/test/app_management_test.rb b/test/app_management_test.rb
new file mode 100644
index 0000000..11b9de0
--- /dev/null
+++ b/test/app_management_test.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# 应用管理(Settings「应用管理」区 + FilePick CRuby 语义 + shell 包管理能力面)
+require 'minitest/autorun'
+require 'emerald'
+
+class AppManagementTest < Minitest::Test
+ def render(component)
+ Citrine.render(component)
+ end
+
+ # ── FilePick:CRuby 下无对话框可弹,返回 nil 且无副作用 ──
+
+ def test_file_pick_is_noop_on_cruby
+ called = false
+ result = Emerald::FilePick.pick(accept: '.emz') { |_fn, _bytes| called = true }
+
+ assert_nil result
+ refute called
+ end
+
+ # ── Settings 应用管理区:ctx 容忍与列表渲染 ──────────────────
+
+ def test_settings_renders_management_section_without_ctx
+ html = render(Emerald::Apps::Settings.new)
+ assert_includes html, '应用管理'
+ assert_includes html, '安装 .emz 应用包'
+ assert_includes html, '尚未安装第三方应用' # ctx 缺失 → 空态
+ end
+
+ def test_settings_lists_installed_apps_and_uninstall_button
+ s = Emerald::Apps::Settings.new
+ s.boot(installed_list: lambda {
+ [{ 'id' => 'scientific-calculator', 'version' => '1.0.0', 'bundled' => false },
+ { 'id' => 'about', 'version' => '1.0.0', 'bundled' => true }]
+ }, uninstall: ->(_id) {})
+
+ html = render(s)
+ assert_includes html, 'scientific-calculator · v1.0.0'
+ assert_includes html, '第三方'
+ assert_includes html, '预装'
+ assert_includes html, '卸载'
+ end
+
+ def test_settings_uninstall_invokes_service_and_bumps_rev
+ uninstalled = []
+ s = Emerald::Apps::Settings.new
+ s.boot(installed_list: -> { [] }, uninstall: ->(id) { uninstalled << id })
+
+ s.send(:uninstall_app, 'scientific-calculator')
+
+ assert_equal ['scientific-calculator'], uninstalled
+ assert_equal 1, s.apps_rev
+ end
+
+ # ── shell 能力面:包管理四键可调用 ──────────────────────────
+
+ def test_shell_exposes_package_management_services
+ shell = Emerald::DesktopShell.new
+ svc = shell.services
+
+ assert_respond_to svc[:install_bytes], :call
+ assert_respond_to svc[:install_git], :call
+ assert_respond_to svc[:uninstall], :call
+ assert_kind_of Array, svc[:installed_list].call
+ end
+end
diff --git a/test/pkg_zip_test.rb b/test/pkg_zip_test.rb
index 1316c71..330f0d4 100644
--- a/test/pkg_zip_test.rb
+++ b/test/pkg_zip_test.rb
@@ -176,3 +176,64 @@ def find_sub(bytes, sig)
(0...(bytes.size - 3)).reverse_each.find { |i| bytes[i, 4] == sig }
end
end
+
+# Zip.write / Crc32 对拍(同一文件,复用 Zlib fixture 纪律):写出 → 自读回环 +
+# 头部字段(CRC/长度/偏移)逐项对 Zlib/构造参数断言
+class PkgZipWriteTest < Minitest::Test
+ Z = Emerald::Pkg::Zip
+
+ def test_crc32_matches_zlib
+ c = Emerald::Pkg::Crc32
+ assert_equal Zlib.crc32(''), c.of([])
+ assert_equal Zlib.crc32('hello zip'), c.of('hello zip'.bytes)
+ binary = (0..255).map { |i| i }
+ assert_equal Zlib.crc32(binary.pack('C*')), c.of(binary)
+ assert_equal Zlib.crc32('a' * 10_000), c.of(('a' * 10_000).bytes)
+ end
+
+ def test_write_read_roundtrip
+ entries = [
+ Z::Entry.new('manifest.json', false, '{"id":"x"}'.b.bytes),
+ Z::Entry.new('src/main.rb', false, "puts 'hi'\n".b.bytes),
+ Z::Entry.new('文档/说明.txt', false, '内容'.b.bytes),
+ ]
+ read_back = Z.read(Z.write(entries))
+ assert_equal entries.map(&:name), read_back.map(&:name)
+ entries.each_with_index { |e, i| assert_equal e.data, read_back[i].data }
+ end
+
+ def test_write_empty_archive
+ assert_empty Z.read(Z.write([]))
+ end
+
+ def test_written_zip_signatures_and_sizes
+ bytes = Z.write([Z::Entry.new('a.txt', false, 'hello'.bytes)])
+ assert_equal [0x50, 0x4b, 0x03, 0x04], bytes[0, 4]
+ assert_equal [0x50, 0x4b, 0x05, 0x06], bytes[-22, 4], 'EOCD 应收尾(无 comment)'
+
+ # local 头:crc @+14、csize @+18、usize @+22
+ assert_equal Zlib.crc32('hello'), bytes[14, 4].pack('C*').unpack1('V')
+ assert_equal 5, bytes[18, 4].pack('C*').unpack1('V')
+ assert_equal 5, bytes[22, 4].pack('C*').unpack1('V')
+ # central 头:method stored、crc/csize/usize 与 local 一致、lho 指回 0
+ cd = find_sub(bytes, [0x50, 0x4b, 0x01, 0x02])
+ assert_equal 0, bytes[cd + 10, 2].pack('C*').unpack1('v')
+ assert_equal Zlib.crc32('hello'), bytes[cd + 16, 4].pack('C*').unpack1('V')
+ assert_equal 5, bytes[cd + 20, 4].pack('C*').unpack1('V')
+ assert_equal 0, bytes[cd + 42, 4].pack('C*').unpack1('V')
+ end
+
+ def test_written_zip_interops_with_zlib_inflater_not_needed
+ # stored 条目数据段原样存放:本地头(30 + 名长)之后即内容
+ bytes = Z.write([Z::Entry.new('src/main.rb', false, "x = 1\n".bytes)])
+ assert_equal "x = 1\n".bytes, bytes[30 + 'src/main.rb'.size, 6]
+ end
+
+ def test_nul_in_name_raises
+ assert_raises(Z::Unsupported) { Z.write([Z::Entry.new("a\0b", false, 'x'.bytes)]) }
+ end
+
+ def find_sub(bytes, sig)
+ (0...(bytes.size - 3)).reverse_each.find { |i| bytes[i, 4] == sig }
+ end
+end