Merge remote-tracking branch 'origin/main' into add-hachidori-backend

# Conflicts:
#	launcher/main.test.ts
#	src/cli/help.test.ts
#	src/core/services/__tests__/stats-server.test.ts
#	src/core/services/cli-command.test.ts
#	src/core/services/tokenizer/yomitan-parser-runtime.test.ts
#	src/main/overlay-runtime.test.ts
#	src/main/runtime/character-dictionary-auto-sync.test.ts
#	src/main/runtime/cli-command-context-deps.test.ts
#	src/main/runtime/cli-command-context-factory.test.ts
#	src/main/runtime/cli-command-context-main-deps.test.ts
#	src/main/runtime/composers/cli-startup-composer.test.ts
#	src/main/runtime/stats-server-runtime.test.ts
#	src/main/runtime/tray-main-actions.test.ts
#	src/main/runtime/tray-main-deps.test.ts
#	src/main/runtime/tray-runtime-handlers.test.ts
#	src/main/runtime/tray-runtime.test.ts
#	src/main/runtime/yomitan-anki-server-sync.test.ts
#	src/preload.ts
#	src/renderer/handlers/mouse.test.ts
#	src/renderer/yomitan-popup.test.ts
This commit is contained in:
sudacode committed 2026-10-05 09:01:11 -07:00
commit 20f151482a
417 files changed
+19477 -60921

No files matched your search

File diff suppressed because it is too large. Load diff
+24 -16
View File
@@ -476,21 +476,29 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each top-level change item should:
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
5. Keep items brief. Release notes summarize; the docs hold the details.
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
- Name a config key only when users must set it to use the change or to keep their current behavior.
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
- **Playlist Browser**:
- Saved shows now open without rescanning the library.
- The picker remembers the last folder you browsed between launches.
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
Bullets inside the Internal section may stay single-level.
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
6. Match the length and density of this example:
### Added
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
### Changed
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
### Fixed
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
- **Jellyfin 12**:
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
- Casting no longer drops after about a minute.
- Mined cards get the episode title again.
7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
@@ -791,7 +799,7 @@ function polishFragmentsWithClaude(
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'',
].join('\n')
: '';
@@ -814,7 +822,7 @@ Rules:
3. ADDED fragments describe changes that are new in this build; summarize them.
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
6. Keep bullets short and concrete. Use nested bullets sparingly.
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
7. Do not invent changes. Every bullet must be grounded in the diffs.
8. If no bullet survives rules 2-5, output exactly this single line:
- No user-facing changes since PREVIOUS_TAG.
-26
View File
@@ -1,26 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/compare-yomitan-api.ts', 'utf8');
test('creates the configured Yomitan user-data directory before Electron uses it', () => {
const setPathIndex = source.indexOf(
"electronModule.app.setPath('userData', options.yomitanUserDataPath)",
);
assert.notEqual(setPathIndex, -1);
const mkdirIndex = source.lastIndexOf(
'fs.mkdirSync(options.yomitanUserDataPath, { recursive: true })',
setPathIndex,
);
assert.ok(mkdirIndex > -1, 'must create the user-data directory');
assert.ok(mkdirIndex < setPathIndex, 'must create the directory before app.setPath');
});
test('lets output drain by using exitCode in the top-level completion handlers', () => {
const completionHandlers = source.slice(source.lastIndexOf('\nmain()'));
assert.match(completionHandlers, /process\.exitCode = process\.exitCode \?\? 0/);
assert.match(completionHandlers, /process\.exitCode = 1/);
assert.doesNotMatch(completionHandlers, /process\.exit\(/);
});
+1
View File
@@ -21,6 +21,7 @@ const requiredDocs = [
'docs/workflow/README.md',
'docs/workflow/agent-skills.md',
'docs/workflow/planning.md',
'docs/workflow/testing.md',
'docs/workflow/verification.md',
] as const;
-94
View File
@@ -1,94 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
assert.notEqual(minimizedAssignmentIndex, -1);
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
assert.notEqual(loopStartIndex, -1);
const pidExtractionIndex = source.indexOf(
'AXUIElementGetPid(window, &windowPid)',
loopStartIndex,
);
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
assert.ok(
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
'window PID must be extracted before accepting a minimized window',
);
assert.ok(
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
'window PID must match the owning app before accepting a minimized window',
);
assert.ok(
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
'target socket must be validated before accepting a minimized window',
);
});
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
/case\s+\.active:/.test(source),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('if windowHasTargetSocket(frontmost.pid)'),
'active state should still accept a matching target socket when available',
);
assert.ok(
source.includes('return targetMpvSocketPath != nil'),
'active state should preserve frontmost mpv even if command-line socket detection fails',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
assert.ok(
/case\s+\.inactive:/.test(source),
'helper should expose an inactive state without window geometry',
);
assert.ok(
source.includes('if frontmost != nil'),
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
);
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
assert.ok(
source.includes('print("inactive")'),
'inactive state should be printed for the tracker',
);
});
-45
View File
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
function createWorkspace(name: string): string {
const baseDir = path.join(process.cwd(), '.tmp', 'get-frequency-typecheck-test');
fs.mkdirSync(baseDir, { recursive: true });
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
test('scripts/get_frequency.ts typechecks in isolation', () => {
const workspace = createWorkspace('isolated-script');
const tsconfigPath = path.join(workspace, 'tsconfig.json');
fs.writeFileSync(
tsconfigPath,
JSON.stringify(
{
extends: '../../../tsconfig.typecheck.json',
include: ['../../../scripts/get_frequency.ts'],
exclude: [],
},
null,
2,
),
'utf8',
);
try {
const result = spawnSync('bunx', ['tsc', '--noEmit', '-p', tsconfigPath], {
cwd: process.cwd(),
encoding: 'utf8',
});
assert.equal(
result.status,
0,
`expected scripts/get_frequency.ts to typecheck\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
-18
View File
@@ -39,24 +39,6 @@ describe('Bun corresponding-source manifest', () => {
}
});
test('pins the source to the revision reported by the distributed binary', async () => {
const manifest = parseSourceManifest(
JSON.parse(
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
),
);
expect(manifest.version).toBe('1.3.5');
expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
'6d0f3aac0b817cc01a846b3754b21271adedac12',
);
expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
'29985a3b59898861442fa3b43f663fc1af2591d7',
);
});
test('parses commit and tag registrations from Bun CMake', () => {
const registrations = parseRegisteredRepositories(`
register_repository(
-16
View File
@@ -1,16 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };
const command = packageJson.scripts['build:syncui'] ?? '';
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
-27
View File
@@ -1,27 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
test('macOS helper build creates dist scripts directory before swiftc output', () => {
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
assert.notEqual(buildFunctionIndex, -1);
const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
assert.notEqual(swiftcIndex, -1);
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
assert.ok(
ensureDirIndex > buildFunctionIndex,
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
);
});
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
// version as the helper's minimum, so released builds refuse to load on older macOS.
test('macOS helper is compiled with an explicit deployment target', () => {
assert.match(source, /-target/);
assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
});
+3 -3
View File
@@ -75,7 +75,7 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});
test('runCoverageLane returns a failure when a discovered test fails', () => {
test('runCoverageLane returns a failure when a discovered test fails', async () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
try {
mkdirSync(join(repoRoot, 'src'));
@@ -93,10 +93,10 @@ test('runCoverageLane returns a failure when a discovered test fails', () => {
);
assert.notEqual(
runCoverageLane({
await runCoverageLane({
repoRootDir: repoRoot,
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
stdio: 'pipe',
quiet: true,
}),
0,
);
+69 -33
View File
@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { availableParallelism } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { collectLaneFiles } from './test-lanes';
@@ -201,9 +202,40 @@ export function mergeLcovReports(reports: string[]): string {
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
export function runCoverageLane(
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
): number {
type ShardResult = { status: number; output: string; lcov: string | null };
// Runs one test file under coverage into its own shard directory, buffering
// output so parallel shards do not interleave on the terminal.
function runShard(repoRootDir: string, file: string, shardDir: string): Promise<ShardResult> {
return new Promise((resolveShard) => {
const child = spawn(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{ cwd: repoRootDir },
);
let output = '';
child.stdout.on('data', (chunk) => (output += chunk));
child.stderr.on('data', (chunk) => (output += chunk));
child.on('error', (error) => resolveShard({ status: 1, output: String(error), lcov: null }));
child.on('close', (code) => {
const lcovPath = join(shardDir, 'lcov.info');
const lcov = existsSync(lcovPath) ? readFileSync(lcovPath, 'utf8') : null;
resolveShard({ status: code ?? 1, output, lcov });
});
});
}
function parseJobsArg(argv: string[]): number {
const index = argv.indexOf('--jobs');
if (index === -1) return availableParallelism();
return Math.max(1, Number(argv[index + 1]) || 1);
}
// Shards run in parallel (one per CPU unless --jobs N). The first failing shard
// stops scheduling new shards and its exit status becomes the lane result.
export async function runCoverageLane(
options: { repoRootDir?: string; argv?: string[]; quiet?: boolean } = {},
): Promise<number> {
const repoRootDir = options.repoRootDir ?? repoRoot;
const argv = options.argv ?? process.argv.slice(2);
const laneName = argv[0];
@@ -225,40 +257,44 @@ export function runCoverageLane(
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1;
}
const reports: string[] = [];
const reports: Array<string | null> = new Array(files.length).fill(null);
const failures: Array<{ file: string; result: ShardResult }> = [];
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (failures.length === 0 && nextIndex < files.length) {
const index = nextIndex++;
const file = files[index]!;
const shardDir = join(shardRoot, String(index + 1).padStart(3, '0'));
const result = await runShard(repoRootDir, file, shardDir);
if (result.status !== 0) {
failures.push({ file, result });
return;
}
if (result.lcov === null && !options.quiet) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
}
reports[index] = result.lcov;
}
};
try {
for (const [index, file] of files.entries()) {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
const result = spawnSync(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRootDir,
stdio: options.stdio ?? 'inherit',
},
);
const jobs = Math.min(parseJobsArg(argv), files.length);
await Promise.all(Array.from({ length: jobs }, worker));
if (result.error) {
throw result.error;
const [failure] = failures;
if (failure !== undefined) {
const { file, result } = failure;
if (!options.quiet) {
process.stderr.write(`${result.output}\nCoverage shard failed: ${file}\n`);
}
if ((result.status ?? 1) !== 0) {
return result.status ?? 1;
}
const lcovPath = join(shardDir, 'lcov.info');
if (!existsSync(lcovPath)) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
continue;
}
reports.push(readFileSync(lcovPath, 'utf8'));
return result.status;
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
);
const lcovPath = join(coverageDir, 'lcov.info');
writeFileSync(lcovPath, mergeLcovReports(reports.filter((r) => r !== null)), 'utf8');
process.stdout.write(`Merged LCOV written to ${relative(repoRootDir, lcovPath)}\n`);
return 0;
} finally {
rmSync(shardRoot, { recursive: true, force: true });
@@ -267,5 +303,5 @@ export function runCoverageLane(
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
if (import.meta.main) {
process.exit(runCoverageLane());
runCoverageLane().then((status) => process.exit(status));
}
+9 -1
View File
@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { availableParallelism } from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { collectLaneFiles } from './test-lanes.ts';
@@ -7,6 +8,8 @@ import { collectLaneFiles } from './test-lanes.ts';
// test file so a hanging test or leaked global in one file cannot poison the
// rest of the lane. Use --single-process for the old all-in-one-process mode.
//
// Files run in parallel, one worker per CPU by default; --jobs 1 runs serially.
//
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
@@ -34,7 +37,12 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
}
function parseArgs(argv) {
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
const options = {
lane: undefined,
jobs: availableParallelism(),
timeoutSecs: 300,
singleProcess: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--jobs') {
-9
View File
@@ -61,15 +61,6 @@ test('normalizeTarget maps each supported electron-builder target without using
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
});
test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
const config = runtimeConfig();
assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
});
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
const config = await loadRuntimeConfig();
assert.equal(config.version, '1.3.5');