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