Skip to content

Commit 10a9b01

Browse files
authored
Merge pull request #6 from Aparencia/dev
feat(dashboard): 学习启动仪式体验重塑
2 parents b03320c + b91bd17 commit 10a9b01

122 files changed

Lines changed: 5106 additions & 767 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,23 @@ name: Release Electron App
44
on:
55
push:
66
tags: ['v*']
7+
# 兜底触发:semantic-release 若以内置 GITHUB_TOKEN 推 tag,
8+
# GitHub 防递归机制不会派发 push 事件,导致本工作流从不运行、
9+
# Release 资产为空(latest.yml 404)。Release 发布事件可正常触发补跑。
10+
release:
11+
types: [published]
12+
# 手动兜底:在 UI 选择对应 tag 运行,用于补发历史版本资产
13+
workflow_dispatch:
714

815
# 上传构建产物到 GitHub Release 需要写权限
916
permissions:
1017
contents: write
1118

19+
# 同一 tag 的多个触发源(push/release)串行执行,避免重复上传竞争
20+
concurrency:
21+
group: release-electron-${{ github.ref_name }}
22+
cancel-in-progress: false
23+
1224
jobs:
1325
lint-test:
1426
runs-on: ubuntu-latest
@@ -41,7 +53,14 @@ jobs:
4153
- uses: actions/upload-artifact@v4
4254
with:
4355
name: release-${{ matrix.os }}
44-
path: client/release/*.{exe,dmg,AppImage}
56+
# 注意:@actions/glob 不支持 {a,b} 花括号展开,必须逐行列出;
57+
# latest*.yml 与 *.blockmap 是 electron-updater 自动更新的必需文件
58+
path: |
59+
client/release/*.exe
60+
client/release/*.dmg
61+
client/release/*.AppImage
62+
client/release/*.blockmap
63+
client/release/latest*.yml
4564
4665
release:
4766
needs: build

‎client/electron/audioCapture.ts‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
2020
import { logger } from './logger';
21+
import { setPreferredDisplaySource } from './displayMediaHandler';
2122

2223
// ================================================================
2324
// 类型定义
@@ -127,18 +128,15 @@ export class AudioCapture {
127128
* 开始音频捕获
128129
*
129130
* 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
130-
* 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获,
131-
* 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持
132-
* 依赖 Chromium 版本,失败时渲染进程自动回退环回源)
133-
* 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置)
134-
* 4. 渲染进程负责 getUserMedia 和音频切片
131+
* 2. 将期望源登记到 displayMedia handler(渲染进程 getDisplayMedia 时生效)
132+
* 3. 向渲染进程发送启动指令(含 sourceId + 配置)
133+
* 4. 渲染进程负责 getDisplayMedia 与音频切片
135134
*/
136135
async start(win: BrowserWindow, sourceId?: string): Promise<void> {
137136
if (this.capturing || this.disposed) return;
138137

139138
// 解析音频源
140139
let resolvedSourceId = sourceId ?? null;
141-
let fallbackSourceId: string | null = null;
142140
if (!resolvedSourceId) {
143141
const sources = await listAudioSources();
144142
if (sources.length === 0) {
@@ -147,17 +145,12 @@ export class AudioCapture {
147145
}
148146
resolvedSourceId = sources[0].id;
149147
logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
150-
} else if (resolvedSourceId.startsWith('window:')) {
151-
// 方案A:窗口源直采目标应用(如 B站客户端/浏览器)的音频,
152-
// 预解析屏幕环回源作为降级候选,防御窗口级捕获不受支持的环境
153-
const sources = await listAudioSources();
154-
fallbackSourceId = sources[0]?.id ?? null;
155-
logger.info(
156-
`[AudioCapture] 方案A 窗口源音频捕获: ${resolvedSourceId}, ` +
157-
`降级候选: ${fallbackSourceId ?? '无'}`,
158-
);
159148
}
160149

150+
// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
151+
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
152+
setPreferredDisplaySource(resolvedSourceId);
153+
161154
this.capturing = true;
162155
this.boundWin = win;
163156

@@ -171,7 +164,6 @@ export class AudioCapture {
171164
if (!win.isDestroyed()) {
172165
win.webContents.send('audio_capture_do_start', {
173166
sourceId: resolvedSourceId,
174-
fallbackSourceId,
175167
options: this.options,
176168
});
177169
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Electron 系统音频环回请求处理器(getDisplayMedia 桥)
3+
*
4+
* @ai-context: Windows 系统音频捕获的唯一可靠路径。Electron 30+ / Chromium 中,
5+
* 渲染进程用 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 单独请求
6+
* 音频轨已不返回真实音频——会拿到一条恒为数字零的静音轨(RMS 0.00,不抛错),
7+
* 这是"监听 0 句 / 时间轴全静默"的根因。正确做法是主进程注册
8+
* setDisplayMediaRequestHandler 并在 callback 中返回 audio: 'loopback',
9+
* 由渲染进程调用 getDisplayMedia({ video: true, audio: true }) 触发。
10+
* @ai-context: audio: 'loopback' 捕获系统输出混音(含本应用自身声音);
11+
* 若需静音本地播放改用 'loopbackWithMute'。handler 全局唯一,重复注册会覆盖。
12+
*
13+
* TODO(现场课程): 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用
14+
* getUserMedia({ audio: { deviceId } }) 即可,无需本模块参与。
15+
*/
16+
17+
import { session, desktopCapturer } from 'electron';
18+
import { logger } from './logger';
19+
20+
/**
21+
* 期望捕获的视频源 ID(window:xxx / screen:xxx)。
22+
* 仅用于让 loopback 请求绑定到目标窗口所在屏幕;音频始终是系统混音。
23+
*/
24+
let preferredSourceId: string | null = null;
25+
26+
/** 设置本次采集期望的源 ID(由 AudioCapture.start 调用),null 表示用首个屏幕源 */
27+
export function setPreferredDisplaySource(sourceId: string | null): void {
28+
preferredSourceId = sourceId;
29+
}
30+
31+
/**
32+
* 注册 displayMedia 请求处理器(须在 app.whenReady 后调用一次)
33+
*
34+
* 渲染进程调用 navigator.mediaDevices.getDisplayMedia() 时进入此 handler,
35+
* 我们直接指定源并附加 loopback 音频,跳过系统选择器(无需用户二次确认)。
36+
*/
37+
export function registerDisplayMediaHandler(): void {
38+
session.defaultSession.setDisplayMediaRequestHandler(
39+
(_request, callback) => {
40+
void (async () => {
41+
try {
42+
const sources = await desktopCapturer.getSources({
43+
types: ['screen', 'window'],
44+
thumbnailSize: { width: 1, height: 1 },
45+
});
46+
if (sources.length === 0) {
47+
logger.error('[DisplayMedia] 无可用捕获源,拒绝请求');
48+
// 空对象表示拒绝授权(Electron 约定)
49+
callback({});
50+
return;
51+
}
52+
// 优先匹配期望源;未命中则退回首个屏幕源(屏幕源对 loopback 最稳定)
53+
const matched = preferredSourceId
54+
? sources.find((s) => s.id === preferredSourceId)
55+
: undefined;
56+
const screenSource = sources.find((s) => s.id.startsWith('screen:'));
57+
const chosen = matched ?? screenSource ?? sources[0];
58+
59+
logger.info(
60+
`[DisplayMedia] 授予捕获: source=${chosen.name} (${chosen.id}), audio=loopback`,
61+
);
62+
callback({ video: chosen, audio: 'loopback' });
63+
} catch (err) {
64+
logger.error('[DisplayMedia] 处理捕获请求失败:', err);
65+
callback({});
66+
}
67+
})();
68+
},
69+
// 使用应用内指定源而非 Windows 系统选择器,保持无感采集体验
70+
{ useSystemPicker: false },
71+
);
72+
logger.info('[DisplayMedia] setDisplayMediaRequestHandler 已注册(audio: loopback)');
73+
}

‎client/electron/main.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { resolveDbPath } from './db/storageConfig.js';
3333
import { registerMigrationHandlers } from './db/migration.js';
3434
import { loadEnvironment } from './envLoader.js';
3535
import { installCspPolicy } from './cspPolicy.js';
36+
import { registerDisplayMediaHandler } from './displayMediaHandler.js';
3637
import { registerDbIpcHandlers } from './db/dbIpcHandlers.js';
3738
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
3839
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
@@ -123,6 +124,10 @@ if (!gotTheLock) {
123124
// SEC-005: CSP 安全策略注入(详见 cspPolicy.ts)
124125
installCspPolicy(isDevMode());
125126

127+
// 系统音频环回捕获授权(详见 displayMediaHandler.ts)
128+
// 必须在渲染进程发起 getDisplayMedia 之前注册,否则请求会被默认拒绝
129+
registerDisplayMediaHandler();
130+
126131
// 加载持久化的 AI 网关地址(在注册 handler 之前,确保 handler 可用正确的 URL)
127132
await loadPersistedGatewayUrl();
128133
logger.info(`[AI] Gateway URL resolved: ${gatewayUrl()}`);

‎client/electron/updater.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ function checkForUpdatesWithTimeout(): Promise<unknown> {
9898
});
9999
}
100100

101+
/**
102+
* 将 electron-updater 的原始错误映射为用户可读的提示。
103+
* 原始 message 可能包含整段 HTTP headers / 堆栈(如 latest.yml 404),
104+
* 不应直接透传到 UI;完整信息仍会经 logger 落盘供排查。
105+
*/
106+
function toFriendlyUpdateError(err: unknown): string {
107+
const raw = err instanceof Error ? err.message : String(err);
108+
if (/latest.*\.yml/i.test(raw) || /404/.test(raw)) {
109+
return '更新服务暂时不可用(新版本文件尚未发布完成),请稍后再试';
110+
}
111+
if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|net::ERR/i.test(raw)) {
112+
return '网络连接异常,请检查网络后重试';
113+
}
114+
// 兜底:截断超长原始信息,避免刷屏
115+
return raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
116+
}
117+
101118
export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
102119
// 开发模式下禁用自动更新
103120
if (!app.isPackaged) {
@@ -183,7 +200,7 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
183200

184201
sendToRenderer(mainWindow, 'update-status', {
185202
status: 'error',
186-
message: error.message,
203+
message: toFriendlyUpdateError(error),
187204
});
188205
});
189206

@@ -210,11 +227,14 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
210227
.then(() => { saveLastCheckTimestamp(); })
211228
.catch((err: unknown) => {
212229
logger.error('[AutoUpdater] Initial check failed', err);
230+
// 失败同样写入节流时间戳:错误已提示过一次,
231+
// 避免服务端问题未修复期间每次启动都重复弹出同一错误
232+
saveLastCheckTimestamp();
213233
// 断网导致的失败不通知前端
214234
if (!net.isOnline()) return;
215235
sendToRenderer(mainWindow, 'update-status', {
216236
status: 'error',
217-
message: err instanceof Error ? err.message : String(err),
237+
message: toFriendlyUpdateError(err),
218238
});
219239
});
220240
}, 10_000);
@@ -233,7 +253,7 @@ export function checkForUpdate(): void {
233253
sendToRenderer(
234254
BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) ?? null,
235255
'update-status',
236-
{ status: 'error', message: err instanceof Error ? err.message : String(err) },
256+
{ status: 'error', message: toFriendlyUpdateError(err) },
237257
);
238258
});
239259
}
197 KB
Binary file not shown.
31.1 KB
Binary file not shown.
24.2 KB
Binary file not shown.
24.2 KB
Binary file not shown.
9.52 KB
Binary file not shown.

0 commit comments

Comments
 (0)