Skip to content
Closed
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
5 changes: 5 additions & 0 deletions openless-all/app/crates/openless-core/src/shared_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,11 @@ pub enum CapsuleStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapsulePayload {
/// Session generation that owns this capsule frame. Keeping this on the
/// payload lets a Recording -> Recording transition reset the transcript
/// without depending on the order of backend:event and capsule:state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub state: CapsuleState,
pub level: f32, // 0..1 RMS
pub elapsed_ms: u64,
Expand Down
65 changes: 50 additions & 15 deletions openless-all/app/scripts/insert-text-motion-h5.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { basename, join, resolve } from 'node:path';
import { createServer } from 'vite';

const out = process.env.OPENLESS_MOTION_ARTIFACT_DIR || join(tmpdir(), 'openless-motion-h5');
const serverPort = Number(process.env.OPENLESS_MOTION_H5_PORT || 1438);
const chromePort = Number(process.env.OPENLESS_MOTION_CHROME_PORT || 9437);
mkdirSync(out, { recursive: true });
const server = await createServer({
server: { host: '127.0.0.1', port: 1438, strictPort: true, watch: null },
server: { host: '127.0.0.1', port: serverPort, strictPort: true, watch: null },
});
const fixture = resolve(`.insert-motion-h5-${process.pid}-${Date.now()}.html`);
writeFileSync(
Expand All @@ -28,14 +30,14 @@ writeFileSync(
</script></body></html>`,
);
await server.listen();
const pageUrl = 'http://127.0.0.1:1438/' + basename(fixture);
const pageUrl = `http://127.0.0.1:${serverPort}/` + basename(fixture);
const chrome = spawn(
process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe',
[
'--headless=new',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=9437',
`--remote-debugging-port=${chromePort}`,
'--window-size=900,500',
'--user-data-dir=' + join(tmpdir(), 'openless-motion-' + Date.now()),
pageUrl,
Expand All @@ -48,7 +50,7 @@ try {
let target;
for (let i = 0; i < 100; i++) {
try {
target = (await (await fetch('http://127.0.0.1:9437/json/list')).json()).find(
target = (await (await fetch(`http://127.0.0.1:${chromePort}/json/list`)).json()).find(
(t) => t.type === 'page' && t.url === pageUrl,
);
} catch {}
Expand Down Expand Up @@ -83,11 +85,14 @@ try {
throw Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
for (let i = 0; i < 150; i++) {
if (await evaluate('Boolean(window.ready)')) break;
await sleep(100);
}
assert(await evaluate('Boolean(window.ready)'), 'React fixture ready');
const waitForFixture = async () => {
for (let i = 0; i < 150; i++) {
if (await evaluate("Boolean(window.ready && typeof window.show === 'function')")) return;
await sleep(100);
}
assert(false, 'React fixture ready');
};
await waitForFixture();
await sleep(800);
const samples = await evaluate(`new Promise(resolve=>{
const frames=[]; const start=performance.now();window.show('帮我查找一下');
Expand Down Expand Up @@ -166,8 +171,9 @@ try {
await send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
});
await evaluate(`window.ready=false; window.show=undefined`);
await send('Page.reload');
await sleep(1000);
await waitForFixture();
await evaluate(`window.show('减少动态效果验证')`);
await sleep(80);
assert(
Expand All @@ -177,8 +183,9 @@ try {
'reduced motion immediate',
);
await send('Emulation.setEmulatedMedia', { features: [] });
await evaluate(`window.ready=false; window.show=undefined`);
await send('Page.reload');
await sleep(900);
await waitForFixture();
await evaluate(`window.show('帮我查找一下十六号的天气')`);
await sleep(1000);
// Optional real browser filmstrip; timestamps preserve capture cadence.
Expand Down Expand Up @@ -220,8 +227,9 @@ try {
}
const shot = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(out, 'settled.png'), Buffer.from(shot.data, 'base64'));
await evaluate(`window.ready=false; window.show=undefined`);
await send('Page.navigate', { url: pageUrl });
await sleep(1200);
await waitForFixture();
await evaluate(`window.show('10六号')`);
await sleep(1000);
const correction = await evaluate(`new Promise(resolve=>{
Expand All @@ -241,12 +249,19 @@ try {
for (const enabled of [true, false]) {
await send('Emulation.setDeviceMetricsOverride', {
width: style === 'typeless' ? 206 : 460,
height: style === 'typeless' ? 57 : style === 'classic' ? 100 : 180,
height:
style === 'typeless'
? enabled
? 65
: 57
: style === 'classic'
? 172
: 228,
deviceScaleFactor: 1,
mobile: false,
});
await send('Page.navigate', {
url: `http://127.0.0.1:1438/?window=capsule&os=win&style=${style}&insertDemo=1&transcript=${enabled ? 1 : 0}&fontSize=20`,
url: `http://127.0.0.1:${serverPort}/?window=capsule&os=win&style=${style}&insertDemo=1&transcript=${enabled ? 1 : 0}&translation=${style === 'typeless' && enabled ? 1 : 0}&fontSize=20`,
});
// Production demo simulates five recognition batches ending after 2440ms.
for (let attempt = 0; attempt < 150; attempt += 1) {
Expand All @@ -261,7 +276,16 @@ try {
const pills=[...document.querySelectorAll('.ol-live-transcript-pill')];
const chars=[...document.querySelectorAll('.ol-live-transcript-char')];
const rect=pills[0]?.getBoundingClientRect();
return {pills:pills.length,chars:chars.length,width:rect?.width,font:chars[0]?parseFloat(getComputedStyle(chars[0]).fontSize):0,buttons:document.querySelectorAll('button').length};
const rail=document.querySelector('.ol-capsule-transcript-rail');
const body=${JSON.stringify(style)}==='typeless'?document.querySelector('.ol-capsule-waveform'): ${JSON.stringify(style)}==='classic'?document.querySelector('.ol-capsule-pill'):document.querySelector('canvas');
const hint=document.querySelector('.ol-typeless-translation');
const hintRect=hint?.getBoundingClientRect();
const hintStyle=hint?getComputedStyle(hint):null;
const railRect=rail?.getBoundingClientRect();
const bodyRect=body?.getBoundingClientRect();
const translationHintVisible=Boolean(hint && hintStyle?.opacity !== '0' && hintRect && hintRect.width > 0 && hintRect.top >= -0.5 && hintRect.bottom <= innerHeight + 0.5);
const translationHintNonOverlapping=Boolean(!translationHintVisible || !hintRect || !bodyRect || (hintRect.bottom <= bodyRect.top + 0.5 && (!railRect || hintRect.top >= railRect.bottom - 0.5)));
return {pills:pills.length,chars:chars.length,width:rect?.width,font:chars[0]?parseFloat(getComputedStyle(chars[0]).fontSize):0,buttons:document.querySelectorAll('button').length,rail:Boolean(rail),body:Boolean(body),railOutsideBody:Boolean(rail && body && !body.contains(rail)),translationHintVisible,translationHintNonOverlapping};
})()`);
if (result.pills !== (enabled ? 1 : 0)) {
writeFileSync(
Expand All @@ -272,7 +296,18 @@ try {
assert.equal(result.pills, enabled ? 1 : 0, `${style} display preference`);
if (enabled) {
assert(result.chars > 0, `${style} original text`);
assert(result.chars <= 64, `${style} motion glyph count stays bounded`);
assert(
result.rail && result.body && result.railOutsideBody,
`${style} keeps the capsule body beside, not inside, the transcript rail`,
);
assert(result.width <= (style === 'typeless' ? 206 : 460), `${style} bounded width`);
if (style === 'typeless') {
assert(result.translationHintVisible, 'typeless translation hint stays inside the native viewport');
if (enabled) {
assert(result.translationHintNonOverlapping, 'typeless translation hint has a dedicated non-overlapping row');
}
}
assert(
Math.abs(result.font * (style === 'typeless' ? 0.447 : 1) - 20) < 0.1,
`${style} visible font size`,
Expand Down
184 changes: 152 additions & 32 deletions openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,153 @@ assertMatch(
const coordinatorRs = (
await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')
).replace(/\r\n/g, '\n');
assertMatch(
coordinatorHostRs,
/fn set_cursor_passthrough[\s\S]*?window\.set_ignore_cursor_events\(passthrough\)[\s\S]*?cursor_passthrough[\s\S]*?store\(passthrough, Ordering::SeqCst\)/,
'the narrow capsule-window capability must update the Tauri window and its host-owned passthrough cache together',
);

function extractBalancedBlock(source, openBrace) {
let depth = 0;
let inString = false;
let inChar = false;
let escaped = false;
let inLineComment = false;
let blockCommentDepth = 0;
let rawStringHashes = null;
for (let index = openBrace; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (inLineComment) {
if (char === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (char === '/' && next === '*') {
blockCommentDepth += 1;
index += 1;
} else if (char === '*' && next === '/') {
blockCommentDepth -= 1;
index += 1;
}
continue;
}
if (rawStringHashes !== null) {
const terminator = `"${'#'.repeat(rawStringHashes)}`;
if (source.startsWith(terminator, index)) {
index += terminator.length - 1;
rawStringHashes = null;
}
continue;
}
if (inString || inChar) {
const terminator = inString ? '"' : "'";
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === terminator) {
inString = false;
inChar = false;
}
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
blockCommentDepth = 1;
index += 1;
continue;
}
if (char === 'r') {
let hashEnd = index + 1;
while (source[hashEnd] === '#') hashEnd += 1;
if (source[hashEnd] === '"') {
rawStringHashes = hashEnd - index - 1;
index = hashEnd;
continue;
}
}
if (char === '"') {
inString = true;
continue;
}
// Rust lifetimes start with a letter after the apostrophe; only enter the
// character-literal state when the closing apostrophe is locally evident.
if (char === "'" && (next === '\\' || source[index + 2] === "'")) {
inChar = true;
continue;
}
if (char === '{') depth += 1;
if (char === '}' && --depth === 0) {
return source.slice(openBrace, index + 1);
}
}
throw new Error('unterminated Rust block');
}

function extractFn(source, name) {
const match = source.match(new RegExp(`(?:pub\\(crate\\) )?fn ${name}[\\s\\S]*?\\n}\\n`));
const signature = new RegExp(
`(?:pub\\([^)]*\\)\\s*)?(?:async\\s+)?fn\\s+${name}(?:<[^>{}]*>)?\\s*\\(`,
);
const match = signature.exec(source);
if (!match) {
throw new Error(`${name}: function not found in coordinator.rs`);
throw new Error(`${name}: function not found`);
}
const openBrace = source.indexOf('{', match.index);
if (openBrace === -1) {
throw new Error(`${name}: function body not found`);
}
return match[0];
const body = extractBalancedBlock(source, openBrace);
return source.slice(match.index, openBrace) + body;
}

function extractCfgBlock(source, target) {
const marker = `#[cfg(target_os = "${target}")]`;
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
throw new Error(`${target}: cfg block not found`);
}
const openBrace = source.indexOf('{', markerIndex + marker.length);
if (openBrace === -1) {
throw new Error(`${target}: cfg block body not found`);
}
return source.slice(markerIndex, openBrace) + extractBalancedBlock(source, openBrace);
}

const setCursorPassthroughRs = extractFn(coordinatorHostRs, 'set_cursor_passthrough');
assertMatch(
setCursorPassthroughRs,
/window\.set_ignore_cursor_events\(passthrough\)[\s\S]*?cursor_passthrough[\s\S]*?store\(passthrough, Ordering::SeqCst\)/,
'the narrow capsule-window capability must update the Tauri window and its host-owned passthrough cache together',
);
const setCardHitTestModeRs = extractFn(coordinatorHostRs, 'set_card_hit_test_mode');
const restoreCapsuleHitTestModeRs = extractFn(coordinatorHostRs, 'restore_capsule_hit_test_mode');
assertMatch(
setCardHitTestModeRs,
/hit_test_mode[\s\S]*?store\(HIT_TEST_MODE_CARD, Ordering::SeqCst\)[\s\S]*?window\.set_ignore_cursor_events\(false\)[\s\S]*?cursor_passthrough[\s\S]*?store\(false, Ordering::SeqCst\)[\s\S]*?configure_card_hit_test/,
'card mode must make the shared window interactive and keep its host-owned hit-test cache in sync',
);
assertMatch(
restoreCapsuleHitTestModeRs,
/hit_test_mode[\s\S]*?store\(HIT_TEST_MODE_CAPSULE, Ordering::SeqCst\)[\s\S]*?set_cursor_passthrough\(true\)[\s\S]*?invalidate_layout\(\)[\s\S]*?maybe_position_capsule_bottom_center/,
'restoring capsule mode must restore passthrough and style-aware position through the host capability',
);
const maybePositionCapsuleRs = extractFn(coordinatorHostRs, 'maybe_position_capsule_bottom_center');
assertMatch(
maybePositionCapsuleRs,
/position_capsule_bottom_center_with_style_and_transcript/,
'capsule restore must route through the style-aware geometry helper',
);
const positionCapsuleRs = extractFn(
libRs,
'position_capsule_bottom_center_with_style_and_transcript',
);
const macosPositionCapsuleRs = extractCfgBlock(positionCapsuleRs, 'macos');
assertMatch(
macosPositionCapsuleRs,
/window\.set_size[\s\S]*?window\.set_position/,
'macOS style-aware capsule restoration must update both window size and position',
);

// Showing a card = moving the shared window away; the dedup cache must be invalidated on the spot.
for (const name of ['show_vocab_suggestion_card', 'show_insert_fallback_card']) {
const body = extractFn(coordinatorRs, name);
Expand All @@ -129,8 +262,8 @@ for (const name of ['show_vocab_suggestion_card', 'show_insert_fallback_card'])
);
assertMatch(
body,
/capsule\.set_cursor_passthrough\(false\)/,
`${name} must change cursor passthrough through the host capability that keeps its cache in sync`,
/capsule\.set_card_hit_test_mode\(\)/,
`${name} must enter card hit-test mode through the host capability that keeps its cache in sync`,
);
}

Expand All @@ -139,32 +272,19 @@ for (const name of ['hide_vocab_suggestion_card', 'hide_insert_fallback_card'])
const body = extractFn(coordinatorRs, name);
assertMatch(
body,
/set_cursor_passthrough\(true\)/,
`${name} must restore cursor passthrough, or the capsule keeps blocking that strip of screen`,
);
assertMatch(
body,
/capsule_window_bounds\(false\)[\s\S]*?set_size/,
`${name} must restore the capsule window size, or the next capsule is squeezed into a card-sized window`,
);
assertMatch(
body,
/capsule\.invalidate_layout\(\)/,
`${name} must invalidate the capsule_layout dedup cache, or the next recording skips repositioning and the capsule stays bottom-right`,
/capsule\.restore_capsule_hit_test_mode\(\)/,
`${name} must restore passthrough and capsule geometry through the host capability`,
);
assertMatch(
body,
/position_capsule_bottom_center\(false\)/,
`${name} must move the capsule window back to bottom-center; restoring size alone leaves it in the card's bottom-right corner`,
/capsule\.hide\(\)[\s\S]*?capsule\.restore_capsule_hit_test_mode\(\)/,
`${name} must hide the window before the host restores its geometry`,
);
// Ordering invariant: size and position must change together; changing them while the window is
// still visible can composite a frame of "card stretched wide, still flying across half the
// screen".
// Ordering invariant: geometry restoration must happen after hiding, or the restore can
// composite a frame of "card stretched wide, still flying across half the screen".
const hideAt = body.indexOf('capsule.hide()');
const resizeAt = body.indexOf('set_size');
if (hideAt === -1 || hideAt > resizeAt) {
throw new Error(
`${name} must hide the window before changing its geometry, or the restore animates on screen`,
);
const restoreAt = body.indexOf('restore_capsule_hit_test_mode()');
if (hideAt === -1 || hideAt > restoreAt) {
throw new Error(`${name} must hide the window before restoring its geometry`);
}
}
Loading
Loading