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
6 changes: 6 additions & 0 deletions examples/desktop.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
Expand Down
16 changes: 16 additions & 0 deletions examples/desktop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/emerald.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
58 changes: 58 additions & 0 deletions lib/emerald/apps/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,订阅语义原样保留)。
Expand Down Expand Up @@ -106,6 +108,7 @@ def view
appearance_section
wallpaper_section
storage_section
apps_management_section
about_section
nil
end
Expand Down Expand Up @@ -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 · 设置' }
Expand Down
36 changes: 36 additions & 0 deletions lib/emerald/file_pick.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# backtick_javascript: true

module Emerald
# 浏览器原生文件选择器(仅 Opal):pick 触发系统文件对话框,选中后把文件
# 读成字节 Array<Integer> 回给回调。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
87 changes: 85 additions & 2 deletions lib/emerald/pkg/zip.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Integer>(Entries 的 data 已解压)。
class Zip
Expand All @@ -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<Integer>(.emz 全量内容)=> [Entry](跳过目录条目)
Expand All @@ -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;长度一致性校验排除误命中)
Expand Down Expand Up @@ -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
12 changes: 11 additions & 1 deletion lib/emerald/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
67 changes: 67 additions & 0 deletions test/app_management_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading