fix(desktop): 状态镜像到宿主侧,修复随机端口导致重启丢数据

This commit is contained in:
2026-11-02 18:20:16 +08:00
parent f98246ef0c
commit a870f74875
9 changed files with 523 additions and 113 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* 宿主侧状态镜像(本地补丁)。
*
* 背景:桌面端宿主每次启动都带 `--port 0`(随机端口),页面来源随之变化
* (如 127.0.0.1:2080 → 127.0.0.1:8494)。浏览器按来源隔离 localStorage /
* IndexedDB,换端口就等于换一个空库,项目/布局/外观重启后全丢。
*
* 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state),
* 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快),
* 宿主文件是跨重启的兜底。
*/
const ENDPOINT = '/api/worktable/state'
export type HostStateParts = { projects?: unknown; view?: unknown }
/** 宿主镜像里可能带的写入时间戳(毫秒) */
export type HostState = HostStateParts & { updatedAt?: number }
/** 拉取宿主侧镜像;没有/不可用都返回 null(不抛错,不影响工作台本身) */
export async function loadHostState(): Promise<HostState | null> {
try {
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
if (!r.ok) return null
const j: any = await r.json()
return j && typeof j === 'object' && j.state && typeof j.state === 'object' ? (j.state as HostState) : null
} catch {
return null
}
}
/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */
let parts: HostStateParts = {}
let timer: any = null
let inflight = false
let dirty = false
/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */
let hydrated = false
function schedule() {
if (timer) return
timer = setTimeout(() => {
timer = null
void flush()
}, 400)
}
async function flush(): Promise<void> {
if (!hydrated || inflight) {
if (inflight) dirty = true
return
}
if (!parts.projects && !parts.view) return
inflight = true
try {
await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ state: { ...parts, updatedAt: Date.now() } }),
keepalive: true,
})
} catch {
// 宿主不可用时静默:localStorage 仍是本次会话的真值
} finally {
inflight = false
if (dirty) {
dirty = false
schedule()
}
}
}
/** 记下要镜像的部分;400ms 内多次调用合并成一次 POST */
export function queueHostSave(patch: HostStateParts): void {
parts = { ...parts, ...patch }
if (!hydrated) return
schedule()
}
/** 首次恢复判定结束:之后再放行宿主写入 */
export function markHydrated(): void {
hydrated = true
}
/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */
export function flushHostSave(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
void flush()
}
+72 -8
View File
@@ -12,6 +12,7 @@ import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, m
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
import { loadHostState, queueHostSave, markHydrated, flushHostSave } from './hostState'
/**
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
@@ -124,6 +125,8 @@ type ProjectsState = {
const PERSIST_KEY = 'dsh.worktable.view.v1'
const PROJECTS_KEY = 'dsh.worktable.projects.v1'
/** 本地最后写入时间(毫秒):与宿主镜像的 updatedAt 比较,决定启动时以哪边为准 */
const SAVED_AT_KEY = 'dsh.worktable.savedAt.v1'
const MIN_TOP = 56
const SNAP_PX = 32
/** 插件市场外链(GitHub 仓库,已核实可访问;PRD 提及的 dshfind.com 未验证,不用死链)。 */
@@ -308,11 +311,11 @@ function normHsl(v: any): { h: number; s: number; l: number } | undefined {
}
}
function loadView(): ViewState {
/** 视图状态归一化(本地存档与宿主镜像共用同一套字段校验) */
function normalizeView(raw: any): ViewState {
try {
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return { ...DEFAULT_VIEW }
const p = JSON.parse(raw)
if (!raw || typeof raw !== 'object') return { ...DEFAULT_VIEW }
const p = raw
// 显式挑字段:旧版遗留的 groupBy 等未知字段直接忽略。
// 一次性迁移:v2 起默认「手动」排序(最近排序点击即置顶、体验差),旧存「最近」回落为「手动」;
// 之后用户手动选择「最近」会写入 sortMigratedV2 标记并被尊重。
@@ -356,11 +359,21 @@ function loadView(): ViewState {
}
}
function loadProjects(): ProjectsState {
function loadView(): ViewState {
try {
const raw = localStorage.getItem(PROJECTS_KEY)
if (!raw) return { ...DEFAULT_PROJECTS }
const p = JSON.parse(raw)
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return { ...DEFAULT_VIEW }
return normalizeView(JSON.parse(raw))
} catch {
return { ...DEFAULT_VIEW }
}
}
/** 项目状态归一化(本地存档与宿主镜像共用同一套字段校验) */
function normalizeProjects(raw: any): ProjectsState {
try {
if (!raw || typeof raw !== 'object') return { ...DEFAULT_PROJECTS }
const p = raw
return {
order: Array.isArray(p.order) ? p.order.filter((x: unknown): x is string => typeof x === 'string') : [],
lastUsed: p.lastUsed && typeof p.lastUsed === 'object' ? p.lastUsed : {},
@@ -383,6 +396,21 @@ function loadProjects(): ProjectsState {
}
}
function loadProjects(): ProjectsState {
try {
const raw = localStorage.getItem(PROJECTS_KEY)
if (!raw) return { ...DEFAULT_PROJECTS }
return normalizeProjects(JSON.parse(raw))
} catch {
return { ...DEFAULT_PROJECTS }
}
}
/** 记下本地写入时间,供启动时与宿主镜像比较新鲜度 */
function markSaved(): void {
try { localStorage.setItem(SAVED_AT_KEY, String(Date.now())) } catch {}
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
type FloatRect = { top: number }
@@ -1380,6 +1408,8 @@ function WorktableSection(props: any) {
setView((prev) => {
const next = { ...prev, ...patch }
try { localStorage.setItem(PERSIST_KEY, JSON.stringify({ ...next, sortMigratedV2: true })) } catch {}
markSaved()
queueHostSave({ view: next })
return next
})
}
@@ -1388,10 +1418,44 @@ function WorktableSection(props: any) {
setProjects((prev) => {
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch }
try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)) } catch {}
markSaved()
queueHostSave({ projects: next })
return next
})
}
/**
* 宿主侧状态恢复(本地补丁):桌面端每次启动端口随机,localStorage 按来源隔离,
* 重启后本地必然是空的。这里比对「本地最后写入时间」与「宿主镜像 updatedAt」,
* 只在宿主更新时回填,避免用旧镜像盖掉当前页面刚改的内容。
* 恢复判定结束前不写宿主(markHydrated),防止首屏默认值先把镜像冲掉。
*/
useEffect(() => {
let cancelled = false
void (async () => {
const host = await loadHostState()
if (cancelled) return
let localAt = 0
try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {}
const hostAt = typeof host?.updatedAt === 'number' ? host.updatedAt : 0
if (host && hostAt > localAt) {
if (host.projects) persistProjects(normalizeProjects(host.projects))
if (host.view) persistView(normalizeView(host.view))
}
markHydrated()
if (hostAt <= localAt) flushHostSave()
})()
const flush = () => flushHostSave()
window.addEventListener('pagehide', flush)
window.addEventListener('beforeunload', flush)
return () => {
cancelled = true
window.removeEventListener('pagehide', flush)
window.removeEventListener('beforeunload', flush)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 自愈:启动时若控制室存档是坏布局(旧版关掉控制室标签造成窗格退化成选择器),立即重建默认面板
useEffect(() => {
const saved = projects.views[CONSOLE_ID]
+43
View File
@@ -544,5 +544,48 @@ export function apply(ctx: Context) {
},
})
/**
* 工作台状态宿主侧镜像(本地补丁)。
*
* 桌面端宿主以 `--port 0` 启动,端口每次随机,页面来源跟着变;而 localStorage 按来源隔离,
* 于是「新建的项目重启就没了」。这里把项目/视图这份 JSON 落到 DSH_HOME 下的固定文件:
* 跨重启、跨端口稳定。客户端启动时比对时间戳决定是否回填(见 client/hostState.ts)。
*/
const statePath = () => pathResolve(resolveDshHomeSafe(), 'worktable-state.json')
register({
kind: 'exact',
path: '/api/worktable/state',
handler: async (req: any, res: any) => {
const file = statePath()
if (req.method === 'GET' || req.method === 'HEAD') {
try {
const raw = await readFile(file, 'utf8')
const text = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw
json(res, 200, { ok: true, path: file, state: JSON.parse(text) })
} catch (err: any) {
if (err?.code === 'ENOENT') { json(res, 200, { ok: true, path: file, state: null }); return }
json(res, 500, { error: String(err) })
}
return
}
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
try {
const body = await readJsonBody(req)
const state = body && typeof body.state === 'object' && body.state !== null ? body.state : null
if (!state) { json(res, 400, { error: 'missing state' }); return }
const text = JSON.stringify(state)
if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: 'state too large' }); return }
const fsx = await import('node:fs/promises')
await fsx.mkdir(dirname(file), { recursive: true })
const tmp = file + '.tmp-' + process.pid + '-' + Date.now()
await fsx.writeFile(tmp, text, 'utf8')
await fsx.rename(tmp, file)
json(res, 200, { ok: true, path: file })
} catch (err: any) {
json(res, err?.status ?? 500, { error: String(err) })
}
},
})
setupTerminal(webServer, ctx)
}