Files
2026-10-03 00:01:12 +08:00

641 lines
22 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--[[----------------------------------------------------------------------------
OS4 Icons —— S5 图标包 -> S4 eSIM
界面按 CSS 设计稿 1:1 落地(本机用真 MiSans 字体渲染预览验证过):
* 页面底色纯黑(取自封面图四角)
* rgba(255,255,255,0.6) -> #999999(白 60% 叠黑)
* font-weight 600 -> MiSans-Semibold ; 500 -> MiSans-Medium
* vertical-align:middle -> 手工算 y(LVGL 标签没有垂直居中样式)
* 多色文本用 LVGL recolor:"灰#ffffff 白#灰"
* 圆屏安全区:实测所有页面 0 像素越界(大按钮全靠 40px 圆角收进圆内)
真机已验证的底层能力:
* 宿主不调用 ui.init -> 加载期建界面
* SCRIPT_PATH 为空 -> 用 debug.getinfo 反推目录
* .face 不解包,资源仍在 resource.bin 里 -> 按偏移读出来用
* /dev/app 可写,dd 读写校验通过
* 没有 utf8 标准库 -> 自带按位 UTF-8 解码(本文件未用到,留作提醒)
------------------------------------------------------------------------------]]
local lvgl = require("lvgl")
local W, H = 466, 466
local DEV, BLK = "/dev/app", 4
local TMPH = "/tmp/os4_head.bin"
local TMPR = "/tmp/os4_back.bin"
-- 等长覆盖只保证「偏移」512 对齐;RLE 素材的长度常常不是 4 的倍数(如 1635 / 3047),
-- 那种记录必须退化成 bs=1(偏移本身对齐,所以 bs=1 也精确),否则会多写相邻文件的字节。
local function ddBlk(off, len)
if off % BLK == 0 and len % BLK == 0 then return BLK end
return 1
end
local TMPB = "/tmp/os4_blob.bin"
--------------------------------------------------------------------- 自身目录
local SRC = ""
if debug and debug.getinfo then
local info = debug.getinfo(1, "S")
SRC = (info and info.source) or ""
end
if type(SRC) ~= "string" then SRC = "" end
if SRC:sub(1, 1) == "@" then SRC = SRC:sub(2) end
SRC = SRC:gsub("\\", "/")
local THIS_DIR = SRC:match("^(.*)/[^/]+$") or ""
local SP = THIS_DIR ~= "" and (THIS_DIR .. "/") or ""
local PARENT = SP:gsub("[^/]+/$", "")
local RES = nil
do
local c = { PARENT .. "resource.bin", SP .. "../resource.bin", SP .. "resource.bin" }
for i = 1, #c do
local f = io.open(c[i], "rb")
if f then f:close(); RES = c[i]; break end
end
end
--------------------------------------------------------------------- 资源偏移
-- UI 图片 { resource.bin 内偏移, 长度, 名字 } —— 由 _tools/gen_ui_lua.py 回填
local UIIMG = {
__UIIMG__
}
-- 负载表 { resource 内偏移, ROMFS 目标偏移, 长度 } —— 同样由脚本回填
local PAYTBL = {
__PAY__
__RB__
}
local function prepImages()
local paths = {}
if not RES then return paths end
local f = io.open(RES, "rb")
if not f then return paths end
for i = 1, #UIIMG do
local e = UIIMG[i]
f:seek("set", e[1])
local d = f:read(e[2])
if d and #d == e[2] then
-- 直接写到 /tmp 根下:不能写子目录(目录不存在,且这台机器的 Lua 没有 mkdir)
local p = "/tmp/ui_" .. e[3] .. ".bin"
local o = io.open(p, "wb")
if o then o:write(d); o:close(); paths[e[3]] = p end
end
end
f:close()
return paths
end
local IMG = prepImages()
--------------------------------------------------------------------- 颜色与字体
local C_GRAY, C_WHITE = '#999999', '#ffffff'
local C_OK, C_ERR = '#43cf7c', '#d43030'
local C_PLATE, C_CIRC = 0x002927, 0x4f4f4f
local function getFont(name, size)
local f = nil
pcall(function() f = lvgl.Font(name, size) end)
return f
end
local F_CLOCK = getFont('MiSans-Semibold', 28)
local F_TITLE = getFont('MiSans-Medium', 30)
local F_ITEM = getFont('MiSans-Medium', 28)
local F_BODY = getFont('MiSans-Medium', 24)
local F_VER = getFont('MiSans-Medium', 20)
--------------------------------------------------------------------- 按下高亮
-- onPressed / onClicked 是真实的 LVGL 对象方法(就在 onevent 旁边,属性表里可查)
local HL = 0x005149 -- 按下高亮色(比底板 0x002927 明显亮)
local pressed = nil -- { obj = , t = }
local tickCount = 0
local function setBg(obj, c)
pcall(function() obj:set { bg_color = c } end)
end
local function pressRelease()
if pressed then
setBg(pressed.obj, C_PLATE)
pressed = nil
end
end
--------------------------------------------------------------------- 基础
local ui = {}
local built = false
local pages = {}
local clocks = {}
local job = { run = false, list = nil, i = 0, errs = 0, variant = 'replace' }
-- 从哪个入口进"开始替换"确认页,确认按钮就执行对应操作:
-- 首页「开始替换」 -> 'replace'
-- 帮助页「回滚修改」 -> 'rollback'(也要先过一遍警告确认,不直接开跑)
local pendingVariant = 'replace'
-- 确认页是从哪进来的,返回就回哪:
-- 首页「开始替换」 -> 'home' ; 帮助页「回滚修改」 -> 'help'
local confirmFrom = 'home'
local function newPage()
local p = nil
pcall(function()
p = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
bg_opa = 255, border_width = 0, pad_all = 0 })
p:clear_flag(lvgl.FLAG.SCROLLABLE)
p:add_flag(lvgl.FLAG.EVENT_BUBBLE)
end)
return p
end
local function show(p)
if not p then return end
pressRelease() -- 切页时清掉可能残留的高亮
for k, v in pairs(pages) do
pcall(function() v:add_flag(lvgl.FLAG.HIDDEN) end)
end
pcall(function() p:clear_flag(lvgl.FLAG.HIDDEN) end)
end
local function bind(obj, fn)
if not obj then return end
-- 成品样本用的是 onevent(lvgl.EVENT.SHORT_CLICKED, fn)(CLICKED 这个常量
-- 在这台机器上不存在),所以按 SHORT_CLICKED -> onClicked -> CLICKED 依次试
local ev = lvgl.EVENT
if ev and ev.SHORT_CLICKED then
if pcall(function() obj:onevent(ev.SHORT_CLICKED, function() fn() end) end) then return end
end
if pcall(function() obj:onClicked(function() fn() end) end) then return end
pcall(function() obj:onevent(ev and ev.CLICKED, fn) end)
end
--------------------------------------------------------------------- 点按
-- 点按 = 按下高亮 + 抬起恢复 + 执行动作;带超时兜底(手指滑出时 click 不触发)
local function bindTap(obj, onTap)
if not obj then return end
local ok = pcall(function()
obj:onPressed(function()
setBg(obj, HL)
pressed = { obj = obj, t = tickCount }
end)
obj:onClicked(function()
pressRelease()
if onTap then onTap() end
end)
end)
if not ok then bind(obj, onTap) end
end
local function setClickable(o)
if not o then return end
pcall(function() o:add_flag(lvgl.FLAG.CLICKABLE) end)
end
local function lab(parent, o)
o.text_font = o.text_font or F_BODY
local l = nil
pcall(function() l = parent:Label(o) end)
return l
end
local function clockLabel(parent)
-- 居中不用 text_align(它要的是 lv_text_align_t:LEFT=0/CENTER=1/RIGHT=2,
-- 传 ALIGN.CENTER=9 越界会被忽略 -> 实测变成左对齐)。
-- 改为按真字体量出的文本宽度手工算 x,并把 w 收紧,这样对齐方式就不影响结果。
local l = lab(parent, { x = 195, y = 3, w = 86, h = 45, text = '--:--',
text_color = C_GRAY, font_size = 28, text_font = F_CLOCK })
clocks[#clocks + 1] = l
return l
end
local function pageTitle(parent, text, x, w)
-- 标题 y:框 y=34 h=45,字号 30 行高约 41 -> 34 + (45-41)/2 = 36
return lab(parent, { x = x, y = 36, w = w, h = 45, text = text,
text_color = C_WHITE, font_size = 30, text_font = F_TITLE })
end
local function backArrow(parent, x, onTap)
local im = nil
if IMG.back then
pcall(function() im = parent:Image { src = IMG.back, x = x or 160, y = 43 } end)
setClickable(im)
bind(im, onTap)
end
return im
end
-- 列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题
-- pad_all=0 是关键:LVGL 主题会给 Object 加内边距,不清零的话子元素会整体右下偏移
local function listItem(parent, y, iconkey, title, onTap)
local plate = nil
pcall(function()
plate = parent:Object { x = 49, y = y, w = 368, h = 88, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 34 }
end)
if plate then
if onTap then
setClickable(plate)
bindTap(plate, onTap)
end
pcall(function()
plate:Object { x = 20, y = 19, w = 50, h = 50,
bg_color = C_CIRC, border_width = 0, radius = 25 }
end)
if IMG[iconkey] then
pcall(function() plate:Image { src = IMG[iconkey], x = 30, y = 29 } end)
end
-- 标题框 h=54,字号 28 行高约 38 -> 垂直居中 y = 17 + 8 = 25(左对齐,不用 text_align)
lab(plate, { x = 80, y = 25, w = 208, h = 54, text = title,
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
end
return plate
end
local function bigButton(parent, text)
local b = nil
pcall(function()
b = parent:Object { x = 108, y = 365, w = 250, h = 80, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 40 }
end)
if b then setClickable(b) end
-- 按钮文字:框 (65,13,120,54) 相对底板;字号 28 行高约 38 -> y = 13 + 8 = 21
-- "确认替换"/"重启手表" 量得 112px -> 居中 x = 65 + (120-112)/2 = 69
lab(b, { x = 69, y = 21, w = 122, h = 54, text = text,
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
return b
end
local function clockTick()
local t = '--:--'
if os and type(os.date) == "function" then
local ok, s = pcall(os.date, "%H:%M")
if ok and type(s) == "string" then t = s end
end
for i = 1, #clocks do
pcall(function() clocks[i]:set { text = t } end)
end
end
--------------------------------------------------------------------- 外壳
local function slurp(p, n)
local f = io.open(p, "rb")
if not f then return nil end
local d = f:read(n or "*a")
f:close()
return d
end
local function sh(cmd)
if type(os.execute) ~= "function" then return -1 end
local a, b, c = os.execute(cmd)
if type(a) == "number" then return a end
if a == true then return 0 end
if type(c) == "number" then return c end
if type(b) == "number" then return b end
return -1
end
--------------------------------------------------------------------- 预检查
local desc1 = nil
local function setDesc1(txt, color)
if not desc1 then return end
pcall(function() desc1:set { text = txt, text_color = color } end)
end
local function doCheck()
if not RES then
setDesc1('找不到 resource.bin,无法操作。', C_ERR)
return false
end
if sh("dd if=" .. DEV .. " of=/tmp/os4_hdr.bin bs=1 count=48") ~= 0 then
setDesc1('读取分区失败(dd 不可用或无权限)。', C_ERR)
return false
end
local h = slurp("/tmp/os4_hdr.bin", 48)
if not h or #h < 48 then
setDesc1('读不到分区头。', C_ERR)
return false
end
if h:sub(1, 8) ~= "-rom1fs-" then
setDesc1('预检查失败:分区头不是 ROMFS。', C_ERR)
return false
end
local z = h:find("\0", 17, true)
local vol = h:sub(17, (z or 33) - 1)
if vol ~= "app" then
setDesc1('预检查失败:卷名是 ' .. vol .. '。', C_ERR)
return false
end
local size = string.unpack("<I4", h, 9)
local list = PAYTBL.pay or {}
if #list == 0 then
setDesc1('预检查失败:负载表为空。', C_ERR)
return false
end
local bad, total = 0, 0
for i = 1, #list do
local r = list[i]
local b = ddBlk(r[2], r[3])
if (r[2] % b) ~= 0 or (r[3] % b) ~= 0 then bad = bad + 1 end
if (r[2] + r[3]) > size then bad = bad + 1 end
total = total + r[3]
end
if bad > 0 then
setDesc1(string.format('预检查失败:%d 项越界或未对齐。', bad), C_ERR)
return false
end
-- 顺带确认 resource.bin 第一条能读出来
local f = io.open(RES, "rb")
if f then
f:seek("set", list[1][1])
local probe = f:read(list[1][3])
f:close()
if not probe or #probe ~= list[1][3] then
setDesc1('预检查失败:resource.bin 读取异常。', C_ERR)
return false
end
end
setDesc1(string.format('预检查通过 | %d 条 | %.2fMB\n您可以返回主页开始修改。',
#list, total / 1048576), C_OK)
return true
end
--------------------------------------------------------------------- 替换
local logLabel = nil
local rebootBtn = nil
local logLines = {}
local function logOut(s)
logLines[#logLines + 1] = s
while #logLines > 8 do table.remove(logLines, 1) end
if logLabel then
pcall(function() logLabel:set { text = table.concat(logLines, "\n") } end)
end
end
local function showReboot()
if rebootBtn then pcall(function() rebootBtn:clear_flag(lvgl.FLAG.HIDDEN) end) end
end
local function readChunk(off, len, path, bsz)
if os.remove then pcall(os.remove, path) end
sh(string.format("dd if=%s of=%s bs=%d skip=%d count=%d",
DEV, path, bsz, off // bsz, len // bsz))
return slurp(path, len)
end
-- 每条三步:① 版本校验(设备上那段必须等于「原厂」或「替换后」)② 写入 ③ 整段回读比对
local function writeOne(rec, want)
local f = io.open(RES, "rb")
if not f then return 'err' end
f:seek("set", rec[1])
local data = f:read(rec[3])
f:close()
if not data or #data ~= rec[3] then return 'err' end
if want then
local n = math.min(64, rec[3], want[3])
local wf = io.open(RES, "rb")
wf:seek("set", want[1])
local wdata = wf:read(n)
wf:close()
local cur = readChunk(rec[2], n, TMPH, 1)
if not cur or #cur ~= n or not wdata or #wdata ~= n then return 'err' end
if cur ~= data:sub(1, n) and cur ~= wdata then return 'ver' end
end
local o = io.open(TMPB, "wb")
if not o then return 'err' end
o:write(data)
o:close()
local b = ddBlk(rec[2], rec[3])
sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d",
TMPB, DEV, b, rec[2] // b, rec[3] // b))
local back = readChunk(rec[2], rec[3], TMPR, b)
if not back or #back ~= rec[3] or back ~= data then return 'err' end
return 'ok'
end
local function startJob(variant)
if job.run then return end
local list = (variant == 'rollback') and PAYTBL.rb or PAYTBL.pay
if not list or #list == 0 then
setDesc1('负载表为空,已中止。', C_ERR)
return
end
job.run, job.list, job.i, job.errs, job.variant = true, list, 0, 0, variant
logLines = {}
logOut(variant == 'rollback' and '> 开始回滚' or '> 开始替换')
logOut('> 读取 resource.bin')
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
show(pages.running)
end
local function tick()
tickCount = tickCount + 1
-- 兜底:按下超过 1.6s 没收到 click(手指滑出等)就恢复高亮
if pressed and (tickCount - pressed.t) > 40 then pressRelease() end
if not job.run or not job.list then return end
job.i = job.i + 1
local n = #job.list
if job.i > n then
job.run = false
local s = (job.variant == 'rollback')
and '> 回滚完毕,请重启手表。'
or '> 替换完毕,请重启手表。若发现问题,请回滚修改。'
logOut(s)
logOut(string.format('> 共 %d 条,失败 %d 条%s', n, job.errs,
(job.vers or 0) > 0 and string.format(',版本不符跳过 %d 条', job.vers) or ''))
showReboot()
return
end
local rb = (job.variant == 'rollback') and PAYTBL.pay or PAYTBL.rb
local r = writeOne(job.list[job.i], rb and rb[job.i])
if r == 'ver' then
job.vers = (job.vers or 0) + 1
elseif r ~= 'ok' then
job.errs = job.errs + 1
end
if job.i % 4 == 0 or job.i == n then
logOut(string.format('> 写入 %d/%d 失败 %d', job.i, n, job.errs))
end
end
--------------------------------------------------------------------- 页面
local function buildHome()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 盘内标题 = 显示名:'S4e Pt.1' 30px 量得 118 -> x = (466-118)/2 = 174
pageTitle(p, 'S4e Pt.1', 174, 120)
listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end)
listItem(p, 181, 'ic_replace', '开始替换', function()
pendingVariant = 'replace'
confirmFrom = 'home'
show(pages.confirm)
end)
listItem(p, 277, 'ic_about', '关于', function() show(pages.about) end)
return p
end
local function buildHelp()
local p = newPage()
if not p then return nil end
clockLabel(p)
backArrow(p, 160, function() show(pages.home) end)
local t = pageTitle(p, '快速帮助', 183, 130)
setClickable(t)
bind(t, function() show(pages.home) end)
listItem(p, 85, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 65, y = 176, w = 338, h = 72,
text = '检查原始资源状态、分区可写情况等。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
-- 回滚修改:不直接开跑,同样先跳到"开始替换"确认页,确认后执行的是回滚
listItem(p, 256, 'ic_rollback', '回滚修改', function()
pendingVariant = 'rollback'
confirmFrom = 'help'
show(pages.confirm)
end)
lab(p, { x = 65, y = 344, w = 334, h = 72,
text = '将图标恢复到系统原始样式。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
return p
end
local function buildConfirm()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 确认页的返回:回到进来时的那个页面(首页 或 快速帮助)
local function goBack() show(pages[confirmFrom] or pages.home) end
backArrow(p, 160, goBack)
local t = pageTitle(p, '开始替换', 183, 130)
setClickable(t)
bind(t, goBack)
-- 多色文本**不能**用 recolor:AP 里那几处 recolor 是图片重着色(bg_img_recolor)
-- 和表盘编辑器的主题重着色,不是文本属性;实测标记会被原样显示出来。
-- 改为拆成多个标签 + 手工算 x(宽度用真 MiSans 24px 量出)。
-- 折行也显式写死:因为"替换图标包之前,请先确认自己"量得 336px,几乎等于 338px 框宽,
-- 且型号那行 500px 必然折行,只有写死才能让多色片段位置确定。
lab(p, { x = 64, y = 90, w = 338, h = 70,
text = '替换图标包之前,请先确认自己\n的系统版本号为 ',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
lab(p, { x = 241, y = 123, w = 104, h = 33, text = '3.202.79',
text_color = C_WHITE, font_size = 24, text_font = F_BODY })
lab(p, { x = 340, y = 123, w = 30, h = 33, text = '!',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
lab(p, { x = 64, y = 189, w = 338, h = 33, text = '请确认自己的手表型号为',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
lab(p, { x = 64, y = 222, w = 338, h = 70,
text = 'Xiaomi Watch S4 eSIM(非\n15 周年纪念版)',
text_color = C_WHITE, font_size = 24, text_font = F_BODY })
lab(p, { x = 242, y = 255, w = 30, h = 33, text = '!',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
lab(p, { x = 64, y = 321, w = 338, h = 33, text = '搞机有风险,变砖后果自负。',
text_color = C_GRAY, font_size = 24, text_font = F_BODY })
local b = bigButton(p, '确认替换')
bindTap(b, function() startJob(pendingVariant) end)
return p
end
local function buildAbout()
local p = newPage()
if not p then return nil end
clockLabel(p)
backArrow(p, 191, function() show(pages.home) end)
local t = pageTitle(p, '关于', 214, 64)
setClickable(t)
bind(t, function() show(pages.home) end)
-- 信息卡:底板 + 圆形 logo + 大标题 + 版本号
pcall(function()
p:Object { x = 52, y = 79, w = 362, h = 204, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 30 }
end)
if IMG.logo then
pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end)
end
-- 'S4e Pt.1' 28px 量得 109 -> 居中 x = 233 - 109/2 = 178.5 -> 178
lab(p, { x = 178, y = 192, w = 111, h = 38, text = 'S4e Pt.1',
text_color = C_WHITE, font_size = 28, text_font = F_ITEM })
-- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210
lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0',
text_color = C_GRAY, font_size = 20, text_font = F_VER })
-- QQ 联系方式:结构与列表项一致(无动作,所以不给它绑点击)
listItem(p, 291, 'ic_qq', '732681995', nil)
return p
end
local function buildRunning()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 本页没有返回图标(规格:顶栏同首页),所以标题要屏幕居中:
-- "正在替换" 30px 量得 120px -> x = 233 - 120/2 = 173(不是帮助页的 183)
pageTitle(p, '正在替换', 173, 130)
logLabel = lab(p, { x = 64, y = 90, w = 338, h = 269, text = '',
text_color = C_WHITE, font_size = 24, text_font = F_BODY })
rebootBtn = bigButton(p, '重启手表')
-- 替换过程中不显示,替换完再显示
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
bindTap(rebootBtn, function()
logOut('> 正在重启…')
sh("sync")
sh("reboot")
end)
return p
end
--------------------------------------------------------------------- 组装
local function build()
if built then return end
built = true
pages.home = buildHome()
pages.help = buildHelp()
pages.confirm = buildConfirm()
pages.about = buildAbout()
pages.running = buildRunning()
if pages.home then show(pages.home) end
clockTick()
pcall(function()
ui.timer = lvgl.Timer { period = 40, repeat_count = -1, cb = function() tick() end }
if ui.timer and ui.timer.resume then ui.timer:resume() end
end)
pcall(function()
ui.clockTimer = lvgl.Timer { period = 1000, repeat_count = -1,
cb = function() clockTick() end }
if ui.clockTimer and ui.clockTimer.resume then ui.clockTimer:resume() end
end)
end
local okbuild, errbuild = pcall(build)
if not okbuild then
pcall(function()
local r = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
bg_opa = 255, border_width = 0 })
r:Label { x = 20, y = 20, w = W - 40, h = H - 40,
text = 'BUILD FAILED:\n' .. tostring(errbuild),
text_color = '#ffffff', font_size = 13, text_font = F_BODY }
end)
end
ui.init = function()
pcall(build)
return ui.root
end
function pageOnPause()
if ui.timer then pcall(function() ui.timer:pause() end) end
if ui.clockTimer then pcall(function() ui.clockTimer:pause() end) end
end
function pageOnResume()
if ui.timer then pcall(function() ui.timer:resume() end) end
if ui.clockTimer then pcall(function() ui.clockTimer:resume() end) end
end
return ui