fix(shell): 去掉所有窗口级玻璃(白框/黑块根因);feat: 多会话切换 + 文件桥(桌面壳 HTTP 网关 403 的解法)

白框根因:SetWindowCompositionAttribute(Win10 亚克力) / DwmExtendFrameIntoClientArea(客户区变玻璃)
/ WebView2 Transparent 背景 三者在真机上都会让 8px 边环露白、WebView2 变黑。现在窗口永远由
应用自绘深色,背景效果只作为页面侧半透明(不再碰 DWM 与窗口 alpha)。

新增:
- 会话切换:标题栏 ⇄ 按钮 + 列表(会话标题/轮/步/消息数,可'跟随最新');宿主侧 pinnedSessionId,
  被选中的会话不再被其它会话的事件抢走;snapshot 里带 sessions[]
- 文件桥:桌面壳对未认证请求一律 403(实测 6 种请求形态),浮窗改走文件——宿主每秒写 snapshot.json,
  浮窗渲染;浮窗的 chat/watch/newSession 写 outbox.json,宿主轮询执行。端到端实测通过
- 编码修复:快照按显式 UTF-8 读(PS 5.1 Get-Content -Raw 会按 GBK 读坏中文 JSON);
  outbox 写 UTF-8 无 BOM
This commit is contained in:
2026-09-27 17:02:07 +08:00
parent 387f9a0012
commit 7d1417fa1c
3 changed files with 248 additions and 22 deletions
+87 -5
View File
@@ -233,6 +233,7 @@ export function apply(ctx, config) {
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件)
let lastSessionId = null
let pinnedSessionId = null // 用户在浮窗里选中的会话:多会话并行时不再被最新事件抢走
function counter(id) {
let c = counts.get(id)
@@ -243,7 +244,7 @@ export function apply(ctx, config) {
ctx.on('session/event', (session, event) => {
try {
const id = session?.id || session?.sessionId || 'unknown'
lastSessionId = id
if (!pinnedSessionId) lastSessionId = id
const c = counter(id)
const type = String(event?.type || '')
const d2 = () => event?.data || event || {}
@@ -360,17 +361,31 @@ export function apply(ctx, config) {
function snapshot() {
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
const id = session?.id || lastSessionId
const activeId = pinnedSessionId || lastSessionId
let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null
const id = session?.id || activeId
return {
ok: true,
at: Date.now(),
sessionId: id || null,
pinned: pinnedSessionId || null,
title: (id && titles.get(id)) || session?.meta?.title || session?.title || null,
messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
settings: floatCfg,
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
// 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随)
sessions: sessions.slice(-20).map((s) => {
const sid = s?.id
const c = counts.get(sid) || {}
return {
id: sid,
title: titles.get(sid) || s?.meta?.title || s?.title || null,
turns: c.turns || 0,
steps: c.steps || 0,
messages: (rows.get(sid) || []).length,
active: sid === id,
}
}),
}
}
@@ -382,9 +397,76 @@ export function apply(ctx, config) {
const payload = `data: ${JSON.stringify(snapshot())}\n\n`
for (const res of sseClients) { try { res.write(payload) } catch { } }
}
const tick = setInterval(() => { push(); if (++tickCount % 10 === 0) writeStatus() }, 1000)
// ---------------------------------------------------------------- 文件桥
// 桌面壳(DSH Desktop)的 HTTP 网关会把未认证请求全部挡掉(实测 6 种请求形态都是 403),
// 而浮窗是独立进程 + file:// 页面,拿不到壳的 auth cookie。所以除了 HTTP/SSE,
// 再开一条纯文件通道:宿主每秒写 snapshot.json,浮窗每秒读;浮窗要发消息就写 outbox.json。
const snapshotFile = path.join(dshHome(), 'float-window', 'snapshot.json')
const outboxFile = path.join(dshHome(), 'float-window', 'outbox.json')
const writeSnapshotFile = () => {
try {
const snap = snapshot()
// 镜像只留最近 40 条,避免文件太大
if (Array.isArray(snap.messages) && snap.messages.length > 40) snap.messages = snap.messages.slice(-40)
fs.mkdirSync(path.dirname(snapshotFile), { recursive: true })
fs.writeFileSync(snapshotFile, JSON.stringify(snap))
} catch { }
}
const tick = setInterval(() => {
push()
writeSnapshotFile()
if (++tickCount % 10 === 0) writeStatus()
}, 1000)
ctx.effect(() => () => clearInterval(tick))
// 浮窗 → 宿主:轮询 outbox.json
let lastOutboxText = ''
const outboxTimer = setInterval(async () => {
try {
if (!fs.existsSync(outboxFile)) return
// 容错:外部写入可能带 UTF-8 BOM
const text = fs.readFileSync(outboxFile, 'utf8').replace(/^\uFEFF/, '')
if (!text || text === lastOutboxText) return
lastOutboxText = text
fs.rmSync(outboxFile, { force: true })
const msg = JSON.parse(text)
log('outbox: ' + short(msg, 140))
if (msg.cmd === 'chat' && msg.text) {
const target = msg.sessionId || lastSessionId
if (!target) { log('outbox chat skipped: no session yet'); return }
const { value, via } = await hostCall('prompt', {
sessionId: target,
mode: 'queue',
content: [{ type: 'text', text: String(msg.text).slice(0, 20000) }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
log(`outbox chat -> ${target} via ${via}: ${short(value, 120)}`)
} else if (msg.cmd === 'window' && msg.action) {
await windowAction(msg.action)
} else if (msg.cmd === 'watch') {
// 浮窗切换跟随的会话:'' / null 表示回到"跟随最新"
pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null
if (pinnedSessionId) lastSessionId = pinnedSessionId
log('watch -> ' + (pinnedSessionId || 'latest'))
} else if (msg.cmd === 'session' && msg.action === 'create') {
const { value } = await hostCall('create', {
sessionId: randomUUID(),
...(msg.cwd ? { cwd: String(msg.cwd) } : {}),
...(msg.agentPreset ? { agentPreset: String(msg.agentPreset) } : {}),
})
if (value?.sessionId) lastSessionId = value.sessionId
}
writeSnapshotFile()
} catch (e) { log('outbox failed: ' + short(e?.data || e?.message || e, 180)) }
}, 700)
ctx.effect(() => () => {
clearInterval(outboxTimer)
try { fs.rmSync(snapshotFile, { force: true }) } catch { }
try { fs.rmSync(outboxFile, { force: true }) } catch { }
})
const handler = async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1')
const cors = {