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
37 changes: 36 additions & 1 deletion openless-all/app/scripts/windows-ui-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', impor
const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8');
const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8');
const floatingShellTsx = await readFile(new URL('../src/components/FloatingShell.tsx', import.meta.url), 'utf-8');
const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8');

if (!capsuleWindow) {
throw new Error('capsule window config missing');
Expand All @@ -33,13 +34,47 @@ assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation-
assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline');
assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals');
assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording');
assertEqual(mainWindow.decorations, false, 'windows main window should use only custom titlebar');
assertEqual(mainWindow.decorations, true, 'shared main window config should keep native macOS traffic lights');
assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point');

assertMatch(
libRs,
/#\[cfg\(target_os = "windows"\)\][\s\S]*?main\.set_decorations\(false\)/,
'windows runtime should disable native chrome before the first show',
);

assertMatch(
coordinatorRs,
/#\[cfg\(target_os = "macos"\)\][\s\S]*?orderFrontRegardless/,
'macOS capsule should show without taking the key window',
);

if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) {
throw new Error('windows frameless shell should expose explicit resize handles');
}

assertMatch(
windowChromeTsx,
/const MAC_TITLEBAR_HEIGHT = 30;/,
'macOS titlebar spacer should stay visually compact around the native traffic lights',
);
assertMatch(
libRs,
/show_main_window[\s\S]*?set_focus\(\)/,
'macOS main window should rely on native traffic lights instead of manually moving standardWindowButton frames',
);
if (/standardWindowButton|setFrameOrigin: origin|tune_macos_main_window_controls/.test(libRs)) {
throw new Error('macOS traffic lights should not be manually repositioned; keep native AppKit button frames visible');
}
if (!/action=\"close\"/.test(windowChromeTsx) || !/tone=\"danger\"/.test(windowChromeTsx)) {
throw new Error('windows titlebar should keep the close button and danger hover treatment');
}
assertMatch(
tokensCss,
/--ol-motion-spring:[\s\S]*?--ol-motion-soft:[\s\S]*?--ol-motion-quick:/,
'shared motion tokens should drive shell animations and transitions',
);

if (!/startResizeDragging\(direction\)/.test(windowChromeTsx)) {
throw new Error('windows resize handles should delegate edge dragging to Tauri');
}
Expand Down
44 changes: 24 additions & 20 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2532,7 +2532,7 @@ fn restore_focus_target_if_possible(target: Option<usize>) -> bool {
let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) };
}
let _ = unsafe { SetForegroundWindow(hwnd) };
std::thread::sleep(Duration::from_millis(60));
std::thread::sleep(std::time::Duration::from_millis(60));

let foreground = unsafe { GetForegroundWindow() };
if foreground != hwnd {
Expand All @@ -2548,23 +2548,24 @@ fn restore_focus_target_if_possible(_target: Option<usize>) -> bool {
}

#[cfg(target_os = "windows")]
fn show_capsule_window_no_activate() -> bool {
use std::iter::once;
use windows::core::PCWSTR;
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOSIZE, SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
};

let title: Vec<u16> = "OpenLess Capsule".encode_utf16().chain(once(0)).collect();
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
Ok(hwnd) => hwnd,
Err(_) => return false,
let Ok(handle) = window.window_handle() else {
return false;
};
if hwnd == HWND::default() || hwnd.0.is_null() {
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
return false;
}
};
let hwnd = HWND(raw.hwnd.get() as *mut _);

let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = unsafe {
Expand All @@ -2581,8 +2582,15 @@ fn show_capsule_window_no_activate() -> bool {
true
}

// macOS / Linux 上不走 no-activate 路径:胶囊由 emit_capsule 的 fallback
// `window.show()` 直接显示,再用 restore_main_window_key_if_active 把焦点还给
// 主窗口。这是 1.2.11 的实现 — 单独走 orderFrontRegardless 会让胶囊在 webview
// 未完整初始化时偶发不可见。
#[cfg(not(target_os = "windows"))]
fn show_capsule_window_no_activate() -> bool {
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
false
}

Expand Down Expand Up @@ -2651,15 +2659,11 @@ fn emit_capsule(
let visible = !matches!(state, CapsuleState::Idle);
maybe_position_capsule_bottom_center(inner, &window, payload.translation);
if show_capsule && visible {
if cfg!(target_os = "windows") {
if !show_capsule_window_no_activate() {
let _ = window.show();
}
} else {
if !show_capsule_window_no_activate(&app, &window) {
let _ = window.show();
}
// 胶囊 show() 在 macOS 会调 makeKeyAndOrderFront: 抢走主窗口焦点
// 若 OpenLess 已是前台 app,用 makeKeyWindow 还原主窗口(不激活 NSApp)
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main
#[cfg(target_os = "macos")]
crate::restore_main_window_key_if_active(&app);
} else {
Expand Down
35 changes: 23 additions & 12 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,8 @@ fn activate_app<R: Runtime>(_app: &AppHandle<R>) {}
/// 不调 NSApp.activate,不抢其他 app 焦点,符合 CLAUDE.md 约束。
#[cfg(target_os = "macos")]
pub(crate) fn restore_main_window_key_if_active<R: Runtime>(app: &AppHandle<R>) {
let _ = app.run_on_main_thread(|| {
let main = app.get_webview_window("main");
let _ = app.run_on_main_thread(move || {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject, Bool};
unsafe {
Expand All @@ -501,11 +502,18 @@ pub(crate) fn restore_main_window_key_if_active<R: Runtime>(app: &AppHandle<R>)
if !is_active.as_bool() {
return;
}
let main_win: *mut AnyObject = msg_send![ns_app, mainWindow];
if main_win.is_null() {
let Some(main) = main else {
return;
}
let _: () = msg_send![main_win, makeKeyWindow];
};
match main.ns_window() {
Ok(handle) => {
let main_win = handle as *mut AnyObject;
if !main_win.is_null() {
let _: () = msg_send![main_win, makeKeyWindow];
}
}
Err(e) => log::warn!("[main] ns_window unavailable for key restore: {e}"),
};
}
});
}
Expand Down Expand Up @@ -753,9 +761,12 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {

#[cfg(not(target_os = "windows"))]
{
// macOS / Linux:固定 220×110,与 1.2.11 行为一致 — 录音 / 翻译徽章
// 共用同一个窗口尺寸,避免按 Shift 后窗口高度变化导致胶囊整体下移。
let _ = translation_active;
CapsuleWindowBounds {
width: 176.0,
height: if translation_active { 110.0 } else { 42.0 },
width: 220.0,
height: 110.0,
bottom_inset: 0.0,
}
}
Expand All @@ -769,7 +780,7 @@ fn capsule_visual_height(_translation_active: bool) -> f64 {

#[cfg(not(target_os = "windows"))]
{
42.0
96.0
}
}

Expand All @@ -788,7 +799,7 @@ mod tests {
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0));

#[cfg(not(target_os = "windows"))]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 42.0, 0.0));
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0));
}

#[test]
Expand All @@ -798,7 +809,7 @@ mod tests {
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0));

#[cfg(not(target_os = "windows"))]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0));
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0));
}

#[test]
Expand All @@ -807,7 +818,7 @@ mod tests {
assert_eq!(capsule_visual_height(true), 52.0);

#[cfg(not(target_os = "windows"))]
assert_eq!(capsule_visual_height(true), 42.0);
assert_eq!(capsule_visual_height(true), 96.0);
}

#[test]
Expand All @@ -816,6 +827,6 @@ mod tests {
assert_eq!(capsule_height_for_qa(), 52.0);

#[cfg(not(target_os = "windows"))]
assert_eq!(capsule_height_for_qa(), 42.0);
assert_eq!(capsule_height_for_qa(), 96.0);
}
}
34 changes: 32 additions & 2 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,8 +645,12 @@ pub mod prompts {
const COMMON_RULES: &str = "# 通用规则\n\
1) \u{4E0D}确定 / 转写明显不完整 / 断句在半截 \u{2192} 保留原话,\u{4E0D}要替用户补全或猜测。\n\
2) 中英混输、专有名词、产品名、代码 / 命令 / 路径 / URL、数字与单位、emoji \u{2192} 原样保留。\n\
3) \u{4E0D}引入用户没说过的事实;中途改口以最终版本为准。\n\
4) 如果原始转写本身是在\u{201C}询问 / 要求别人做某事\u{201D},只整理为清楚的问题或请求,\u{4E0D}代替对方回答。";
3) \u{4E0D}引入用户没说过的事实;中途改口以最终版本为准。在保留原意和语气的前提下,按用户的整体意图把零碎口语组织成协调、自然的书面表达。\n\
4) 如果原始转写本身是在\u{201C}询问 / 要求别人做某事\u{201D},只整理为清楚的问题或请求,\u{4E0D}代替对方回答。\n\
5) 自动纠错:明显的 ASR 同音 / 形近错字按上下文纠回正确字面,常见模式包括\
\u{201C}跟目录 / 根木鹿\u{201D}\u{2192}\u{201C}根目录\u{201D}、\u{201C}代码厂\u{201D}\u{2192}\u{201C}代码仓\u{201D}、\
\u{201C}编一编\u{201D}\u{2192}\u{201C}编译\u{201D}、\u{201C}的 / 得 / 地\u{201D}用法、\u{201C}做 / 作\u{201D} 等常见错别字。\
专有名词(见 # 热词)、人名、品牌名、不在常见中文词典里的词原样保留,\u{4E0D}强行改字;改了之后含义会发生变化的不改。";

const OUTPUT_BLOCK: &str = "# 输出\n\
直接输出最终文本正文。需要结构化时直接从标题 / 段落 / 编号开始。\n\
Expand Down Expand Up @@ -912,6 +916,32 @@ mod tests {
assert!(prompt.contains("- OpenLess"));
}

#[test]
fn common_rules_include_auto_correction_and_natural_organization() {
// 所有 mode 都要带上"自动纠错"(规则 5)和"按整体意图组织成自然书面表达"
// 的扩展(规则 3)。任一缺失说明 COMMON_RULES 被回退掉了。
for mode in [
PolishMode::Raw,
PolishMode::Light,
PolishMode::Structured,
PolishMode::Formal,
] {
let prompt = prompts::system_prompt(mode);
assert!(
prompt.contains("5) 自动纠错"),
"{mode:?} prompt 缺少自动纠错规则"
);
assert!(
prompt.contains("根目录"),
"{mode:?} prompt 缺少根目录纠错示例"
);
assert!(
prompt.contains("按用户的整体意图把零碎口语组织成协调、自然的书面表达"),
"{mode:?} prompt 缺少自然组织扩展"
);
}
}

#[tokio::test]
async fn chat_completion_omits_authorization_when_api_key_is_empty() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
"minWidth": 980,
"minHeight": 640,
"resizable": true,
"decorations": false,
"decorations": true,
"transparent": true,
"shadow": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 14, "y": 20 },
"visible": false,
"acceptFirstMouse": true
},
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/components/AutoUpdate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function UpdateDialog({
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 8, borderRadius: 999, background: 'var(--ol-surface-2)', overflow: 'hidden', border: '0.5px solid var(--ol-line)' }}>
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s ease-out' }} />
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s var(--ol-motion-soft)' }} />
</div>
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{installing
Expand Down
21 changes: 12 additions & 9 deletions openless-all/app/src/components/Capsule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function AudioBars({ level }: AudioBarsProps) {
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.82,
transition: 'height 0.06s linear',
transition: 'height 0.08s var(--ol-motion-quick)',
}}
/>
))}
Expand All @@ -59,7 +59,7 @@ function ProcessingDots() {
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.85,
animation: `cap-dot 0.9s linear ${i * 0.3}s infinite`,
animation: `cap-dot 0.9s var(--ol-motion-soft) ${i * 0.3}s infinite`,
}}
/>
))}
Expand Down Expand Up @@ -130,7 +130,7 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
flexShrink: 0,
padding: 0,
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
transition: 'opacity 0.15s ease-out, background 0.12s ease-out, transform 0.08s ease-out',
transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
}}
>
{isCancel ? (
Expand Down Expand Up @@ -243,7 +243,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
fontFamily: 'var(--ol-font-sans)',
transform: `scale(${scale.toFixed(4)})`,
transformOrigin: 'center',
transition: 'transform 0.06s linear, box-shadow 0.06s linear',
transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)',
willChange: 'transform, box-shadow',
filter: dropShadow,
}}
Expand All @@ -261,12 +261,13 @@ export function Capsule() {
const { t } = useTranslation();
const os = detectOS();
const metrics = getCapsulePillMetrics(os);
const [translation, setTranslation] = useState<boolean>(false);
const hostMetrics = getCapsuleHostMetrics(os, translation);
const [state, setState] = useState<CapsuleState>(isTauri ? 'idle' : 'recording');
const [level, setLevel] = useState<number>(isTauri ? 0 : 0.6);
const [insertedChars, setInsertedChars] = useState<number>(0);
const [message, setMessage] = useState<string | undefined>();
const [translation, setTranslation] = useState<boolean>(false);
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
const hostMetrics = getCapsuleHostMetrics(os, translation);

useEffect(() => {
if (!isTauri) return;
Expand Down Expand Up @@ -329,9 +330,11 @@ export function Capsule() {
style={{
position: 'absolute',
left: '50%',
// bottom = 50%(pill 中线)+ pill 半高 21px(capsuleLayout mac=42)+ 8px 间隔。
// 只有翻译徽章可见时才需要额外高度;普通录音/转写状态由后端缩到 pill 本体,避免透明死区。
bottom: `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`,
// macOS / Linux:胶囊窗口 220×110、pill 居中,badge 锚到 pill 中线上方 21+8。
// Windows:pill 不居中(带 12pt 阴影 inset),用 hostMetrics 量到底部 inset + pill 高 + gap。
bottom: os === 'win'
? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`
: 'calc(50% + 21px + 8px)',
transform: 'translateX(-50%)',
pointerEvents: 'none',
}}
Expand Down
Loading
Loading