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
21 changes: 20 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,23 @@ name: Release Electron App
on:
push:
tags: ['v*']
# 兜底触发:semantic-release 若以内置 GITHUB_TOKEN 推 tag,
# GitHub 防递归机制不会派发 push 事件,导致本工作流从不运行、
# Release 资产为空(latest.yml 404)。Release 发布事件可正常触发补跑。
release:
types: [published]
# 手动兜底:在 UI 选择对应 tag 运行,用于补发历史版本资产
workflow_dispatch:

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

# 同一 tag 的多个触发源(push/release)串行执行,避免重复上传竞争
concurrency:
group: release-electron-${{ github.ref_name }}
cancel-in-progress: false

jobs:
lint-test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -41,7 +53,14 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.os }}
path: client/release/*.{exe,dmg,AppImage}
# 注意:@actions/glob 不支持 {a,b} 花括号展开,必须逐行列出;
# latest*.yml 与 *.blockmap 是 electron-updater 自动更新的必需文件
path: |
client/release/*.exe
client/release/*.dmg
client/release/*.AppImage
client/release/*.blockmap
client/release/latest*.yml

release:
needs: build
Expand Down
24 changes: 8 additions & 16 deletions client/electron/audioCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
import { logger } from './logger';
import { setPreferredDisplaySource } from './displayMediaHandler';

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

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

// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
setPreferredDisplaySource(resolvedSourceId);

this.capturing = true;
this.boundWin = win;

Expand All @@ -171,7 +164,6 @@ export class AudioCapture {
if (!win.isDestroyed()) {
win.webContents.send('audio_capture_do_start', {
sourceId: resolvedSourceId,
fallbackSourceId,
options: this.options,
});
}
Expand Down
73 changes: 73 additions & 0 deletions client/electron/displayMediaHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Electron 系统音频环回请求处理器(getDisplayMedia 桥)
*
* @ai-context: Windows 系统音频捕获的唯一可靠路径。Electron 30+ / Chromium 中,
* 渲染进程用 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 单独请求
* 音频轨已不返回真实音频——会拿到一条恒为数字零的静音轨(RMS 0.00,不抛错),
* 这是"监听 0 句 / 时间轴全静默"的根因。正确做法是主进程注册
* setDisplayMediaRequestHandler 并在 callback 中返回 audio: 'loopback',
* 由渲染进程调用 getDisplayMedia({ video: true, audio: true }) 触发。
* @ai-context: audio: 'loopback' 捕获系统输出混音(含本应用自身声音);
* 若需静音本地播放改用 'loopbackWithMute'。handler 全局唯一,重复注册会覆盖。
*
* TODO(现场课程): 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用
* getUserMedia({ audio: { deviceId } }) 即可,无需本模块参与。
*/

import { session, desktopCapturer } from 'electron';
import { logger } from './logger';

/**
* 期望捕获的视频源 ID(window:xxx / screen:xxx)。
* 仅用于让 loopback 请求绑定到目标窗口所在屏幕;音频始终是系统混音。
*/
let preferredSourceId: string | null = null;

/** 设置本次采集期望的源 ID(由 AudioCapture.start 调用),null 表示用首个屏幕源 */
export function setPreferredDisplaySource(sourceId: string | null): void {
preferredSourceId = sourceId;
}

/**
* 注册 displayMedia 请求处理器(须在 app.whenReady 后调用一次)
*
* 渲染进程调用 navigator.mediaDevices.getDisplayMedia() 时进入此 handler,
* 我们直接指定源并附加 loopback 音频,跳过系统选择器(无需用户二次确认)。
*/
export function registerDisplayMediaHandler(): void {
session.defaultSession.setDisplayMediaRequestHandler(
(_request, callback) => {
void (async () => {
try {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 1, height: 1 },
});
if (sources.length === 0) {
logger.error('[DisplayMedia] 无可用捕获源,拒绝请求');
// 空对象表示拒绝授权(Electron 约定)
callback({});
return;
}
// 优先匹配期望源;未命中则退回首个屏幕源(屏幕源对 loopback 最稳定)
const matched = preferredSourceId
? sources.find((s) => s.id === preferredSourceId)
: undefined;
const screenSource = sources.find((s) => s.id.startsWith('screen:'));
const chosen = matched ?? screenSource ?? sources[0];

logger.info(
`[DisplayMedia] 授予捕获: source=${chosen.name} (${chosen.id}), audio=loopback`,
);
callback({ video: chosen, audio: 'loopback' });
} catch (err) {
logger.error('[DisplayMedia] 处理捕获请求失败:', err);
callback({});
}
})();
},
// 使用应用内指定源而非 Windows 系统选择器,保持无感采集体验
{ useSystemPicker: false },
);
logger.info('[DisplayMedia] setDisplayMediaRequestHandler 已注册(audio: loopback)');
}
5 changes: 5 additions & 0 deletions client/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { resolveDbPath } from './db/storageConfig.js';
import { registerMigrationHandlers } from './db/migration.js';
import { loadEnvironment } from './envLoader.js';
import { installCspPolicy } from './cspPolicy.js';
import { registerDisplayMediaHandler } from './displayMediaHandler.js';
import { registerDbIpcHandlers } from './db/dbIpcHandlers.js';
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
Expand Down Expand Up @@ -123,6 +124,10 @@ if (!gotTheLock) {
// SEC-005: CSP 安全策略注入(详见 cspPolicy.ts)
installCspPolicy(isDevMode());

// 系统音频环回捕获授权(详见 displayMediaHandler.ts)
// 必须在渲染进程发起 getDisplayMedia 之前注册,否则请求会被默认拒绝
registerDisplayMediaHandler();

// 加载持久化的 AI 网关地址(在注册 handler 之前,确保 handler 可用正确的 URL)
await loadPersistedGatewayUrl();
logger.info(`[AI] Gateway URL resolved: ${gatewayUrl()}`);
Expand Down
26 changes: 23 additions & 3 deletions client/electron/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ function checkForUpdatesWithTimeout(): Promise<unknown> {
});
}

/**
* 将 electron-updater 的原始错误映射为用户可读的提示。
* 原始 message 可能包含整段 HTTP headers / 堆栈(如 latest.yml 404),
* 不应直接透传到 UI;完整信息仍会经 logger 落盘供排查。
*/
function toFriendlyUpdateError(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
if (/latest.*\.yml/i.test(raw) || /404/.test(raw)) {
return '更新服务暂时不可用(新版本文件尚未发布完成),请稍后再试';
}
if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|net::ERR/i.test(raw)) {
return '网络连接异常,请检查网络后重试';
}
// 兜底:截断超长原始信息,避免刷屏
return raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
}

export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
// 开发模式下禁用自动更新
if (!app.isPackaged) {
Expand Down Expand Up @@ -183,7 +200,7 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {

sendToRenderer(mainWindow, 'update-status', {
status: 'error',
message: error.message,
message: toFriendlyUpdateError(error),
});
});

Expand All @@ -210,11 +227,14 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
.then(() => { saveLastCheckTimestamp(); })
.catch((err: unknown) => {
logger.error('[AutoUpdater] Initial check failed', err);
// 失败同样写入节流时间戳:错误已提示过一次,
// 避免服务端问题未修复期间每次启动都重复弹出同一错误
saveLastCheckTimestamp();
// 断网导致的失败不通知前端
if (!net.isOnline()) return;
sendToRenderer(mainWindow, 'update-status', {
status: 'error',
message: err instanceof Error ? err.message : String(err),
message: toFriendlyUpdateError(err),
});
});
}, 10_000);
Expand All @@ -233,7 +253,7 @@ export function checkForUpdate(): void {
sendToRenderer(
BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) ?? null,
'update-status',
{ status: 'error', message: err instanceof Error ? err.message : String(err) },
{ status: 'error', message: toFriendlyUpdateError(err) },
);
});
}
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added client/public/sounds/_backup_original/ui_drop.wav
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added client/public/sounds/ritual_breath_cycle.wav
Binary file not shown.
Binary file added client/public/sounds/ritual_breath_exhale.wav
Binary file not shown.
Binary file added client/public/sounds/ritual_breath_hold.wav
Binary file not shown.
Binary file added client/public/sounds/ritual_breath_inhale.wav
Binary file not shown.
19 changes: 17 additions & 2 deletions client/src/components/ui/UpdateNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/
import { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
import { Download, RefreshCw, X, ChevronDown } from 'lucide-react';
import { Download, RefreshCw, X, ChevronDown, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';

interface UpdateStatus {
Expand Down Expand Up @@ -126,7 +126,22 @@ export default function UpdateNotification() {
</>
)}
{updateStatus.status === 'error' && (
<p className="text-b3 text-semantic-error">更新检查失败:{updateStatus.message}</p>
<>
<p className="flex items-center gap-1.5 text-b1 font-medium text-text-primary">
<AlertCircle className="w-icon-sm h-icon-sm shrink-0 text-semantic-error" />
更新检查失败
</p>
<p className="text-b3 text-text-secondary mt-1 break-words">
{updateStatus.message}
</p>
<p className="text-b3 text-text-tertiary mt-1">不影响正常使用,可稍后在设置中手动检查更新。</p>
<button
onClick={() => setDismissed(true)}
className="mt-2 inline-flex items-center gap-1 px-3 py-1.5 bg-bg-tertiary text-text-primary rounded-kb-md text-b3 hover:bg-bg-card transition-colors"
>
<X className="w-icon-xs h-icon-xs" /> 关闭
</button>
</>
)}
</div>
<button onClick={() => setDismissed(true)} className="text-text-tertiary hover:text-text-secondary">
Expand Down
72 changes: 72 additions & 0 deletions client/src/features/classroom/components/CourseInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* CourseInfoCard — 课程信息卡片(右侧空态区)
* Course info editable card rendered in the right-side idle guide area.
*
* @ai-context: 原左栏 CourseInfoSection 迁出至右侧空态底部,启动采集前顺手
* 填写课程名称/学科/AI 识别开关;detectedBy 徽标标示信息来源(ai/窗口标题/手动)。
* @ai-context: Moved out of the left rail to keep it within one screen height;
* rendered as an editable card at the bottom of the idle guide panel.
*/
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CourseMeta } from '@/lib/capture';
import { SUBJECT_OPTIONS } from '../constants';

interface CourseInfoCardProps {
courseMeta: CourseMeta;
onChange: (meta: CourseMeta) => void;
aiDetectEnabled: boolean;
onAiDetectToggle: (enabled: boolean) => void;
}

export function CourseInfoCard({ courseMeta, onChange, aiDetectEnabled, onAiDetectToggle }: CourseInfoCardProps) {
return (
<div className="rounded-kb-lg border border-border/20 bg-bg-secondary/40 p-4 space-y-3">
<div className="flex items-center gap-2 text-b3 text-text-tertiary">
<Sparkles className="w-4 h-4" strokeWidth={1.5} />
<span className="font-medium">课程信息</span>
{courseMeta.detectedBy && (
<span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-kb-xs bg-brand-50 text-brand-500">
{courseMeta.detectedBy === 'ai' ? 'AI 识别' : courseMeta.detectedBy === 'window_title' ? '自动提取' : '手动'}
</span>
)}
</div>
<div>
<label className="text-b3 text-text-tertiary mb-1 block">课程名称</label>
<input
type="text"
value={courseMeta.courseName ?? ''}
onChange={(e) => onChange({ ...courseMeta, courseName: e.target.value || undefined, detectedBy: 'manual' })}
placeholder="如:高等数学、数据结构..."
className="w-full px-2.5 py-1.5 rounded-kb-sm text-b3 bg-bg-secondary border border-border/30 text-text-primary placeholder:text-text-tertiary/50 focus:outline-none focus:ring-1 focus:ring-brand-300"
/>
</div>
<div>
<label className="text-b3 text-text-tertiary mb-1 block">学科</label>
<div className="flex gap-1 flex-wrap">
{SUBJECT_OPTIONS.map(({ value, label }) => (
<button key={value} onClick={() => onChange({ ...courseMeta, subject: value, detectedBy: 'manual' })}
className={cn(
'px-2 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
courseMeta.subject === value
? 'bg-brand-50 text-brand-600 ring-1 ring-brand-200/50'
: 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary',
)}>
{label}
</button>
))}
</div>
</div>
{/* AI 识别开关 */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={aiDetectEnabled}
onChange={(e) => onAiDetectToggle(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-brand-600"
/>
<span className="text-b3 text-text-tertiary">采集开始时 AI 自动识别课程</span>
</label>
</div>
);
}
Loading
Loading