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
16 changes: 16 additions & 0 deletions .github/workflows/build-shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ name: build dsh-local-shell (shell)
# 触发: 壳/前端/构建配置变更(push main)。版本号自动递增: 以最新 Release
# tag 为基准 patch +1, 构建时写入 tauri.conf.json(不回写仓库),
# 旧 Release 永不被覆盖。
# PR 也会跑本工作流, 但只构建并上传产物以校验可编译 —— 版本递增与发布
# 都限定在 main 上进行(见各步骤的 if 条件)。

on:
workflow_dispatch:
Expand All @@ -17,6 +19,18 @@ on:
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.github/workflows/build-shell.yml'
# Pull requests build and upload the exe to prove the change compiles, but
# never touch the version or publish a release (see the `if:` guards below).
pull_request:
branches: [main, master]
paths:
- 'src/**'
- 'src-tauri/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.github/workflows/build-shell.yml'

permissions:
contents: write
Expand All @@ -42,6 +56,7 @@ jobs:
run: pnpm install --no-frozen-lockfile --config.dangerously-allow-all-builds=true

- name: bump version (latest release tag + 0.0.1, in-memory only)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -80,6 +95,7 @@ jobs:
if-no-files-found: error

- name: publish shell exe to a NEW patch release (older tags are never touched)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## v2.0.6 — 2026-09-16

**复用(attach)实例不再卡在「是否重启接管」:从运行中的进程内存里取回 launch token**:

- **新增 `src-tauri/src/memtok.rs`**(纯 `kernel32` 裸 FFI,不加依赖):DSH 的 launch token 只存在于运行进程的内存里(打印过的那一行早被 GC 回收),attach 实例又没有本应用自己的子进程控制台尾可读——此前唯一出路是弹窗让用户重启接管。现在直接读监听 3080 那个进程的内存:枚举可读内存区 → 收集 43 字符 base64url 的**极大串**(附加尾字符约束 `AEIMQUYcgkosw048`——43 字符编码 32 字节时末位只承载 4 bit,这是长度带来的数学必然,不是对内容的猜测)→ 逐个候选走 HTTP 交换,**只有回 303 且签发 `dsh-auth-*` cookie 的才算数**(形状永不作为证据)
- **四遍递进, 锚点优先**:先只认打印行 `token=` 之后的 43 字符(实测同一实例:锚点法候选由数百降到个位数)→ 再扫全内存的极大 base64url 串(先跳过 MEM_IMAGE,映射的 DLL 代码不可能存 JS 字符串,少读约 160MB)→ 全量内存 → 最后去掉尾字符约束兜底(防上游改编码)
- **速度**(实测本机 3080 实例基线:扫描 592.5MB / 656 个候选 / 2.5s):4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即 **8 路并行探测**、命中即刻停扫(early stop)。串行探测在数百个候选下会变成主要开销——实测真令牌出现在第 649 次探测上,这个量级必须并行;探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头
- **回退**:无监听进程 / `OpenProcess` 失败(跨用户或已退出)/ 无候选 / 候选全不通过 → 落到原来的「是(壳接管重启并自动认证)/ 否(退出保留外部实例)」弹窗,文案改为说明已尝试内存提取;每次运行最多扫 2 次,同一令牌不重复导航(前端登录插件在 HTTP 层拦 token URL 时不会变成扫描环)
- **不落盘**:全程只读(`ReadProcessMemory`),令牌明文绝不写日志——不落盘正是这套认证的设计前提;日志只记扫描统计(区域数/跳过映像/读取量/候选数/探测数/耗时/是否早停)
- **CI**:`build-shell.yml` 增加 `pull_request` 触发,PR 只构建并上传 exe 以校验可编译,不递增版本、不发布 Release

Attach-mode token recovery from the running process's memory (no more "restart and take over?" on reuse):

- New `src-tauri/src/memtok.rs` (raw `kernel32` FFI, no new dependency): the launch token lives only in the running process, and an attached instance has no console tail of ours to read — so reusing a running `dsh web` used to end at the takeover prompt. The shell now reads the listener's memory: enumerate readable regions → harvest every maximal 43-character base64url run (plus the tail-character constraint `AEIMQUYcgkosw048`, a mathematical consequence of encoding exactly 32 bytes) → exchange each candidate over HTTP and accept only one that answers `303` while minting the `dsh-auth-*` cookie. Shape is never accepted as proof
- Four passes, most specific first: the 43 characters behind the printed URL's `token=` anchor (measured on a live instance: single-digit candidates instead of hundreds), then every maximal base64url run over private memory (mapped executables cannot hold a JS string, ~160MB less to read), then all memory, then all memory without the tail filter
- Fast (measured baseline on this machine's 3080 instance: 592.5MB scanned, 656 candidates, 2.5s): 4MB `ReadProcessMemory` chunks, `min(CPU,8)` workers pulling ranges off an atomic queue, and a probe round every 16 candidates across 8 lanes so a hit stops the scan immediately — the real token showed up on the 649th probe, and that volume has to be probed in parallel. Probes are a hand-written minimal HTTP/1.1 exchange with an explicit `Host` header (without it the server always answers 401 — the cookie name and its signed audience both derive from the request authority)
- Fallback: no listener, `OpenProcess` denied, no candidate, or nothing verifying → the original 「是」take over / 「否」quit prompt, reworded to say memory recovery was tried. At most two scans per run and the same token is never navigated twice, so a front-auth plugin intercepting the token URL cannot turn into a scan loop
- Read-only and disk-free: `ReadProcessMemory` only; the token never reaches the log, because not landing on disk is the whole point of this auth
- CI: `build-shell.yml` also runs on pull requests to prove the change compiles; a PR bumps no version and publishes no release

## v2.0.5 — 2026-09-15

**启动页重构 + 深浅色跟随系统**(前端 UI 层重写;业务逻辑与 Rust 侧零改动):
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Ships as a **single portable bare exe** (~4.5 MB, no installer).
- **Tray icon pinned to the taskbar**: the app writes the Windows notification-area setting (`IsPromoted`) at startup, so the icon no longer falls into the overflow on every launch
- **Task-done notification**: when a session transitions from running to idle, a Windows toast fires with two buttons — **"Open Window"** (restore & focus) and **"Got it"** (dismiss); left untouched it auto-collapses after a few seconds
- **Link context menu**: right-clicking a link in the chat shows a clean two-item menu — "Open in browser" / "Copy link" (replacing the misleading default WebView2 menu); left-click still opens external links in the system default browser
- **Attach mode**: if DSH is already listening on 3080 at startup, the app attaches instead of spawning a second one — and never kills an instance it didn't start
- **Attach mode**: if DSH is already listening on 3080 at startup, the app attaches instead of spawning a second one — and never kills an instance it didn't start. When that instance demands the browser token and its printed line is long gone, the shell recovers the token **from the running process's own memory** and navigates with it (accepted only after a real HTTP `303` + `dsh-auth-*` exchange — never on shape alone), so reusing a running DSH no longer ends at the "restart and take over?" prompt; only a failed recovery falls back to that prompt
- **Clean exit**: only tray right-click → "Quit (close DSH)" exits, tearing down the process tree it spawned via `taskkill /T` with zero orphans
- **Single instance**: launching the exe again just focuses the existing window
- **Portable & small**: one file, no installer, no DLL dependencies; data/logs go to `%LOCALAPPDATA%\dsh-desktop\`
Expand Down
95 changes: 93 additions & 2 deletions src-tauri/src/dsh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,15 +568,28 @@ pub(crate) fn check_auth_wall_now(app: &AppHandle) {
}
log_write(
LogLevel::Warn,
"[auth-wall] 10秒内token仍未出现, 回退到接管弹窗(重启后重试捕获)",
"[auth-wall] 10秒内token仍未在控制台尾出现, 改从运行中实例的内存恢复",
);
}
// 内存恢复: attach 实例(复用场景)没有可读的子进程控制台尾, 这是它
// 唯一的令牌来源; 自启实例则是控制台尾也没抓到时的兜底。DSH 的
// launch token 只存在于运行进程的内存里 —— 打印过的那一行早已被
// GC 回收, 磁盘上任何东西都重建不出来。
if recover_token_from_memory(&app) {
// 重新放行后续 401: 令牌导航若仍未生效(前端登录插件在 HTTP 层
// 拦截 token URL 是已知情况), 第二次扫描会拿到同一个令牌并直接
// 放弃, 由 MAX_SCANS 与 LAST_TOKEN 双重封顶, 不会变成扫描环。
AUTH_WALL_HANDLED.store(false, std::sync::atomic::Ordering::Relaxed);
return;
}
if owned {
AUTH_WALL_HANDLED.store(false, std::sync::atomic::Ordering::Relaxed);
}
let take_over = crate::prompt_yes_no(
&app,
"DSH 需要浏览器认证",
concat!(
"检测到 DSH 需要浏览器令牌(新版本认证),但当前实例不是本应用启动的,无法获取令牌。\n\n",
"检测到 DSH 需要浏览器令牌(新版本认证),已尝试从运行中的实例内存提取令牌但未取得。\n\n",
"「是」重启 dsh web 宿主(由本应用接管,自动完成认证)\n",
"「否」退出本应用(保留当前 DSH 实例)",
),
Expand All @@ -600,6 +613,84 @@ pub(crate) fn check_auth_wall_now(app: &AppHandle) {
});
}

/// Recover a launch token from the memory of the process listening on the DSH
/// port and, when one verifies over HTTP, navigate the main window to the
/// tokenized URL. Returns true only when the window was navigated with a token
/// that really exchanged for a browser session — shape alone never navigates.
///
/// This is the attach path's only token source: an instance we did not spawn
/// has no console tail of ours to read, which is why reusing a running `dsh web`
/// used to end at the takeover prompt. Bounded to `MAX_SCANS` scans per run,
/// and the same token is never navigated twice: a token that answers 303 over
/// HTTP but still leaves the webview unauthenticated means something sits in
/// front of the token URL (the dsh-remote login plugin is the known case), and
/// the caller must fall back to the prompt instead of scanning in a loop.
fn recover_token_from_memory(app: &AppHandle) -> bool {
/// Scans allowed per app run: one to use the token, one to learn that using
/// the same token again changes nothing.
const MAX_SCANS: u32 = 2;
static SCANS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
static LAST_TOKEN: Mutex<Option<String>> = Mutex::new(None);

if SCANS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= MAX_SCANS {
log_write(
LogLevel::Warn,
"[auth-wall] 内存恢复已用尽本次运行的重试次数, 回退接管弹窗",
);
return false;
}
let Some(pid) = port_listener_pid() else {
log_write(
LogLevel::Warn,
&format!("[auth-wall] 内存恢复跳过: 没有进程监听 127.0.0.1:{DSH_PORT}"),
);
return false;
};
let started = Instant::now();
let token = match crate::memtok::recover(pid, DSH_PORT) {
crate::memtok::Outcome::Found { token, stats } => {
log_write(
LogLevel::Info,
&format!(
"[auth-wall] 内存恢复命中令牌(pid {pid}, {:.1}s): {stats}",
started.elapsed().as_secs_f64()
),
);
token
}
crate::memtok::Outcome::NotFound { detail } => {
log_write(
LogLevel::Warn,
&format!(
"[auth-wall] 内存恢复未取得令牌(pid {pid}, {:.1}s): {detail}",
started.elapsed().as_secs_f64()
),
);
return false;
}
};
{
let Ok(mut last) = LAST_TOKEN.lock() else {
return false;
};
if last.as_deref() == Some(token.as_str()) {
log_write(
LogLevel::Warn,
"[auth-wall] 内存恢复拿到与上次相同的令牌而导航未生效, 回退接管弹窗",
);
return false;
}
*last = Some(token.clone());
}
// 令牌明文绝不写进日志: 不落盘正是这套认证的设计前提。
log_write(
LogLevel::Info,
&format!("[auth-wall] 用内存恢复的令牌重新导航(pid {pid})"),
);
navigate_main(app, &format!("{DSH_BASE}/?token={token}"));
true
}

/// Outcome of one candidate attempt.
enum Attempt {
Ready,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! task-completion event monitor.

mod dsh;
mod memtok;
mod menu;
mod monitor;
mod update;
Expand Down
Loading
Loading