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 = {
+47 -17
View File
@@ -67,6 +67,9 @@ $alreadyRunning = $false
try { $null = [System.Threading.Mutex]::OpenExisting($mutexName); $alreadyRunning = $true } catch { $alreadyRunning = $false }
$commandFile = Join-Path $stateDir 'command.json'
$aliveFile = Join-Path $stateDir 'alive.json'
# 文件桥:插件每秒写 snapshot.json(桌面壳 HTTP 网关 403,浮窗只能走文件)
$snapshotFile = Join-Path $DshHome 'float-window\snapshot.json'
$outboxFile = Join-Path $DshHome 'float-window\outbox.json'
if ($alreadyRunning) {
if ($Action) {
try {
@@ -127,6 +130,7 @@ namespace DshFloat
[StructLayout(LayoutKind.Sequential)] struct WINCOMPDATA { public int Attrib; public IntPtr Data; public int Size; }
[DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr h, int attr, ref int val, int size);
[DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int val, int size);
[DllImport("dwmapi.dll")] static extern int DwmExtendFrameIntoClientArea(IntPtr h, ref MARGINS m);
[DllImport("user32.dll")] static extern int SetWindowCompositionAttribute(IntPtr h, ref WINCOMPDATA d);
@@ -135,12 +139,17 @@ namespace DshFloat
BackdropMode = mode;
int type = mode == 1 ? 2 : (mode == 2 ? 3 : 0); // DWMSBT_MAINWINDOW / TRANSIENTWINDOW / AUTO
int hr = DwmSetWindowAttribute(Handle, 38, ref type, 4); // DWMWA_SYSTEMBACKDROP_TYPE
int got = -1;
int hrGet = DwmGetWindowAttribute(Handle, 38, out got, 4);
bool effective = mode == 0 ? true : (hr == 0 && hrGet == 0 && got == type);
// 关键 1:**不再**把客户区扩展成"玻璃"(真机上会让 8px 边环与 WebView2 露出白底)
// 关键 2:**不要**再用 SetWindowCompositionAttribute 的 ACCENT_ENABLE_ACRYLICBLURBEHIND,
// 那是 Win10 时代的未文档化做法,Win11 上会把窗口变成纯黑(实测 PrintWindow 全黑)。
// 现在窗口始终由应用自己画深色,系统背景只当装饰,"霜化"观感交给页面自己的半透明。
MARGINS m = new MARGINS();
if (mode != 0) { m.l = -1; m.r = -1; m.t = -1; m.b = -1; }
DwmExtendFrameIntoClientArea(Handle, ref m);
SetAccent(mode == 3 ? 4 : 0, mode == 0 ? 0 : unchecked((int)0x99202028));
Invalidate(true);
return "mode=" + mode + " hr=" + hr;
return "mode=" + mode + " hr=" + hr + " got=" + got + " effective=" + effective;
}
void SetAccent(int state, int color)
@@ -156,16 +165,15 @@ namespace DshFloat
Marshal.FreeHGlobal(p);
}
// 始终由应用自己画背景(深色):玻璃模式下 DWM 若没生效也不会露出白底
protected override void OnPaintBackground(PaintEventArgs e)
{
if (BackdropMode == 0) base.OnPaintBackground(e);
base.OnPaintBackground(e);
}
protected override void WndProc(ref Message m)
{
const int WM_NCHITTEST = 0x84;
const int WM_ERASEBKGND = 0x14;
if (m.Msg == WM_ERASEBKGND && BackdropMode != 0) { m.Result = (IntPtr)1; return; }
if (m.Msg == WM_NCHITTEST && AllowResize)
{
long lp = m.LParam.ToInt64();
@@ -275,20 +283,27 @@ function Apply-Exclude {
function Apply-Backdrop([string]$Mode) {
if (-not $Mode) { $Mode = 'opaque' }
# 真机结论:这台机器上任何"窗口级"的系统背景(DWM 系统背景类型 / Win10 亚克力 /
# 把客户区扩展成玻璃)都会让 WebView2 内容变黑、8px 边环露白底(用户原话"边框有白色的框")。
# 所以窗口**永远**保持应用自己绘制的深色,"背景效果"只作为页面侧的半透明/霜化观感,
# 页面 alpha 由不透明度滑杆控制。
$script:state.backdrop = $Mode
$code = switch ($Mode) { 'mica' { 1 } 'acrylic' { 2 } 'blur' { 3 } 'translucent' { 0 } default { 0 } }
try {
$info = $form.ApplyBackdrop($code)
Log "backdrop $Mode -> $info"
} catch { Log "backdrop ERROR: $($_.Exception.Message)" }
# 玻璃模式必须让 WebView2 背景透明,否则页面自己把桌面糊住了
if ($wv.CoreWebView2) {
try {
$wv.DefaultBackgroundColor = if ($code -eq 0) { [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } else { [System.Drawing.Color]::Transparent }
} catch { Log "bg color ERROR: $($_.Exception.Message)" }
try { $wv.DefaultBackgroundColor = [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } catch { }
}
# 半透明:整窗 alpha(WebView2 是子窗口,这条路径观感一般,作为兜底)
if ($Mode -eq 'translucent') { Apply-Opacity $script:state.opacity } else { Set-WindowAlpha 1.0 }
Set-WindowAlpha 1.0
Log "backdrop $Mode (page-level only)"
Push-State
}
# 页面 -> 宿主:写 outbox.json(UTF-8 无 BOM,宿主侧 JSON.parse 才吃得下)
function Write-Outbox([hashtable]$Msg) {
try {
$Msg.at = (Get-Date).ToString('o')
$json = $Msg | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($outboxFile, $json, (New-Object System.Text.UTF8Encoding($false)))
Log "outbox $($Msg.cmd) ($($json.Length)B)"
} catch { Log "outbox write failed: $($_.Exception.Message)" }
}
function Set-WindowAlpha([double]$value) {
@@ -371,6 +386,9 @@ $onWebMessage = {
'backdrop' { Apply-Backdrop ([string]$m.value); Save-State; Push-State }
'scale' { $script:state.scale = [double]$m.value; $wv.ZoomFactor = [double]$m.value; Save-State; Push-State }
'reload' { $wv.Reload() }
'chat' { Write-Outbox @{ cmd = 'chat'; text = [string]$m.text } }
'watch' { Write-Outbox @{ cmd = 'watch'; sessionId = [string]$m.sessionId } }
'newSession' { Write-Outbox @{ cmd = 'session'; action = 'create' } }
'openExternal' { if ($m.url -match '^https?://') { Start-Process $m.url } }
}
}
@@ -391,6 +409,7 @@ function Invoke-Cmd([string]$c) {
$script:lastEndpointText = ''
$script:lastCommandText = ''
$script:lastSnapshotText = ''
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 700
$timer.Add_Tick({
@@ -401,6 +420,17 @@ $timer.Add_Tick({
if ($t -ne $script:lastEndpointText) { $script:lastEndpointText = $t; Send-Endpoint }
}
} catch {}
# 文件桥:宿主每秒写 snapshot.json(桌面壳的 HTTP 网关会 403,浮窗只能走文件)
# 必须显式按 UTF-8 读:PS 5.1 的 Get-Content -Raw 会按 GBK 解码,中文摘要会破坏 JSON
try {
if (Test-Path $snapshotFile) {
$s = [System.IO.File]::ReadAllText($snapshotFile, [System.Text.Encoding]::UTF8)
if ($s -and $s -ne $script:lastSnapshotText) {
$script:lastSnapshotText = $s
Send-ToPage @{ type = 'snapshot'; data = ($s | ConvertFrom-Json) }
}
}
} catch { Log "snapshot error: $($_.Exception.Message)" }
# 插件/命令行下发的动作
try {
if (Test-Path $commandFile) {
+114
View File
@@ -357,6 +357,64 @@
display: block;
}
/* ---------------- 会话切换器 ---------------- */
#sessList {
position: absolute;
left: 8px;
right: 8px;
top: 36px;
max-height: 60%;
overflow-y: auto;
background: #171b22;
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .5);
display: none;
z-index: 25;
}
#sessList.show {
display: block;
}
#sessList .srow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
}
#sessList .srow:hover {
background: var(--bg-chip);
}
#sessList .srow.act {
background: var(--accent-soft);
}
#sessList .srow .st {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#sessList .srow .sm {
flex: 0 0 auto;
color: var(--fg-dim);
font-size: 11px;
}
#sessList .head {
color: var(--fg-dim);
font-size: 11px;
padding: 2px 8px 6px;
}
#settings h4 {
margin: 0 0 8px;
font-size: 12px;
@@ -482,6 +540,11 @@
<div id="bar">
<div class="dot wait" id="dot"></div>
<div id="title"><span id="titleText">DSH 悬浮窗</span><small id="sub">未连接</small></div>
<button class="tbtn" id="bSess" title="切换要镜像的会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 8h13l-3-3M20 16H7l3 3" />
</svg>
</button>
<button class="tbtn" id="bPin" title="固定位置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
@@ -554,6 +617,8 @@
</div>
</div>
<div id="sessList"></div>
<div id="settings">
<h4>悬浮窗设置</h4>
<div class="srow"><span>背景效果</span>
@@ -620,6 +685,16 @@
let es = null
let ENDPOINT = null
/** 文件桥(桌面壳 HTTP 网关不可用时的通道)也叫"连上了" */
function markLive(label) {
$('dot').className = 'dot live'
if (label) $('sub').textContent = label
$('input').disabled = false
$('send').disabled = false
document.body.classList.add('linked')
const hint = $('hint')
if (hint) hint.style.display = 'none'
}
function setEndpoint(ep) {
const live = !!(ep && ep.baseUrl)
ENDPOINT = live ? ep.baseUrl : null
@@ -652,6 +727,11 @@
function handleCmd(c) {
if (c === 'hide') document.body.style.visibility = 'hidden'
if (c === 'show') document.body.style.visibility = 'visible'
if (c === 'backdropFallback') { // 系统玻璃不可用,宿主已回退成不透明
S.backdrop = 'opaque'
paint()
toast('这台机器上系统毛玻璃不可用,已回退为不透明')
}
}
let settingsApplied = false
@@ -672,15 +752,48 @@
function render(msg) {
if (!msg) return
if (msg.type === 'cmd') return handleCmd(msg.cmd)
if (msg.type === 'snapshot') { // 文件桥来的快照
markLive('本地桥')
return render(msg.data)
}
if (msg.settings && !settingsApplied) { settingsApplied = true; applyDshSettings(msg.settings) }
if (msg.metrics) metrics(msg.metrics)
if (msg.messages) messages(msg.messages)
if (Array.isArray(msg.sessions)) { SESSIONS = msg.sessions; PINNED = msg.pinned || null; if ($('sessList').classList.contains('show')) renderSessions() }
if (msg.title) {
const t = String(msg.title).slice(0, 42)
$('titleText').textContent = 'DSH · ' + t
}
}
/* ---------------- 会话切换 ---------------- */
let SESSIONS = []
let PINNED = null
function renderSessions() {
const box = $('sessList')
box.innerHTML = ''
const head = document.createElement('div')
head.className = 'head'
head.textContent = SESSIONS.length ? `共 ${SESSIONS.length} 个会话 · 点一个跟随它` : '还没有会话'
box.appendChild(head)
const mk = (label, sub, active, sid) => {
const row = document.createElement('div')
row.className = 'srow' + (active ? ' act' : '')
const st = document.createElement('div'); st.className = 'st'; st.textContent = label
const sm = document.createElement('div'); sm.className = 'sm'; sm.textContent = sub
row.append(st, sm)
row.onclick = () => { send({ cmd: 'watch', sessionId: sid }); box.classList.remove('show'); toast(sid ? '已切换到:' + label : '已改为跟随最新') }
box.appendChild(row)
}
mk('跟随最新', '', !PINNED, '')
for (const s of SESSIONS) {
const label = s.title || (s.id || '').slice(0, 8) + '…'
const sub = `${s.turns || 0}轮/${s.steps || 0}步`
mk(label, sub, PINNED ? s.id === PINNED : !!s.active, s.id)
}
}
function metrics(m) {
$('m-turn').textContent = m.turns ?? '—'
$('m-step').textContent = m.steps ?? '—'
@@ -723,6 +836,7 @@
$('bFull').onclick = () => cmd('fullscreen')
$('bClose').onclick = () => cmd('close')
$('bSet').onclick = () => $('settings').classList.toggle('show')
$('bSess').onclick = () => { const el = $('sessList'); renderSessions(); el.classList.toggle('show') }
$('sTop').onclick = () => cmd('topmost')
$('sPin').onclick = () => cmd('posLock')
$('sLock').onclick = () => cmd('sizeLock')