11 Commits

Author SHA1 Message Date
385959a1bd docs: regenerate config example 2026-03-16 01:09:06 -07:00
fbfd688109 Route OSD notifications through overlay with mpv fallback
- Add main/renderer overlay notification pipeline with loading-state support
- Preserve notificationType semantics across osd/system/both/none routing
- Update plugin fallback behavior, docs/config examples, and regression tests
2026-03-16 00:52:13 -07:00
e35aac6ee0 chore: prepare v0.6.5 release 2026-03-15 21:05:58 -07:00
fe2da22d29 chore: prepare v0.6.4 release 2026-03-15 20:43:50 -07:00
2045ffbff8 chore: prepare v0.6.3 release 2026-03-15 20:16:36 -07:00
478869ff28 feat(controller): add inline remap modal with descriptor-based bindings (#21) 2026-03-15 15:55:45 -07:00
9eed37420e Automate AUR publish in tagged release workflow (#22) 2026-03-14 19:49:46 -07:00
99f4d2baaf Harden subminer-scrum-master pre-handoff policy checks (#20) 2026-03-13 01:40:48 -07:00
f4e8c3feec chore: make verifier script executable 2026-03-13 00:44:00 -07:00
d0b308f340 test: harden agent verification workflow 2026-03-13 00:44:00 -07:00
1b56360a24 feat(yomitan): add read-only external profile support for shared dictionaries (#18) 2026-03-12 01:17:34 -07:00
139 changed files with 7192 additions and 2326 deletions

View File

@@ -0,0 +1,127 @@
---
name: "subminer-change-verification"
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
---
# SubMiner Change Verification
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
## Scripts
- `scripts/classify_subminer_diff.sh`
- Emits suggested lanes and flags from explicit paths or current git changes.
- `scripts/verify_subminer_change.sh`
- Runs selected lanes, captures artifacts, and writes a compact summary.
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
```bash
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
```
## Default workflow
1. Inspect the changed files or user-requested area.
2. Run the classifier unless you already know the right lane.
3. Run the verifier with the cheapest sufficient lane set.
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
6. Return:
- verification summary
- exact commands run
- artifact paths
- skipped lanes and blockers
## Quick start
Repo-source quick start:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Installed-skill quick start:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
```
Classify explicit files:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
launcher/main.ts \
plugin/subminer/lifecycle.lua \
src/main/runtime/mpv-client-runtime-service.ts
```
Run automatic lane selection:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Installed-skill form:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
```
Run targeted lanes:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--lane launcher-plugin \
--lane runtime-compat
```
Dry-run to inspect planned commands and artifact layout:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--dry-run \
launcher/main.ts \
src/main.ts
```
## Lane guidance
- `docs`
- For `docs-site/`, `docs/`, and doc-only edits.
- `config`
- For `src/config/` and config-template-sensitive edits.
- `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin`
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
- `runtime-compat`
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
- `real-runtime`
- Only after deliberate escalation.
## Real Runtime Escalation
Escalate only when the change claim depends on actual runtime behavior, for example:
- overlay appears, hides, or tracks a real mpv window
- mpv launch flags or pause-until-ready behavior
- plugin/socket/auto-start handshake under a real player
- macOS/window-tracker/focus-sensitive behavior
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
## Artifact contract
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
- `summary.json`
- `summary.txt`
- `classification.txt`
- `env.txt`
- `lanes.txt`
- `steps.tsv`
- `steps/*.stdout.log`
- `steps/*.stderr.log`
On failure, quote the exact failing command and point at the artifact directory.

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: classify_subminer_diff.sh [path ...]
Emit suggested verification lanes for explicit paths or current local git changes.
Output format:
lane:<name>
flag:<name>
reason:<text>
EOF
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
add_lane() {
local lane=$1
if ! has_item "$lane" "${LANES[@]:-}"; then
LANES+=("$lane")
fi
}
add_flag() {
local flag=$1
if ! has_item "$flag" "${FLAGS[@]:-}"; then
FLAGS+=("$flag")
fi
}
add_reason() {
REASONS+=("$1")
}
collect_git_paths() {
local top_level
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
return 0
fi
(
cd "$top_level"
if git rev-parse --verify HEAD >/dev/null 2>&1; then
git diff --name-only --relative HEAD --
git diff --name-only --relative --cached --
else
git diff --name-only --relative --
git diff --name-only --relative --cached --
fi
git ls-files --others --exclude-standard
) | awk 'NF' | sort -u
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
declare -a PATHS=()
declare -a LANES=()
declare -a FLAGS=()
declare -a REASONS=()
if [[ $# -gt 0 ]]; then
while [[ $# -gt 0 ]]; do
PATHS+=("$1")
shift
done
else
while IFS= read -r line; do
[[ -n "$line" ]] && PATHS+=("$line")
done < <(collect_git_paths)
fi
if [[ ${#PATHS[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no changed paths detected -> default to core"
fi
for path in "${PATHS[@]}"; do
specialized=0
case "$path" in
docs-site/*|docs/*|changes/*|README.md)
add_lane "docs"
add_reason "$path -> docs"
specialized=1
;;
esac
case "$path" in
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
add_lane "config"
add_reason "$path -> config"
specialized=1
;;
esac
case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin"
add_reason "$path -> launcher-plugin"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
case "$path" in
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
add_lane "runtime-compat"
add_reason "$path -> runtime-compat"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
if [[ "$specialized" == "0" ]]; then
case "$path" in
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
add_lane "core"
add_reason "$path -> core"
;;
esac
fi
case "$path" in
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
add_flag "broad-impact"
add_reason "$path -> broad-impact"
;;
esac
done
if [[ ${#LANES[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no lane-specific matches -> default to core"
fi
for lane in "${LANES[@]}"; do
printf 'lane:%s\n' "$lane"
done
for flag in "${FLAGS[@]}"; do
printf 'flag:%s\n' "$flag"
done
for reason in "${REASONS[@]}"; do
printf 'reason:%s\n' "$reason"
done

View File

@@ -0,0 +1,566 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: verify_subminer_change.sh [options] [path ...]
Options:
--lane <name> Force a verification lane. Repeatable.
--artifact-dir <dir> Use an explicit artifact directory.
--allow-real-runtime Allow explicit real-runtime execution.
--allow-real-gui Deprecated alias for --allow-real-runtime.
--dry-run Record planned steps without executing commands.
--help Show this help text.
If no lanes are supplied, the script classifies the provided paths. If no paths are
provided, it classifies the current local git changes.
Authoritative real-runtime verification should be requested with explicit path
arguments instead of relying on inferred local git changes.
EOF
}
timestamp() {
date +%Y%m%d-%H%M%S
}
timestamp_iso() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
generate_session_id() {
local tmp_dir
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
basename "$tmp_dir"
rmdir "$tmp_dir"
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
normalize_lane_name() {
case "$1" in
real-gui)
printf '%s' "real-runtime"
;;
*)
printf '%s' "$1"
;;
esac
}
add_lane() {
local lane
lane=$(normalize_lane_name "$1")
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
SELECTED_LANES+=("$lane")
fi
}
add_blocker() {
BLOCKERS+=("$1")
BLOCKED=1
}
append_step_record() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
}
record_env() {
{
printf 'repo_root=%s\n' "$REPO_ROOT"
printf 'session_id=%s\n' "$SESSION_ID"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
printf 'dry_run=%s\n' "$DRY_RUN"
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
printf 'session_home=%s\n' "$SESSION_HOME"
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
printf 'pwd=%s\n' "$(pwd)"
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
git status --short 2>/dev/null || true
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf 'requested_paths=\n'
printf ' %s\n' "${PATH_ARGS[@]}"
fi
} >"$ARTIFACT_DIR/env.txt"
}
run_step() {
local lane=$1
local name=$2
local command=$3
local note=${4:-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log"
local stderr_rel="steps/${slug}.stderr.log"
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
local status exit_code
COMMANDS_RUN+=("$command")
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$command" >"$stdout_path"
: >"$stderr_path"
status="dry-run"
exit_code=0
else
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
status="passed"
exit_code=0
EXECUTED_REAL_STEPS=1
else
exit_code=$?
status="failed"
FAILED=1
fi
fi
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
if [[ "$status" == "failed" ]]; then
FAILURE_STEP="$name"
FAILURE_COMMAND="$command"
FAILURE_STDOUT="$stdout_rel"
FAILURE_STDERR="$stderr_rel"
return "$exit_code"
fi
}
record_nonpassing_step() {
local lane=$1
local name=$2
local status=$3
local note=$4
local slug=${name//[^a-zA-Z0-9_-]/-}
local stdout_rel="steps/${slug}.stdout.log"
local stderr_rel="steps/${slug}.stderr.log"
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
: >"$ARTIFACT_DIR/$stderr_rel"
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
}
record_skipped_step() {
record_nonpassing_step "$1" "$2" "skipped" "$3"
}
record_blocked_step() {
add_blocker "$3"
record_nonpassing_step "$1" "$2" "blocked" "$3"
}
record_failed_step() {
FAILED=1
FAILURE_STEP=$2
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
add_blocker "$3"
record_nonpassing_step "$1" "$2" "failed" "$3"
}
find_real_runtime_helper() {
local candidate
for candidate in \
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
acquire_real_runtime_lease() {
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
local lease_dir="$lease_root/exclusive-real-runtime"
mkdir -p "$lease_root"
if mkdir "$lease_dir" 2>/dev/null; then
REAL_RUNTIME_LEASE_DIR="$lease_dir"
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
return 0
fi
local owner=""
if [[ -f "$lease_dir/session_id" ]]; then
owner=$(cat "$lease_dir/session_id")
fi
add_blocker "real-runtime lease already held${owner:+ by $owner}"
return 1
}
release_real_runtime_lease() {
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
local owner
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
if [[ "$owner" != "$SESSION_ID" ]]; then
return 0
fi
fi
rm -rf "$REAL_RUNTIME_LEASE_DIR"
fi
}
compute_final_status() {
if [[ "$FAILED" == "1" ]]; then
FINAL_STATUS="failed"
elif [[ "$BLOCKED" == "1" ]]; then
FINAL_STATUS="blocked"
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
FINAL_STATUS="passed"
else
FINAL_STATUS="skipped"
fi
}
write_summary_files() {
local lane_lines
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
SESSION_ID_ENV="$SESSION_ID" \
FINAL_STATUS_ENV="$FINAL_STATUS" \
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
SESSION_HOME_ENV="$SESSION_HOME" \
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
STARTED_AT_ENV="$STARTED_AT" \
FINISHED_AT_ENV="$FINISHED_AT" \
FAILED_ENV="$FAILED" \
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
bun -e '
const fs = require("fs");
const path = require("path");
function readLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
}
const artifactDir = process.env.ARTIFACT_DIR_ENV;
const reportsDir = path.join(artifactDir, "reports");
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
return {
lane,
name,
status,
exitCode: Number(exitCode || 0),
command,
stdout,
stderr,
note,
};
});
const summary = {
sessionId: process.env.SESSION_ID_ENV || "",
artifactDir,
reportsDir,
status: process.env.FINAL_STATUS_ENV || "failed",
selectedLanes: lanes,
failed: process.env.FAILED_ENV === "1",
failure:
process.env.FAILED_ENV === "1"
? {
command: process.env.FAILURE_COMMAND_ENV || "",
stdout: process.env.FAILURE_STDOUT_ENV || "",
stderr: process.env.FAILURE_STDERR_ENV || "",
}
: null,
blockers,
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
requestedPaths,
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
startedAt: process.env.STARTED_AT_ENV || "",
finishedAt: process.env.FINISHED_AT_ENV || "",
env: {
home: process.env.SESSION_HOME_ENV || "",
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
},
steps,
};
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
const lines = [];
lines.push(`session_id: ${summary.sessionId}`);
lines.push(`artifact_dir: ${artifactDir}`);
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
lines.push(`status: ${summary.status}`);
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
if (requestedPaths.length > 0) {
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
}
if (blockers.length > 0) {
lines.push(`blockers: ${blockers.join(" | ")}`);
}
for (const step of steps) {
lines.push(`${step.lane}/${step.name}: ${step.status}`);
if (step.command) lines.push(` command: ${step.command}`);
lines.push(` stdout: ${step.stdout}`);
lines.push(` stderr: ${step.stderr}`);
if (step.note) lines.push(` note: ${step.note}`);
}
if (summary.failed) {
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
}
const summaryText = lines.join("\n") + "\n";
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
'
}
cleanup() {
release_real_runtime_lease
}
CLASSIFIER_OUTPUT=""
ARTIFACT_DIR=""
ALLOW_REAL_RUNTIME=0
DRY_RUN=0
FAILED=0
BLOCKED=0
EXECUTED_REAL_STEPS=0
FINAL_STATUS=""
FAILURE_STEP=""
FAILURE_COMMAND=""
FAILURE_STDOUT=""
FAILURE_STDERR=""
REAL_RUNTIME_LEASE_DIR=""
STARTED_AT=""
FINISHED_AT=""
declare -a EXPLICIT_LANES=()
declare -a SELECTED_LANES=()
declare -a PATH_ARGS=()
declare -a COMMANDS_RUN=()
declare -a BLOCKERS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--lane)
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
shift 2
;;
--artifact-dir)
ARTIFACT_DIR=$2
shift 2
;;
--allow-real-runtime|--allow-real-gui)
ALLOW_REAL_RUNTIME=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--help|-h)
usage
exit 0
;;
--)
shift
while [[ $# -gt 0 ]]; do
PATH_ARGS+=("$1")
shift
done
;;
*)
PATH_ARGS+=("$1")
shift
;;
esac
done
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
SESSION_ID=$(generate_session_id)
PATH_SELECTION_MODE="git-inferred"
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
PATH_SELECTION_MODE="explicit"
fi
if [[ -z "$ARTIFACT_DIR" ]]; then
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
fi
SESSION_HOME="$ARTIFACT_DIR/home"
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
: >"$STEPS_TSV"
trap cleanup EXIT
STARTED_AT=$(timestamp_iso)
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
local_lane=""
for local_lane in "${EXPLICIT_LANES[@]}"; do
add_lane "$local_lane"
done
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
else
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
else
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
fi
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
while IFS= read -r line; do
case "$line" in
lane:*)
add_lane "${line#lane:}"
;;
esac
done <<<"$CLASSIFIER_OUTPUT"
fi
record_env
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
for lane in "${SELECTED_LANES[@]}"; do
case "$lane" in
docs)
run_step "$lane" "docs-test" "bun run docs:test" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "docs-build" "bun run docs:build" || break
;;
config)
run_step "$lane" "test-config" "bun run test:config" || break
;;
core)
run_step "$lane" "typecheck" "bun run typecheck" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-fast" "bun run test:fast" || break
;;
launcher-plugin)
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
;;
runtime-compat)
run_step "$lane" "build" "bun run build" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
[[ "$FAILED" == "1" ]] && break
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
;;
real-runtime)
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
record_blocked_step \
"$lane" \
"real-runtime-guard" \
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
break
fi
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
record_blocked_step \
"$lane" \
"real-runtime-guard" \
"real-runtime lane requested but --allow-real-runtime was not supplied"
break
fi
if ! acquire_real_runtime_lease; then
record_blocked_step \
"$lane" \
"real-runtime-lease" \
"real-runtime lease already held; rerun after the active runtime verification finishes"
break
fi
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
record_blocked_step \
"$lane" \
"real-runtime-helper" \
"real-runtime helper not implemented yet"
break
fi
printf -v REAL_RUNTIME_COMMAND \
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
"$SESSION_ID" \
"$SESSION_HOME" \
"$SESSION_XDG_CONFIG_HOME" \
"$SESSION_MPV_LOG" \
"$REAL_RUNTIME_HELPER"
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
;;
*)
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
break
;;
esac
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
break
fi
done
FINISHED_AT=$(timestamp_iso)
compute_final_status
write_summary_files
printf 'status=%s\n' "$FINAL_STATUS"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
case "$FINAL_STATUS" in
failed)
printf 'result=failed\n'
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
exit 1
;;
blocked)
printf 'result=blocked\n'
exit 2
;;
*)
printf 'result=ok\n'
exit 0
;;
esac

View File

@@ -0,0 +1,146 @@
---
name: "subminer-scrum-master"
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
---
# SubMiner Scrum Master
Own workflow, not code by default.
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
## Core Rules
1. Decide first whether backlog tracking is warranted.
2. If backlog is needed, search first. Update existing work when it clearly matches.
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
4. Record a plan before dispatching coding work.
5. Use parent + subtasks for multi-part work when backlog is used.
6. Dispatch conservatively. Parallelize only disjoint write scopes.
7. Require verification before handoff, typically via `subminer-change-verification`.
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
## Backlog Decision
Skip backlog when the request is:
- question only
- obvious mechanical edit
- tiny isolated change with no real planning
Use backlog when the work:
- needs planning or scope decisions
- spans multiple phases or subsystems
- is likely to need subagent dispatch
- should remain traceable for handoff/resume
If backlog is used:
- search existing tasks first
- create/update a standalone task for one focused deliverable
- create/update a parent task plus subtasks for multi-part work
- record the implementation plan in the task before implementation begins
## Intake Workflow
1. Parse the request.
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
2. Decide whether backlog is needed.
3. If backlog is needed:
- search first
- update existing task if clearly relevant
- otherwise create the right structure
- write the implementation plan before dispatch
4. If backlog is skipped:
- write a short working plan in-thread
- proceed without fake ticketing
5. Choose execution mode:
- no subagents for trivial work
- one worker for focused work
- parallel workers only for disjoint scopes
6. Run verification before handoff.
## Dispatch Rules
The scrum master orchestrates. Workers implement.
- Do not become the default implementer unless delegation is unnecessary.
- Do not parallelize overlapping files or tightly coupled runtime work.
- Give every worker explicit ownership of files/modules.
- Tell every worker other agents may be active and they must not revert unrelated edits.
- Require each worker to report:
- changed files
- tests run
- blockers
Use worker agents for implementation and explorer agents only for bounded codebase questions.
## Verification
Every nontrivial code task gets verification.
Preferred flow:
1. use `subminer-change-verification`
2. start with the cheapest sufficient lane
3. escalate only when needed
4. if worker verification is sufficient, accept it or run one final consolidating pass
Never hand off nontrivial work without stating what was verified and what was skipped.
## Pre-Handoff Policy Checks (Required)
Before handoff, always ask and answer both of these questions explicitly:
1. **Docs update required?**
2. **Changelog fragment required?**
Rules:
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
- If the answer is yes, either complete the update or report the blocker before handoff.
- Include the final answers in the handoff summary even when both answers are "no."
## Failure / Scope Handling
- If a worker hits ambiguity, pause and ask the user.
- If verification fails, either:
- send the worker back with exact failure context, or
- fix it directly if it is tiny and clearly in scope
- If new scope appears, revisit backlog structure before silently expanding work.
## Representative Flows
### Trivial no-ticket work
- decide backlog is unnecessary
- keep a short plan
- implement directly or with one worker if helpful
- run targeted verification
- report outcome concisely
### Single-task implementation
- search/create/update one task
- record plan
- dispatch one worker
- integrate
- verify
- update task and report outcome
### Parent + subtasks execution
- search/create/update parent task
- create subtasks for distinct deliverables/phases
- record sequencing in the plan
- dispatch workers only where scopes are disjoint
- integrate
- run consolidated verification
- update task state and report outcome
## Output Expectations
At the end, report:
- whether backlog was used and what changed
- which workers were dispatched and what they owned
- what verification ran
- explicit answers to:
- docs update required?
- changelog fragment required?
- blockers, skips, and risks

View File

@@ -317,7 +317,7 @@ jobs:
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
@@ -362,3 +362,85 @@ jobs:
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done
aur-publish:
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Validate AUR SSH secret
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
exit 1
fi
- name: Configure SSH for AUR
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
install -dm700 ~/.ssh
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Clone AUR repo
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
- name: Download release assets for AUR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="${{ steps.version.outputs.VERSION }}"
install -dm755 .tmp/aur-release-assets
gh release download "$version" \
--dir .tmp/aur-release-assets \
--pattern "SubMiner-${version#v}.AppImage" \
--pattern "subminer" \
--pattern "subminer-assets.tar.gz"
- name: Update AUR packaging metadata
run: |
set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}"
version_no_v="${version_no_v#v}"
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO
bash scripts/update-aur-package.sh \
--pkg-dir aur-subminer-bin \
--version "${{ steps.version.outputs.VERSION }}" \
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
--wrapper ".tmp/aur-release-assets/subminer" \
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
- name: Commit and push AUR update
working-directory: aur-subminer-bin
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
run: |
set -euo pipefail
if git diff --quiet -- PKGBUILD .SRCINFO; then
echo "AUR packaging already up to date."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
git push origin HEAD:master

14
.gitignore vendored
View File

@@ -35,6 +35,20 @@ docs/.vitepress/cache/
docs/.vitepress/dist/
tests/*
.worktrees/
.tmp/
.codex/*
.agents/*
!.agents/skills/
.agents/skills/*
!.agents/skills/subminer-change-verification/
!.agents/skills/subminer-scrum-master/
.agents/skills/subminer-change-verification/*
!.agents/skills/subminer-change-verification/SKILL.md
!.agents/skills/subminer-change-verification/scripts/
.agents/skills/subminer-change-verification/scripts/*
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
.agents/skills/subminer-scrum-master/*
!.agents/skills/subminer-scrum-master/SKILL.md
favicon.png
.claude/*

View File

@@ -1,5 +1,33 @@
# Changelog
## v0.6.5 (2026-03-15)
### Internal
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## v0.6.4 (2026-03-15)
### Internal
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
## v0.6.3 (2026-03-15)
### Changed
- Overlay: Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
### Internal
- Workflow: Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
- Release: Automate `subminer-bin` AUR package updates from the tagged release workflow.
## v0.6.2 (2026-03-12)
### Changed
- Config: Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
- Config: SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile.
- Config: Launcher-managed playback now respects `yomitan.externalProfilePath` and no longer forces first-run setup when external Yomitan is configured.
- Config: SubMiner now seeds `config.jsonc` even when the default config directory already exists.
- Config: First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed.
## v0.6.1 (2026-03-12)
### Added

View File

@@ -0,0 +1,82 @@
---
id: TASK-165
title: Automate AUR publish on tagged releases
status: Done
assignee:
- codex
created_date: '2026-03-14 15:55'
updated_date: '2026-03-14 18:40'
labels:
- release
- packaging
- linux
dependencies:
- TASK-161
references:
- .github/workflows/release.yml
- src/release-workflow.test.ts
- docs/RELEASING.md
- packaging/aur/subminer-bin/PKGBUILD
documentation:
- docs/plans/2026-03-14-aur-release-sync-design.md
- docs/plans/2026-03-14-aur-release-sync.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Extend the tagged release workflow so a successful GitHub release automatically updates the `subminer-bin` AUR package over SSH. Keep the PKGBUILD source-of-truth in this repo so release automation is reviewable and testable instead of depending on an external maintainer checkout.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repo-tracked AUR packaging source exists for `subminer-bin` and matches the current release artifact layout.
- [x] #2 The release workflow clones `ssh://aur@aur.archlinux.org/subminer-bin.git` with a dedicated secret-backed SSH key only after release artifacts are ready.
- [x] #3 The workflow updates `pkgver`, regenerates `sha256sums` from the built release artifacts, regenerates `.SRCINFO`, and pushes only when packaging files changed.
- [x] #4 Regression coverage fails if the AUR publish job, secret contract, or update steps are removed from the release workflow.
- [x] #5 Release docs mention the required `AUR_SSH_PRIVATE_KEY` setup and the new tagged-release side effect.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Record the approved design and implementation plan for direct AUR publishing from the release workflow.
2. Add failing release workflow regression tests covering the new AUR publish job, SSH secret, and PKGBUILD/.SRCINFO regeneration steps.
3. Reintroduce repo-tracked `packaging/aur/subminer-bin` source files as the maintained AUR template.
4. Add a small helper script that updates `pkgver`, computes checksums from release artifacts, and regenerates `.SRCINFO` deterministically.
5. Extend `.github/workflows/release.yml` with an AUR publish job that clones the AUR repo over SSH, runs the helper, commits only when needed, and pushes to `aur`.
6. Update release docs for the new secret/setup requirements and tagged-release behavior.
7. Run targeted workflow tests plus the SubMiner verification lane needed for workflow/docs changes, then update this task with results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added repo-tracked AUR packaging source under `packaging/aur/subminer-bin/` plus `scripts/update-aur-package.sh` to stamp `pkgver`, compute SHA-256 sums from release assets, and regenerate `.SRCINFO` with `makepkg --printsrcinfo`.
Extended `.github/workflows/release.yml` with a terminal `aur-publish` job that runs after `release`, validates `AUR_SSH_PRIVATE_KEY`, installs `makepkg`, configures SSH/known_hosts, clones `ssh://aur@aur.archlinux.org/subminer-bin.git`, downloads the just-published `SubMiner-<version>.AppImage`, `subminer`, and `subminer-assets.tar.gz` assets, updates packaging metadata, and pushes only when `PKGBUILD` or `.SRCINFO` changed.
Updated `src/release-workflow.test.ts` with regression assertions for the AUR publish contract and updated `docs/RELEASING.md` with the new secret/setup requirement.
Verification run:
- `bun test src/release-workflow.test.ts src/ci-workflow.test.ts`
- `bash -n scripts/update-aur-package.sh && bash -n packaging/aur/subminer-bin/PKGBUILD`
- `cd packaging/aur/subminer-bin && makepkg --printsrcinfo > .SRCINFO`
- updater smoke via temp package dir with fake assets and `v9.9.9`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `git submodule update --init --recursive` (required because the worktree lacked release submodules)
- `bun run build`
- `bun run test:smoke:dist`
Docs update required: yes, completed in `docs/RELEASING.md`.
Changelog fragment required: no; internal release automation only.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Tagged releases now attempt a direct AUR sync for `subminer-bin` using a dedicated SSH private key stored in `AUR_SSH_PRIVATE_KEY`. The release workflow clones the AUR repo after GitHub Release publication, rewrites `PKGBUILD` and `.SRCINFO` from the published release assets, and skips empty pushes. Repo-owned packaging source and workflow regression coverage were added so the automation remains reviewable and testable.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,94 @@
---
id: TASK-165
title: Make controller configuration easier with inline remapping modal
status: To Do
assignee:
- Codex
created_date: '2026-03-13 00:10'
updated_date: '2026-03-13 00:10'
labels:
- enhancement
- renderer
- overlay
- input
- config
dependencies:
- TASK-159
references:
- src/renderer/modals/controller-select.ts
- src/renderer/modals/controller-debug.ts
- src/renderer/handlers/gamepad-controller.ts
- src/renderer/index.html
- src/renderer/style.css
- src/renderer/utils/dom.ts
- src/preload.ts
- src/core/services/ipc.ts
- src/main.ts
- src/types.ts
- src/config/definitions/defaults-core.ts
- src/config/definitions/options-core.ts
- config.example.jsonc
- docs/plans/2026-03-13-overlay-controller-config-remap-design.md
- docs/plans/2026-03-13-overlay-controller-config-remap.md
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the current controller-selection-only modal with a denser controller configuration surface that keeps device selection and adds inline controller remapping. The new flow should feel like emulator configuration: pick an overlay action, arm capture, then press the matching controller button, trigger, d-pad direction, or stick direction to bind it. Keep the current overlay-local renderer architecture, preserve controller gating to keyboard-only mode, and retain the separate raw debug modal for troubleshooting.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `Alt+C` opens a controller modal that includes both preferred-controller selection and controller-config editing in one surface.
- [ ] #2 Controller device selection uses a compact dropdown or equivalent compact picker instead of the current full-height device list.
- [ ] #3 Each remappable controller action shows its current binding and supports learn/capture, clear, and reset-to-default flows.
- [ ] #4 Learn mode captures the next fresh controller input edge or stick/d-pad direction, not a held/stale input.
- [ ] #5 Captured bindings can represent non-standard controllers without depending only on the browser's standard semantic button names.
- [ ] #6 Updated bindings persist through the existing config pipeline and take effect in the renderer without restart unless a field explicitly requires reopen/reload.
- [ ] #7 Existing controller behavior remains gated to keyboard-only mode except for the controller action that toggles keyboard-only mode itself.
- [ ] #8 Renderer/config/IPC regression tests cover the new modal layout, capture flow, persistence, and runtime mapping behavior.
- [ ] #9 Docs/config example explain the new controller-config flow and when to use the debug modal.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add the design doc and implementation plan for inline controller remapping, tied to a new backlog task instead of reopening the already-completed base controller-support task.
2. Expand controller config types/defaults/template output so action bindings can store captured input descriptors, not only semantic button-name enums.
3. Extend preload/main/IPC write paths from preferred-controller-only saves to full controller-config patching needed by the modal.
4. Redesign the controller modal UI into a compact device picker plus action-binding editor with learn, clear, and reset affordances.
5. Add renderer capture state and a learn-mode runtime that waits for neutral-to-active transitions before saving a binding.
6. Update the gamepad runtime to resolve the new stored descriptors into actions while preserving current gating and repeat/deadzone behavior.
7. Keep the raw debug modal as a separate advanced surface; optionally expose copyable input-descriptor text for troubleshooting.
8. Add focused regression tests first, then run the maintained gate needed for docs/config/renderer/main changes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Planning only in this pass.
Current-state findings:
- `src/renderer/modals/controller-select.ts` only persists `preferredGamepadId` / `preferredGamepadLabel`.
- `src/preload.ts`, `src/core/services/ipc.ts`, and `src/main.ts` only expose a narrow save path for preferred controller, not general controller config writes.
- `src/renderer/handlers/gamepad-controller.ts` currently resolves actions from semantic button bindings plus a few axis slots; this is fine for defaults but too narrow for emulator-style learn mode on non-standard controllers.
- `src/renderer/modals/controller-debug.ts` already provides the raw input surface needed for troubleshooting and for validating capture behavior.
Recommended direction:
- keep `Alt+C` as the single controller-config entrypoint
- keep `Alt+Shift+C` as raw debug
- introduce stored input descriptors for discrete bindings so learn mode can capture buttons, triggers, d-pad directions, and stick directions directly
- defer per-controller profiles; keep one global binding set plus preferred-controller selection for this pass
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Planned follow-up work to make controller configuration materially easier than the current “pick preferred device” modal. The proposed change keeps existing controller runtime/debug foundations, but upgrades the selection modal into a compact controller-config surface with inline learn-mode remapping and persistent binding storage.
Main architectural change in scope: move from semantic-button-only binding storage toward captured input descriptors so the UI can reliably learn from buttons, triggers, d-pad directions, and stick directions on non-standard controllers.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,51 @@
---
id: TASK-166
title: Replace OSD notifications with overlay notifications and runtime fallback
status: In Progress
assignee: []
created_date: '2026-03-16 00:00'
updated_date: '2026-03-16 00:00'
labels:
- overlay
- notifications
- ux
dependencies: []
references:
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/main.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/renderer/renderer.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/plugin/subminer/process.lua
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace SubMiner's mpv OSD-first notification flow with overlay-native notifications. The visible overlay becomes the primary in-app notification surface, with fallback to mpv OSD only when the overlay is unavailable, hidden, disabled, or failed to load. Electron/system notifications remain unchanged. Existing notification copy and loading states should be refreshed to fit the richer overlay surface, especially spinner-driven loading states.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Main-process logical OSD notifications render on the overlay when the visible overlay is available and visible.
- [ ] #2 Logical OSD notifications fall back to actual mpv OSD when the overlay is hidden, disabled, unavailable, or fails to load.
- [ ] #3 Notification-type config semantics remain intact: `osd` uses the overlay-or-OSD channel, `system` remains Electron notifications, `both` uses both channels, and `none` suppresses notifications.
- [ ] #4 Overlay notifications support richer presentation for loading/progress states, including a spinner/persistent loading state.
- [ ] #5 Regression coverage verifies routing, renderer behavior, and fallback handling.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Plan:
1. Add failing tests for a pure main-process overlay notification router and a renderer overlay notification controller.
2. Add overlay notification IPC/types plus renderer DOM/controller/CSS for styled toast notifications and spinner handling.
3. Route existing logical OSD callsites through the new overlay-or-OSD fallback seam in main.
4. Trim plugin-side direct OSD usage down to true fallback/error cases.
5. Verify with targeted runtime-compat/core lanes, then escalate if runtime behavior claims remain unproven.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,5 @@
type: changed
area: overlay
- Routed in-app OSD notifications through the visible overlay when available, with fallback to mpv OSD when the overlay is hidden or unavailable.
- Added overlay-native notification styling for loading, success, warning, and error states.

View File

@@ -53,13 +53,13 @@
// ==========================================
// Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active.
// Use the selection modal to save a preferred controller by id for future launches.
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
@@ -81,22 +81,64 @@
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
} // Bindings setting.
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
"leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -385,6 +427,17 @@
} // Character dictionary setting.
}, // Anilist API credentials and update behavior.
// ==========================================
// Yomitan
// Optional external Yomitan profile integration.
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ==========================================
"yomitan": {
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration.
// ==========================================
// Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -206,7 +206,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
"overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected
"notificationType": "osd" // "osd", "system", "both", or "none"
"notificationType": "osd" // "osd" prefers overlay notifications and falls back to mpv OSD; "system", "both", or "none"
}
}
```

View File

@@ -1,10 +1,27 @@
# Changelog
## v0.6.0 (2026-03-12)
## v0.6.5 (2026-03-15)
- Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## v0.6.4 (2026-03-15)
- Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
## v0.6.3 (2026-03-15)
- Expanded `Alt+C` into an inline controller config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
- Automated `subminer-bin` AUR package updates from the tagged release workflow.
## v0.6.2 (2026-03-12)
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
- Reused external Yomitan dictionaries/settings without writing back to that profile.
- Let launcher-managed playback honor external Yomitan config instead of forcing first-run setup.
- Seeded `config.jsonc` even when the default config directory already exists.
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
## v0.6.1 (2026-03-12)
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
- Added smooth, slower popup scrolling for controller navigation.
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
- Expanded `Alt+C` into a controller config/remap modal with preferred-controller saving, inline learn mode, and kept `Alt+Shift+C` for raw input debugging.
- Added a transient in-overlay controller-detected indicator when a controller is first found.
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.

View File

@@ -62,6 +62,10 @@ Character dictionary sync is disabled by default. To turn it on:
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
:::
::: warning
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
:::
## Name Generation
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:

View File

@@ -113,6 +113,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
@@ -513,8 +514,10 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- `Alt+C` opens the controller selection modal and saves the selected controller for future launches.
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -546,19 +549,19 @@ Important behavior:
"rightTrigger": 7
},
"bindings": {
"toggleLookup": "buttonSouth",
"closeLookup": "buttonEast",
"toggleKeyboardOnlyMode": "buttonNorth",
"mineCard": "buttonWest",
"quitMpv": "select",
"previousAudio": "none",
"nextAudio": "rightShoulder",
"playCurrentAudio": "leftShoulder",
"toggleMpvPause": "leftStickPress",
"leftStickHorizontal": "leftStickX",
"leftStickVertical": "leftStickY",
"rightStickHorizontal": "rightStickX",
"rightStickVertical": "rightStickY"
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"closeLookup": { "kind": "button", "buttonIndex": 1 },
"toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 },
"mineCard": { "kind": "button", "buttonIndex": 2 },
"quitMpv": { "kind": "button", "buttonIndex": 6 },
"previousAudio": { "kind": "none" },
"nextAudio": { "kind": "button", "buttonIndex": 5 },
"playCurrentAudio": { "kind": "button", "buttonIndex": 4 },
"toggleMpvPause": { "kind": "button", "buttonIndex": 9 },
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }
}
}
}
@@ -580,10 +583,28 @@ Default logical mapping:
- `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
If you bind a discrete action to an axis manually, include `direction`:
```jsonc
{
"controller": {
"bindings": {
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" }
}
}
}
```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts
@@ -808,7 +829,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification channel for card updates. `"osd"` prefers the visible overlay and falls back to mpv OSD when the overlay is hidden/unavailable (default: `"osd"`). |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
@@ -1017,6 +1038,33 @@ AniList CLI commands:
- `--anilist-setup`: open AniList setup/auth flow helper window.
- `--anilist-retry-queue`: process one ready retry queue item immediately.
### Yomitan
SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode.
For GameSentenceMiner on Linux, the default overlay profile path is typically `~/.config/gsm_overlay`.
```json
{
"yomitan": {
"externalProfilePath": "/home/you/.config/gsm_overlay"
}
}
```
| Option | Values | Description |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
External-profile mode behavior:
- SubMiner uses the external profile's Yomitan extension/session instead of its local copy.
- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries.
- SubMiner does not open its own Yomitan settings window in this mode.
- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
### Jellyfin
Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch.

View File

@@ -1,6 +1,7 @@
import { expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const rootChangelogContents = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
const readmeContents = readFileSync(new URL('./README.md', import.meta.url), 'utf8');
const usageContents = readFileSync(new URL('./usage.md', import.meta.url), 'utf8');
const installationContents = readFileSync(new URL('./installation.md', import.meta.url), 'utf8');
@@ -10,6 +11,12 @@ const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8');
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
function extractReleaseHeadings(content: string, count: number): string[] {
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
.map(([heading]) => heading)
.slice(0, count);
}
test('docs reflect current launcher and release surfaces', () => {
expect(usageContents).not.toContain('--mode preprocess');
expect(usageContents).not.toContain('"automatic" (default)');
@@ -37,3 +44,7 @@ test('docs reflect current launcher and release surfaces', () => {
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)');
});
test('docs changelog keeps the newest release headings aligned with the root changelog', () => {
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3));
});

View File

@@ -53,13 +53,13 @@
// ==========================================
// Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active.
// Use the selection modal to save a preferred controller by id for future launches.
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
@@ -81,22 +81,64 @@
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
} // Bindings setting.
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
"leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -385,6 +427,17 @@
} // Character dictionary setting.
}, // Anilist API credentials and update behavior.
// ==========================================
// Yomitan
// Optional external Yomitan profile integration.
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ==========================================
"yomitan": {
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration.
// ==========================================
// Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -75,8 +75,8 @@ These overlay-local shortcuts are fixed and open controller utilities for the Ch
| Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ |
| `Alt+C` | Open controller selection modal | Fixed |
| `Alt+Shift+C` | Open controller debug modal | Fixed |
| `Alt+C` | Open controller config + remap modal | Fixed |
| `Alt+Shift+C` | Open controller debug modal | Fixed |
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.

View File

@@ -182,6 +182,7 @@ If you installed from the AppImage and see this error, the package may be incomp
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
## MeCab / Tokenization

View File

@@ -254,10 +254,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup.
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads.
### Default Button Mapping
@@ -278,10 +280,11 @@ By default SubMiner uses the first connected controller. Press `Alt+C` in the ov
| Input | Action |
| ----- | ------ |
| Left stick horizontal | Move token selection left/right |
| Left stick vertical | Smooth scroll Yomitan popup |
| Right stick horizontal | Jump inside popup (horizontal) |
| Right stick vertical | Smooth scroll popup (vertical) |
| D-pad | Fallback for stick navigation |
| Left stick vertical | Scroll Yomitan popup |
| Right stick vertical | Jump through Yomitan popup |
| D-pad | Fallback for stick navigation when configured |
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.

View File

@@ -20,3 +20,5 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches.
- Do not tag while `changes/*.md` fragments still exist.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -1,105 +0,0 @@
# SubMiner Change Verification Skill Design
**Date:** 2026-03-10
**Status:** Approved
## Goal
Create a SubMiner-specific skill that agents can use to verify code changes with automated checks. The skill must support both targeted regression testing during debugging and pre-handoff verification before final response.
## Skill Contract
- **Name:** `subminer-change-verification`
- **Trigger:** Use when working in the SubMiner repo and you need to verify code changes actually work, especially for launcher, mpv, plugin, overlay, runtime, Electron, or env-sensitive behavior.
- **Default posture:** cheap-first; prefer repo-native tests and narrow lanes before broader or GUI-dependent verification.
- **Outputs:**
- verification summary
- exact commands run
- artifact paths for logs, captured summaries, and preserved temp state on failures
- skipped lanes and blockers
- **Non-goals:**
- replacing the repo's native tests
- launching real GUI apps for every change
- default visual regression or pixel-diff workflows
## Lane Selection
The skill chooses lanes from the diff or explicit file list.
- **`docs`**
- For `docs-site/`, `docs/`, and similar documentation-only changes.
- Prefer `bun run docs:test` and `bun run docs:build`.
- **`config`**
- For `src/config/`, config example generation/verification paths, and config-template-sensitive changes.
- Prefer `bun run test:config`.
- **`core`**
- For general source-level changes where type safety and the fast maintained lane are the best cheap signal.
- Prefer `bun run typecheck` and `bun run test:fast`.
- **`launcher-plugin`**
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
- Prefer `bun run test:launcher:smoke:src` and `bun run test:plugin:src`.
- **`runtime-compat`**
- For runtime/composition/bundled behavior where dist-sensitive validation matters.
- Prefer `bun run build`, `bun run test:runtime:compat`, and `bun run test:smoke:dist`.
- **`real-gui`**
- Reserved for cases where actual Electron/mpv/window behavior must be validated.
- Not part of the default lane set; the classifier marks these changes as candidates so the agent can escalate deliberately.
## Escalation Rules
1. Start with the narrowest lane that credibly exercises the changed behavior.
2. If a narrow lane fails in a way that suggests broader fallout, expand once.
3. If a change touches launcher/mpv/plugin/runtime/overlay/window tracking paths, include the relevant specialized lanes before falling back to broad suites.
4. Treat real GUI/mpv verification as opt-in escalation:
- use only when cheaper evidence is insufficient
- allow for platform/display/permission blockers
- report skipped/blocker states explicitly
## Helper Script Design
The skill uses two small shell helpers:
- **`scripts/classify_subminer_diff.sh`**
- Accepts explicit paths or discovers local changes from git.
- Emits lane suggestions and flags in a simple line-oriented format.
- Marks real GUI-sensitive paths as `flag:real-gui-candidate` instead of forcing GUI execution.
- **`scripts/verify_subminer_change.sh`**
- Creates an artifact directory under `.tmp/skill-verification/<timestamp>/`.
- Selects lanes from the classifier unless lanes are supplied explicitly.
- Runs repo-native commands in a stable order and captures stdout/stderr per step.
- Writes a compact `summary.json` and a human-readable `summary.txt`.
- Skips real GUI verification unless explicitly enabled.
## Artifact Contract
Each invocation should create:
- `summary.json`
- `summary.txt`
- `classification.txt`
- `env.txt`
- `lanes.txt`
- `steps.tsv`
- `steps/<step>.stdout.log`
- `steps/<step>.stderr.log`
Failures should preserve the artifact directory and identify the exact failing command and log paths.
## Agent Workflow
1. Inspect changed files or requested area.
2. Classify the change into verification lanes.
3. Run the cheapest sufficient lane set.
4. Escalate only if evidence is insufficient.
5. Escalate to real GUI/mpv only for actual Electron/mpv/window behavior claims.
6. Return a short report with:
- pass/fail/skipped per lane
- exact commands run
- artifact paths
- blockers/gaps
## Initial Implementation Scope
- Ship the skill entrypoint plus the classifier/verifier helpers.
- Make real GUI verification an explicit future hook rather than a default workflow.
- Verify the new skill locally with representative classifier output and artifact generation.

View File

@@ -1,111 +0,0 @@
# SubMiner Scrum Master Skill Design
**Date:** 2026-03-10
**Status:** Approved
## Goal
Create a repo-local skill that can take incoming requests, bugs, or issues in the SubMiner repo, decide whether backlog tracking is warranted, create or update backlog work when appropriate, plan the implementation, dispatch one or more subagents, and ensure verification happens before handoff.
## Skill Contract
- **Name:** `subminer-scrum-master`
- **Location:** `.agents/skills/subminer-scrum-master/`
- **Use when:** the user gives a feature request, bug report, issue, refactor, or implementation ask in the SubMiner repo and the agent should own intake, planning, backlog hygiene, dispatch, and completion flow.
- **Responsibilities:**
- assess whether backlog tracking is warranted
- if needed, search/update/create proper backlog structure
- write a plan before dispatching coding work
- choose sequential vs parallel execution
- assign explicit ownership to workers
- require verification before final handoff
- **Limits:**
- not the default code implementer unless delegation would be wasteful
- no overlapping parallel write scopes
- no skipping planning before dispatch
- no skipping verification
- must pause for ambiguous, risky, or external side-effect work
## Backlog Decision Rules
Backlog use is conditional, not mandatory.
- **Skip backlog when:**
- question only
- obvious mechanical edit
- tiny isolated change with no real planning
- **Use backlog when:**
- implementation requires planning
- scope/approach needs decisions
- multiple phases or subsystems
- likely subagent dispatch
- work should remain traceable
When backlog is used:
- search first
- update existing matching work when appropriate
- otherwise create standalone task or parent task
- use parent + subtasks for multi-part work
- record the implementation plan before coding starts
## Orchestration Policy
The skill orchestrates; workers implement.
- **No dispatch** for trivial/mechanical work
- **Single worker** for focused single-scope work
- **Parallel workers** only for clearly disjoint scopes
- **Sequential flow** for shared files, runtime coupling, or unclear boundaries
Every worker should receive:
- owned files/modules
- explicit reminder not to revert unrelated edits
- requirement to report changed files, tests run, and blockers
## Verification Policy
Every nontrivial code task gets verification.
- prefer `subminer-change-verification`
- use cheap-first lanes
- escalate only when needed
- accept worker-run verification only if it is clearly relevant and sufficient
- run a consolidating final verification pass when the scrum master needs stronger evidence
## Representative Flows
### Trivial fix, no backlog
1. assess request as mechanical or narrowly reversible
2. skip backlog
3. keep a short internal plan
4. implement directly or use one worker if helpful
5. run targeted verification
6. report concise summary
### Single-task implementation
1. search backlog
2. create/update one task
3. record plan
4. dispatch one worker
5. integrate result
6. run verification
7. update task and report outcome
### Multi-part feature
1. search backlog
2. create/update parent task
3. create subtasks for distinct deliverables/phases
4. record sequencing in the plan
5. dispatch workers only where write scopes do not overlap
6. integrate
7. run consolidated verification
8. update task state and report outcome
## V1 Scope
- instruction-heavy `SKILL.md`
- no helper scripts unless orchestration becomes too repetitive
- strong coordination with existing Backlog workflow and `subminer-change-verification`

View File

@@ -1,110 +0,0 @@
# Overlay Controller Support Design
**Date:** 2026-03-11
**Backlog:** `TASK-159`
## Goal
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
## Scope
- Poll connected gamepads from the visible overlay renderer.
- Default to the first connected controller unless config specifies a preferred controller.
- Add logical controller bindings and tuning knobs to config.
- Add `Alt+C` controller selection modal.
- Add `Alt+Shift+C` controller debug modal.
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
Out of scope for this pass:
- Raw arbitrary axis/button index remapping in config.
- Controller support outside the visible overlay renderer.
- Haptics or vibration.
## Architecture
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
## Behavior
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
Default logical mappings:
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
- left stick horizontal: move token selection left/right
- right stick vertical: smooth Yomitan popup/window scroll
- right stick horizontal: jump horizontally inside Yomitan popup/window
- `A`: toggle lookup
- `B`: close lookup
- `Y`: toggle keyboard-only mode
- `X`: mine card
- `L1` / `R1`: previous / next Yomitan audio
- `R2`: activate current Yomitan audio button
- `L2`: toggle mpv play/pause
Selection-highlight cleanup:
- disabling keyboard-only mode clears the selected token class immediately
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
## Config
Add a top-level `controller` block in resolved config with:
- `enabled`
- `preferredGamepadId`
- `preferredGamepadLabel`
- `smoothScroll`
- `scrollPixelsPerSecond`
- `horizontalJumpPixels`
- `stickDeadzone`
- `triggerDeadzone`
- `repeatDelayMs`
- `repeatIntervalMs`
- `bindings` logical fields for the named actions/sticks
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
## UI
Controller selection modal:
- overlay-hosted modal in the visible renderer
- lists currently connected controllers
- highlights current active choice
- selecting one persists config and makes it the active controller immediately if connected
Controller debug modal:
- overlay-hosted modal
- shows selected controller and all connected controllers
- live raw axis array values
- live raw button values, pressed flags, and touched flags if available
## Testing
Test first:
- controller gating outside keyboard-only mode
- logical mapping to existing helpers
- continuous stick scroll and repeat behavior
- modal open shortcuts
- preferred-controller selection persistence
- highlight cleanup on keyboard-only disable and popup close
- config defaults/parse/template generation coverage
## Risks
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
Mitigation: match by exact preferred id first; fall back to first connected controller.
- Continuous stick input can spam actions.
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
- Popup DOM/audio controls may vary.
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.

View File

@@ -1,245 +0,0 @@
# Overlay Controller Support Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
---
### Task 1: Track work and lock the design
**Files:**
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
**Step 1: Record the approved scope**
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
**Step 2: Verify the written scope matches the approved design**
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
### Task 2: Add failing config tests and defaults
**Files:**
- Modify: `src/config/config.test.ts`
- Modify: `src/config/definitions/defaults-core.ts`
- Modify: `src/config/definitions/options-core.ts`
- Modify: `src/config/definitions/template-sections.ts`
- Modify: `src/types.ts`
- Modify: `config.example.jsonc`
**Step 1: Write the failing test**
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
**Step 2: Run test to verify it fails**
Run: `bun test src/config/config.test.ts`
Expected: FAIL because `controller` config is not defined yet.
**Step 3: Write minimal implementation**
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
**Step 4: Run test to verify it passes**
Run: `bun test src/config/config.test.ts`
Expected: PASS
### Task 3: Add failing keyboard-selection cleanup tests
**Files:**
- Modify: `src/renderer/handlers/keyboard.test.ts`
- Modify: `src/renderer/handlers/keyboard.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- turning keyboard-only mode off clears `.keyboard-selected`
- closing the popup clears stale selection highlight when keyboard-only mode is off
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: FAIL because selection cleanup is incomplete today.
**Step 3: Write minimal implementation**
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: PASS
### Task 4: Add failing controller runtime tests
**Files:**
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
- Create: `src/renderer/handlers/gamepad-controller.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
- Modify: `src/renderer/renderer.ts`
**Step 1: Write the failing tests**
Cover:
- first connected controller is selected by default
- preferred controller wins when connected
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
- stick/button mappings invoke the expected logical helpers
- smooth scroll and repeat throttling behavior
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: FAIL because controller runtime does not exist.
**Step 3: Write minimal implementation**
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: PASS
### Task 5: Add failing controller modal tests
**Files:**
- Modify: `src/renderer/index.html`
- Modify: `src/renderer/style.css`
- Create: `src/renderer/modals/controller-select.ts`
- Create: `src/renderer/modals/controller-select.test.ts`
- Create: `src/renderer/modals/controller-debug.ts`
- Create: `src/renderer/modals/controller-debug.test.ts`
- Modify: `src/renderer/renderer.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- `Alt+C` opens controller selection modal
- `Alt+Shift+C` opens controller debug modal
- selection modal renders connected controllers and persists the chosen device
- debug modal shows live axes/buttons state
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: FAIL because modals and shortcuts do not exist.
**Step 3: Write minimal implementation**
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: PASS
### Task 6: Persist controller preference through preload/main wiring
**Files:**
- Modify: `src/preload.ts`
- Modify: `src/types.ts`
- Modify: `src/shared/ipc/contracts.ts`
- Modify: `src/core/services/ipc.ts`
- Modify: `src/main.ts`
- Modify: related main/runtime tests as needed
**Step 1: Write the failing test**
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
**Step 2: Run test to verify it fails**
Run: `bun test src/core/services/ipc.test.ts`
Expected: FAIL because no controller preference IPC exists yet.
**Step 3: Write minimal implementation**
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
**Step 4: Run test to verify it passes**
Run: `bun test src/core/services/ipc.test.ts`
Expected: PASS
### Task 7: Update docs and config example
**Files:**
- Modify: `config.example.jsonc`
- Modify: `README.md`
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
**Step 1: Write the failing doc/config check if needed**
If config example generation is covered by tests, add/refresh the failing assertion first.
**Step 2: Implement the docs**
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
**Step 3: Run doc/config verification**
Run: `bun run test:config`
Expected: PASS
### Task 8: Run the handoff gate and update the backlog task
**Files:**
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
**Step 1: Run targeted verification**
Run:
- `bun test src/config/config.test.ts`
- `bun test src/renderer/handlers/keyboard.test.ts`
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
- `bun test src/renderer/modals/controller-select.test.ts`
- `bun test src/renderer/modals/controller-debug.test.ts`
- `bun test src/core/services/ipc.test.ts`
Expected: PASS
**Step 2: Run broader gate**
Run:
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
Expected: PASS, or document exact blockers/failures.
**Step 3: Update backlog notes**
Fill in implementation notes, verification commands, and final summary in `TASK-159`.

View File

@@ -21,6 +21,7 @@ import {
getSetupStatePath,
readSetupState,
} from '../../src/shared/setup-state.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
@@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { readExternalYomitanProfilePath } from './config.js';
import {
getPluginConfigCandidates,
parsePluginRuntimeConfigContent,
@@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
test('getDefaultSocketPath returns Windows named pipe default', () => {
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
});
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: ' ~/.config/gsm_overlay ',
},
}),
'~/.config/gsm_overlay',
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: ' ',
},
}),
null,
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: null,
}),
null,
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: 123,
},
} as never),
null,
);
});

View File

@@ -17,6 +17,21 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
export function readExternalYomitanProfilePath(
root: Record<string, unknown> | null,
): string | null {
const yomitan =
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
? (root.yomitan as Record<string, unknown>)
: null;
const externalProfilePath = yomitan?.externalProfilePath;
if (typeof externalProfilePath !== 'string') {
return null;
}
const trimmed = externalProfilePath.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
@@ -29,6 +44,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
return parseLauncherJellyfinConfig(root);
}
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
return readPluginRuntimeConfigValue(logLevel);
}

View File

@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
const sequence: Array<SetupState | null> = [
null,
{
version: 2,
version: 3,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
@@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
windowsMpvShortcutLastStatus: 'unknown',
},
{
version: 2,
version: 3,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null,
@@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null;
if (reads === 2) {
return {
version: 2,
version: 3,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
@@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
};
}
return {
version: 2,
version: 3,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
@@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
assert.deepEqual(calls, ['launch']);
});
test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => {
const calls: string[] = [];
const ready = await ensureLauncherSetupReady({
readSetupState: () => null,
isExternalYomitanConfigured: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 2,
version: 3,
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,

View File

@@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: {
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
if (isSetupCompleted(deps.readSetupState())) {
return true;
}

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.6.1",
"version": "0.6.5",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -54,7 +54,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",

View File

@@ -0,0 +1,36 @@
pkgbase = subminer-bin
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
pkgver = 0.6.2
pkgrel = 1
url = https://github.com/ksyasuda/SubMiner
arch = x86_64
license = GPL-3.0-or-later
depends = bun
depends = fuse2
depends = glibc
depends = mpv
depends = zlib-ng-compat
optdepends = ffmpeg: media extraction and screenshot generation
optdepends = ffmpegthumbnailer: faster thumbnail previews in the launcher
optdepends = fzf: terminal media picker in the subminer wrapper
optdepends = rofi: GUI media picker in the subminer wrapper
optdepends = chafa: image previews in the fzf picker
optdepends = yt-dlp: YouTube playback and subtitle extraction
optdepends = mecab: optional Japanese metadata enrichment
optdepends = mecab-ipadic: dictionary for MeCab metadata enrichment
optdepends = python-guessit: improved AniSkip title and episode inference
optdepends = alass-git: preferred subtitle synchronization engine
optdepends = python-ffsubsync: fallback subtitle synchronization engine
provides = subminer=0.6.2
conflicts = subminer
noextract = SubMiner-0.6.2.AppImage
options = !strip
options = !debug
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
source = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
source = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
pkgname = subminer-bin

View File

@@ -0,0 +1,64 @@
# Maintainer: Kyle Yasuda <suda@sudacode.com>
pkgname=subminer-bin
pkgver=0.6.2
pkgrel=1
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
arch=('x86_64')
url='https://github.com/ksyasuda/SubMiner'
license=('GPL-3.0-or-later')
options=('!strip' '!debug')
depends=(
'bun'
'fuse2'
'glibc'
'mpv'
'zlib-ng-compat'
)
optdepends=(
'ffmpeg: media extraction and screenshot generation'
'ffmpegthumbnailer: faster thumbnail previews in the launcher'
'fzf: terminal media picker in the subminer wrapper'
'rofi: GUI media picker in the subminer wrapper'
'chafa: image previews in the fzf picker'
'yt-dlp: YouTube playback and subtitle extraction'
'mecab: optional Japanese metadata enrichment'
'mecab-ipadic: dictionary for MeCab metadata enrichment'
'python-guessit: improved AniSkip title and episode inference'
'alass-git: preferred subtitle synchronization engine'
'python-ffsubsync: fallback subtitle synchronization engine'
)
provides=("subminer=${pkgver}")
conflicts=('subminer')
source=(
"SubMiner-${pkgver}.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/SubMiner-${pkgver}.AppImage"
"subminer::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer"
"subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
)
sha256sums=(
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
)
noextract=("SubMiner-${pkgver}.AppImage")
package() {
install -dm755 "${pkgdir}/usr/bin"
install -Dm755 "${srcdir}/SubMiner-${pkgver}.AppImage" \
"${pkgdir}/opt/SubMiner/SubMiner.AppImage"
install -dm755 "${pkgdir}/opt/SubMiner"
ln -s '/opt/SubMiner/SubMiner.AppImage' "${pkgdir}/usr/bin/SubMiner.AppImage"
install -Dm755 "${srcdir}/subminer" "${pkgdir}/usr/bin/subminer"
install -Dm644 "${srcdir}/config.example.jsonc" \
"${pkgdir}/usr/share/SubMiner/config.example.jsonc"
install -Dm644 "${srcdir}/plugin/subminer.conf" \
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
}

View File

@@ -34,6 +34,17 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function should_prefer_overlay_notifications()
return resolve_visible_overlay_startup()
end
local function show_overlay_fallback_osd(message)
if should_prefer_overlay_notifications() then
return
end
show_osd(message)
end
local function normalize_socket_path(path)
if type(path) ~= "string" then
return nil
@@ -98,7 +109,7 @@ function M.create(ctx)
end
disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end
@@ -109,11 +120,11 @@ function M.create(ctx)
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
end
end)
end
@@ -298,7 +309,7 @@ function M.create(ctx)
return
end
subminer_log("info", "process", "Overlay already running")
show_osd("Already running")
show_overlay_fallback_osd("Already running")
return
end
@@ -332,7 +343,7 @@ function M.create(ctx)
end
if attempt == 1 and not state.auto_play_ready_gate_armed then
show_osd("Starting...")
show_overlay_fallback_osd("Starting...")
end
state.overlay_running = true
@@ -458,7 +469,6 @@ function M.create(ctx)
run_control_command_async("settings", nil, function(ok)
if ok then
subminer_log("info", "process", "Options window opened")
show_osd("Options opened")
else
subminer_log("warn", "process", "Failed to open options")
show_osd("Failed to open options")
@@ -474,7 +484,7 @@ function M.create(ctx)
end
subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...")
show_overlay_fallback_osd("Restarting...")
run_control_command_async("stop", nil, function()
state.overlay_running = false
@@ -502,7 +512,7 @@ function M.create(ctx)
)
show_osd("Restart failed")
else
show_osd("Restarted successfully")
show_overlay_fallback_osd("Restarted successfully")
end
end)
end)
@@ -516,7 +526,11 @@ function M.create(ctx)
end
local status = state.overlay_running and "running" or "stopped"
show_osd("Status: overlay is " .. status)
if state.overlay_running then
show_overlay_fallback_osd("Status: overlay is " .. status)
else
show_osd("Status: overlay is " .. status)
end
subminer_log("info", "process", "Status check: overlay is " .. status)
end

15
release/release-notes.md Normal file
View File

@@ -0,0 +1,15 @@
## Highlights
### Internal
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.

View File

@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repoRoot = process.cwd();
const classifyScript = path.join(
repoRoot,
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
);
const verifyScript = path.join(
repoRoot,
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
);
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function runBash(args: string[]) {
return spawnSync('bash', args, {
cwd: repoRoot,
env: process.env,
encoding: 'utf8',
});
}
function parseArtifactDir(stdout: string): string {
const match = stdout.match(/^artifact_dir=(.+)$/m);
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
return match[1] ?? '';
}
function readSummaryJson(artifactDir: string) {
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
sessionId: string;
status: string;
selectedLanes: string[];
blockers?: string[];
artifactDir: string;
pathSelectionMode?: string;
};
}
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /^lane:launcher-plugin$/m);
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
});
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'real-runtime',
'launcher/mpv.ts',
]);
assert.notEqual(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=blocked$/m);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
assert.ok(summary.sessionId.length > 0);
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
});
});
test('verifier fails closed for unknown lanes', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'not-a-lane',
'src/main.ts',
]);
assert.notEqual(result.status, 0, result.stdout);
assert.match(result.stdout, /^result=failed$/m);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'failed');
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
});
});
test('verifier allocates unique session ids and artifact roots by default', () => {
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
assert.equal(first.status, 0, first.stderr || first.stdout);
assert.equal(second.status, 0, second.stderr || second.stdout);
const firstArtifactDir = parseArtifactDir(first.stdout);
const secondArtifactDir = parseArtifactDir(second.stdout);
try {
const firstSummary = readSummaryJson(firstArtifactDir);
const secondSummary = readSummaryJson(secondArtifactDir);
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
assert.equal(firstSummary.pathSelectionMode, 'explicit');
assert.equal(secondSummary.pathSelectionMode, 'explicit');
} finally {
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
}
});

View File

@@ -724,12 +724,12 @@ do
"duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
"duplicate pause-until-ready auto-start should arm tokenization loading gate for each file"
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 0,
"duplicate pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2,
"duplicate pause-until-ready auto-start should release tokenization gate for each file"
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 0,
"duplicate pause-until-ready auto-start should suppress ready OSD when visible overlay is enabled"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
@@ -770,16 +770,16 @@ do
"autoplay-ready script message should resume mpv playback"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should show loading OSD message"
not has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
)
assert_true(
not has_osd_message(recorded.osd, "SubMiner: Starting..."),
"pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should show loaded OSD message"
not has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should suppress ready OSD when visible overlay is enabled"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,

214
scripts/update-aur-package.sh Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
EOF
}
pkg_dir=
version=
appimage=
wrapper=
assets=
while [[ $# -gt 0 ]]; do
case "$1" in
--pkg-dir)
pkg_dir="${2:-}"
shift 2
;;
--version)
version="${2:-}"
shift 2
;;
--appimage)
appimage="${2:-}"
shift 2
;;
--wrapper)
wrapper="${2:-}"
shift 2
;;
--assets)
assets="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
usage >&2
exit 1
fi
version="${version#v}"
pkgbuild="${pkg_dir}/PKGBUILD"
srcinfo="${pkg_dir}/.SRCINFO"
if [[ ! -f "$pkgbuild" ]]; then
echo "Missing PKGBUILD at $pkgbuild" >&2
exit 1
fi
for artifact in "$appimage" "$wrapper" "$assets"; do
if [[ ! -f "$artifact" ]]; then
echo "Missing artifact: $artifact" >&2
exit 1
fi
done
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
tmpfile="$(mktemp)"
awk \
-v version="$version" \
-v sum_appimage="${sha256sums[0]}" \
-v sum_wrapper="${sha256sums[1]}" \
-v sum_assets="${sha256sums[2]}" \
'
BEGIN {
in_sha_block = 0
found_pkgver = 0
found_sha_block = 0
}
/^pkgver=/ {
print "pkgver=" version
found_pkgver = 1
next
}
/^sha256sums=\(/ {
print "sha256sums=("
print "\047" sum_appimage "\047"
print "\047" sum_wrapper "\047"
print "\047" sum_assets "\047"
in_sha_block = 1
next
}
in_sha_block {
if ($0 ~ /^\)/) {
print ")"
in_sha_block = 0
found_sha_block = 1
}
next
}
{
print
}
END {
if (!found_pkgver) {
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
exit 1
}
if (!found_sha_block) {
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
exit 1
}
}
' "$pkgbuild" > "$tmpfile"
mv "$tmpfile" "$pkgbuild"
if [[ ! -f "$srcinfo" ]]; then
echo "Missing .SRCINFO at $srcinfo" >&2
exit 1
fi
tmpfile="$(mktemp)"
awk \
-v version="$version" \
-v sum_appimage="${sha256sums[0]}" \
-v sum_wrapper="${sha256sums[1]}" \
-v sum_assets="${sha256sums[2]}" \
'
BEGIN {
sha_index = 0
found_pkgver = 0
found_provides = 0
found_noextract = 0
found_source_appimage = 0
found_source_wrapper = 0
found_source_assets = 0
}
/^\tpkgver = / {
print "\tpkgver = " version
found_pkgver = 1
next
}
/^\tprovides = subminer=/ {
print "\tprovides = subminer=" version
found_provides = 1
next
}
/^\tnoextract = SubMiner-.*\.AppImage$/ {
print "\tnoextract = SubMiner-" version ".AppImage"
found_noextract = 1
next
}
/^\tsource = SubMiner-.*\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/SubMiner-.*\.AppImage$/ {
print "\tsource = SubMiner-" version ".AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/SubMiner-" version ".AppImage"
found_source_appimage = 1
next
}
/^\tsource = subminer::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer$/ {
print "\tsource = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer"
found_source_wrapper = 1
next
}
/^\tsource = subminer-assets\.tar\.gz::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer-assets\.tar\.gz$/ {
print "\tsource = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer-assets.tar.gz"
found_source_assets = 1
next
}
/^\tsha256sums = / {
sha_index += 1
if (sha_index == 1) {
print "\tsha256sums = " sum_appimage
next
}
if (sha_index == 2) {
print "\tsha256sums = " sum_wrapper
next
}
if (sha_index == 3) {
print "\tsha256sums = " sum_assets
next
}
}
{
print
}
END {
if (!found_pkgver) {
print "Missing pkgver entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_provides) {
print "Missing provides entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_noextract) {
print "Missing noextract entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_source_appimage || !found_source_wrapper || !found_source_assets) {
print "Missing source entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (sha_index < 3) {
print "Missing sha256sums entries in .SRCINFO" > "/dev/stderr"
exit 1
}
}
' "$srcinfo" > "$tmpfile"
mv "$tmpfile" "$srcinfo"

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
}
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const workspace = createWorkspace('subminer-aur-package');
const pkgDir = path.join(workspace, 'aur-subminer-bin');
const appImagePath = path.join(workspace, 'SubMiner-0.6.3.AppImage');
const wrapperPath = path.join(workspace, 'subminer');
const assetsPath = path.join(workspace, 'subminer-assets.tar.gz');
fs.mkdirSync(pkgDir, { recursive: true });
fs.copyFileSync('packaging/aur/subminer-bin/PKGBUILD', path.join(pkgDir, 'PKGBUILD'));
fs.copyFileSync('packaging/aur/subminer-bin/.SRCINFO', path.join(pkgDir, '.SRCINFO'));
fs.writeFileSync(appImagePath, 'appimage');
fs.writeFileSync(wrapperPath, 'wrapper');
fs.writeFileSync(assetsPath, 'assets');
try {
execFileSync(
'bash',
[
'scripts/update-aur-package.sh',
'--pkg-dir',
pkgDir,
'--version',
'v0.6.3',
'--appimage',
appImagePath,
'--wrapper',
wrapperPath,
'--assets',
assetsPath,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
);
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
assert.match(
srcinfo,
/^\tsource = SubMiner-0\.6\.3\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v0\.6\.3\/SubMiner-0\.6\.3\.AppImage$/m,
);
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[0]}$`, 'm'));
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[1]}$`, 'm'));
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[2]}$`, 'm'));
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

View File

@@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
assert.equal(config.yomitan.externalProfilePath, '');
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
@@ -1167,12 +1168,103 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
assert.equal(config.controller.repeatIntervalMs, 70);
assert.equal(config.controller.buttonIndices.select, 6);
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
assert.equal(config.controller.bindings.quitMpv, 'select');
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
assert.deepEqual(config.controller.bindings.toggleLookup, { kind: 'button', buttonIndex: 2 });
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
assert.deepEqual(config.controller.bindings.toggleMpvPause, { kind: 'button', buttonIndex: 9 });
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
kind: 'axis',
axisIndex: 3,
dpadFallback: 'horizontal',
});
assert.deepEqual(config.controller.bindings.rightStickVertical, {
kind: 'axis',
axisIndex: 1,
dpadFallback: 'none',
});
});
test('parses descriptor-based controller bindings', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
"closeLookup": { "kind": "axis", "axisIndex": 4, "direction": "negative" },
"playCurrentAudio": { "kind": "none" },
"leftStickHorizontal": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" },
"leftStickVertical": { "kind": "axis", "axisIndex": 2, "dpadFallback": "vertical" }
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.controller.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
assert.deepEqual(config.controller.bindings.closeLookup, {
kind: 'axis',
axisIndex: 4,
direction: 'negative',
});
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
kind: 'axis',
axisIndex: 7,
dpadFallback: 'none',
});
assert.deepEqual(config.controller.bindings.leftStickVertical, {
kind: 'axis',
axisIndex: 2,
dpadFallback: 'vertical',
});
});
test('controller descriptor config rejects malformed binding objects', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": -1 },
"closeLookup": { "kind": "axis", "axisIndex": 1, "direction": "sideways" },
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "diagonal" }
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(
config.controller.bindings.toggleLookup,
DEFAULT_CONFIG.controller.bindings.toggleLookup,
);
assert.deepEqual(
config.controller.bindings.closeLookup,
DEFAULT_CONFIG.controller.bindings.closeLookup,
);
assert.deepEqual(
config.controller.bindings.leftStickHorizontal,
DEFAULT_CONFIG.controller.bindings.leftStickHorizontal,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'),
true,
);
});
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
@@ -1194,14 +1286,32 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(
config.controller.scrollPixelsPerSecond,
DEFAULT_CONFIG.controller.scrollPixelsPerSecond,
);
assert.equal(
config.controller.horizontalJumpPixels,
DEFAULT_CONFIG.controller.horizontalJumpPixels,
);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.repeatDelayMs'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'),
true,
);
});
test('controller button index config rejects fractional values', () => {
@@ -1223,12 +1333,18 @@ test('controller button index config rejects fractional values', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
assert.equal(
config.controller.buttonIndices.select,
DEFAULT_CONFIG.controller.buttonIndices.select,
);
assert.equal(
config.controller.buttonIndices.leftStickPress,
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.select'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
true,
@@ -1800,6 +1916,24 @@ test('template generator includes known keys', () => {
output,
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
);
assert.match(
output,
/"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./,
);
assert.match(
output,
/"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./,
);
assert.match(
output,
/"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/,
);
assert.match(output, /"toggleLookup": \{\s*"kind": "button"/);
assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);
assert.match(
output,
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(
output,

View File

@@ -32,7 +32,7 @@ const {
startupWarmups,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } =
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay,
jimaku,
anilist,
yomitan,
jellyfin,
discordPresence,
ai,

View File

@@ -58,19 +58,19 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
},
shortcuts: {

View File

@@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
| 'ankiConnect'
| 'jimaku'
| 'anilist'
| 'yomitan'
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
@@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
},
},
yomitan: {
externalProfilePath: '',
},
jellyfin: {
enabled: false,
serverUrl: '',

View File

@@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', ()
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.collapsibleSections.description',
'yomitan.externalProfilePath',
'immersionTracking.enabled',
]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
@@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () =>
'startupWarmups',
'subtitleStyle',
'ankiConnect',
'yomitan',
'immersionTracking',
];

View File

@@ -4,20 +4,76 @@ import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
const controllerButtonEnumValues = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
];
const discreteBindings = [
{
id: 'toggleLookup',
defaultValue: defaultConfig.controller.bindings.toggleLookup,
description: 'Controller binding descriptor for toggling lookup.',
},
{
id: 'closeLookup',
defaultValue: defaultConfig.controller.bindings.closeLookup,
description: 'Controller binding descriptor for closing lookup.',
},
{
id: 'toggleKeyboardOnlyMode',
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
description: 'Controller binding descriptor for toggling keyboard-only mode.',
},
{
id: 'mineCard',
defaultValue: defaultConfig.controller.bindings.mineCard,
description: 'Controller binding descriptor for mining the active card.',
},
{
id: 'quitMpv',
defaultValue: defaultConfig.controller.bindings.quitMpv,
description: 'Controller binding descriptor for quitting mpv.',
},
{
id: 'previousAudio',
defaultValue: defaultConfig.controller.bindings.previousAudio,
description: 'Controller binding descriptor for previous Yomitan audio.',
},
{
id: 'nextAudio',
defaultValue: defaultConfig.controller.bindings.nextAudio,
description: 'Controller binding descriptor for next Yomitan audio.',
},
{
id: 'playCurrentAudio',
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
description: 'Controller binding descriptor for playing the current Yomitan audio.',
},
{
id: 'toggleMpvPause',
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding descriptor for toggling mpv play/pause.',
},
] as const;
const axisBindings = [
{
id: 'leftStickHorizontal',
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
description: 'Axis binding descriptor used for left/right token selection.',
},
{
id: 'leftStickVertical',
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
description: 'Axis binding descriptor used for primary popup scrolling.',
},
{
id: 'rightStickHorizontal',
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
},
{
id: 'rightStickVertical',
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
description: 'Axis binding descriptor used for popup page jumps.',
},
] as const;
return [
{
@@ -37,7 +93,7 @@ export function buildCoreConfigOptionRegistry(
path: 'controller.preferredGamepadId',
kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadId,
description: 'Preferred controller id saved from the controller selection modal.',
description: 'Preferred controller id saved from the controller config modal.',
},
{
path: 'controller.preferredGamepadLabel',
@@ -74,13 +130,15 @@ export function buildCoreConfigOptionRegistry(
kind: 'enum',
enumValues: ['auto', 'digital', 'analog'],
defaultValue: defaultConfig.controller.triggerInputMode,
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
description:
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
},
{
path: 'controller.triggerDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.triggerDeadzone,
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
description:
'Minimum analog trigger value required when trigger input uses auto or analog mode.',
},
{
path: 'controller.repeatDelayMs',
@@ -94,6 +152,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.',
},
{
path: 'controller.buttonIndices',
kind: 'object',
defaultValue: defaultConfig.controller.buttonIndices,
description:
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
},
{
path: 'controller.buttonIndices.select',
kind: 'number',
@@ -161,96 +226,79 @@ export function buildCoreConfigOptionRegistry(
description: 'Raw button index used for controller R2 input.',
},
{
path: 'controller.bindings.toggleLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleLookup,
description: 'Controller binding for toggling lookup.',
},
{
path: 'controller.bindings.closeLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.closeLookup,
description: 'Controller binding for closing lookup.',
},
{
path: 'controller.bindings.toggleKeyboardOnlyMode',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
description: 'Controller binding for toggling keyboard-only mode.',
},
{
path: 'controller.bindings.mineCard',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.mineCard,
description: 'Controller binding for mining the active card.',
},
{
path: 'controller.bindings.quitMpv',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.quitMpv,
description: 'Controller binding for quitting mpv.',
},
{
path: 'controller.bindings.previousAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.previousAudio,
description: 'Controller binding for previous Yomitan audio.',
},
{
path: 'controller.bindings.nextAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.nextAudio,
description: 'Controller binding for next Yomitan audio.',
},
{
path: 'controller.bindings.playCurrentAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
description: 'Controller binding for playing the current Yomitan audio.',
},
{
path: 'controller.bindings.toggleMpvPause',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding for toggling mpv play/pause.',
},
{
path: 'controller.bindings.leftStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
description: 'Axis binding used for left/right token selection.',
},
{
path: 'controller.bindings.leftStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
description: 'Axis binding used for primary popup scrolling.',
},
{
path: 'controller.bindings.rightStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
description: 'Axis binding reserved for alternate right-stick mappings.',
},
{
path: 'controller.bindings.rightStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
description: 'Axis binding used for popup page jumps.',
path: 'controller.bindings',
kind: 'object',
defaultValue: defaultConfig.controller.bindings,
description:
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
},
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
kind: 'object' as const,
defaultValue: binding.defaultValue,
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
},
{
path: `controller.bindings.${binding.id}.kind`,
kind: 'enum' as const,
enumValues: ['none', 'button', 'axis'],
defaultValue: binding.defaultValue.kind,
description:
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
},
{
path: `controller.bindings.${binding.id}.buttonIndex`,
kind: 'number' as const,
defaultValue:
binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined,
description: 'Raw button index captured for this discrete controller action.',
},
{
path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'number' as const,
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
description: 'Raw axis index captured for this discrete controller action.',
},
{
path: `controller.bindings.${binding.id}.direction`,
kind: 'enum' as const,
enumValues: ['negative', 'positive'],
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
description:
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
},
]),
...axisBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
kind: 'object' as const,
defaultValue: binding.defaultValue,
description: `${binding.description} Use Alt+C learn mode or set a raw axis descriptor manually.`,
},
{
path: `controller.bindings.${binding.id}.kind`,
kind: 'enum' as const,
enumValues: ['none', 'axis'],
defaultValue: binding.defaultValue.kind,
description: 'Analog binding input source kind.',
},
{
path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'number' as const,
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
description: 'Raw axis index captured for this analog controller action.',
},
{
path: `controller.bindings.${binding.id}.dpadFallback`,
kind: 'enum' as const,
enumValues: ['none', 'horizontal', 'vertical'],
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined,
description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
},
]),
{
path: 'texthooker.launchAtStartup',
kind: 'boolean',

View File

@@ -211,6 +211,13 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Open the Voiced by section by default in character dictionary glossary entries.',
},
{
path: 'yomitan.externalProfilePath',
kind: 'string',
defaultValue: defaultConfig.yomitan.externalProfilePath,
description:
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',

View File

@@ -38,7 +38,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'Controller Support',
description: [
'Gamepad support for the visible overlay while keyboard-only mode is active.',
'Use the selection modal to save a preferred controller by id for future launches.',
'Use Alt+C to pick a preferred controller and remap actions inline with learn mode.',
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
],
@@ -137,6 +137,16 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'anilist',
},
{
title: 'Yomitan',
description: [
'Optional external Yomitan profile integration.',
'Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.',
'For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.',
'In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.',
],
key: 'yomitan',
},
{
title: 'Jellyfin',
description: [

View File

@@ -1,23 +1,141 @@
import type {
ControllerAxisBinding,
ControllerAxisBindingConfig,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
const controllerButtonBindings = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
@@ -178,7 +296,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
@@ -188,7 +311,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
@@ -236,19 +364,27 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
] as const;
for (const key of buttonBindingKeys) {
const value = asString(src.controller.bindings[key]);
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
value !== undefined &&
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
resolved.controller.buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
bindingValue,
resolved.controller.bindings[key],
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
@@ -261,19 +397,31 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
] as const;
for (const key of axisBindingKeys) {
const value = asString(src.controller.bindings[key]);
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
value !== undefined &&
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
legacyValue as ControllerAxisBinding,
key,
);
continue;
}
if (legacyValue === 'none') {
resolved.controller.bindings[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
bindingValue,
resolved.controller.bindings[key],
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}

View File

@@ -1,6 +1,19 @@
import * as os from 'node:os';
import * as path from 'node:path';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
function normalizeExternalProfilePath(value: string): string {
const trimmed = value.trim();
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
}
export function applyIntegrationConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -199,6 +212,22 @@ export function applyIntegrationConfig(context: ResolveContext): void {
}
}
if (isObject(src.yomitan)) {
const externalProfilePath = asString(src.yomitan.externalProfilePath);
if (externalProfilePath !== undefined) {
resolved.yomitan.externalProfilePath = normalizeExternalProfilePath(externalProfilePath);
} else if (src.yomitan.externalProfilePath !== undefined) {
warn(
'yomitan.externalProfilePath',
src.yomitan.externalProfilePath,
resolved.yomitan.externalProfilePath,
'Expected string.',
);
}
} else if (src.yomitan !== undefined) {
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
}
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {

View File

@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as os from 'node:os';
import * as path from 'node:path';
import { createResolveContext } from './context';
import { applyIntegrationConfig } from './integrations';
@@ -104,3 +106,42 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
);
});
test('yomitan externalProfilePath is trimmed and invalid values warn', () => {
const { context, warnings } = createResolveContext({
yomitan: {
externalProfilePath: ' /tmp/gsm-profile ',
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.yomitan.externalProfilePath, '/tmp/gsm-profile');
const invalid = createResolveContext({
yomitan: {
externalProfilePath: 42 as never,
},
});
applyIntegrationConfig(invalid.context);
assert.equal(invalid.context.resolved.yomitan.externalProfilePath, '');
assert.ok(invalid.warnings.some((warning) => warning.path === 'yomitan.externalProfilePath'));
});
test('yomitan externalProfilePath expands leading tilde to the current home directory', () => {
const homeDir = os.homedir();
const { context } = createResolveContext({
yomitan: {
externalProfilePath: '~/.config/gsm_overlay',
},
});
applyIntegrationConfig(context);
assert.equal(
context.resolved.yomitan.externalProfilePath,
path.join(homeDir, '.config', 'gsm_overlay'),
);
});

View File

@@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
await runAppReadyRuntime(deps);
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
assert.equal(calls.includes('reloadConfig'), false);
assert.equal(calls.includes('reloadConfig'), true);
assert.equal(calls.includes('getResolvedConfig'), false);
assert.equal(calls.includes('getConfigWarnings'), false);
assert.equal(calls.includes('setLogLevel:warn:config'), false);
@@ -170,6 +170,8 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadYomitanExtension'), true);
assert.equal(calls.includes('handleFirstRunSetup'), true);
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('reloadConfig'));
assert.ok(calls.indexOf('reloadConfig') < calls.indexOf('handleFirstRunSetup'));
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
});

View File

@@ -33,6 +33,50 @@ function createFakeIpcRegistrar(): {
};
}
function createControllerConfigFixture() {
return {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto' as const,
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: { kind: 'button' as const, buttonIndex: 0 },
closeLookup: { kind: 'button' as const, buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 },
mineCard: { kind: 'button' as const, buttonIndex: 2 },
quitMpv: { kind: 'button' as const, buttonIndex: 6 },
previousAudio: { kind: 'button' as const, buttonIndex: 4 },
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
};
}
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
@@ -53,47 +97,8 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
@@ -159,47 +164,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
@@ -299,47 +265,10 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: (update) => {
controllerSaves.push(update);
},
saveControllerPreference: (update) => {
controllerSaves.push(update);
},
@@ -400,47 +329,8 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async (update) => {
await Promise.resolve();
controllerSaves.push(update);
@@ -467,16 +357,16 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
assert.ok(saveHandler);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
await saveHandler!(
{},
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
/Invalid controller preference payload/,
);
await saveHandler!({}, {
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
});
assert.deepEqual(controllerSaves, [
{
@@ -486,6 +376,85 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerConfigSaves: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async (update) => {
await Promise.resolve();
controllerConfigSaves.push(update);
},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
assert.ok(saveHandler);
await assert.rejects(
async () => {
await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } });
},
/Invalid controller config payload/,
);
await saveHandler!({}, {
preferredGamepadId: 'pad-2',
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
},
});
assert.deepEqual(controllerConfigSaves, [
{
preferredGamepadId: 'pad-2',
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
},
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
@@ -508,47 +477,8 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
@@ -570,10 +500,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
});

View File

@@ -1,6 +1,7 @@
import electron from 'electron';
import type { IpcMainEvent } from 'electron';
import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
RuntimeOptionId,
@@ -12,6 +13,7 @@ import type {
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
parseControllerConfigUpdate,
parseControllerPreferenceUpdate,
parseOptionalForwardingOptions,
parseOverlayHostedModal,
@@ -49,6 +51,7 @@ export interface IpcServiceDeps {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
@@ -114,6 +117,7 @@ export interface IpcDepsRuntimeOptions {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
@@ -167,6 +171,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
@@ -265,12 +270,23 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.saveSubtitlePosition(parsedPosition);
});
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update);
ipc.handle(
IPC_CHANNELS.command.saveControllerPreference,
async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller preference payload');
}
await deps.saveControllerPreference(parsedUpdate);
},
);
ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerConfigUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller preference payload');
throw new Error('Invalid controller config payload');
}
await deps.saveControllerPreference(parsedUpdate);
await deps.saveControllerConfig(parsedUpdate);
});
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {

View File

@@ -1,11 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { buildOverlayWindowOptions } from './overlay-window-options';
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
const options = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m);
assert.equal(options.webPreferences?.sandbox, false);
});
test('overlay window config uses the provided Yomitan session when available', () => {
const yomitanSession = { id: 'session' } as never;
const withSession = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession,
});
const withoutSession = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.equal(withSession.webPreferences?.session, yomitanSession);
assert.equal(withoutSession.webPreferences?.session, undefined);
});

View File

@@ -0,0 +1,39 @@
import type { BrowserWindowConstructorOptions, Session } from 'electron';
import * as path from 'path';
import type { OverlayWindowKind } from './overlay-window-input';
export function buildOverlayWindowOptions(
kind: OverlayWindowKind,
options: {
isDev: boolean;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
return {
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`],
},
};
}

View File

@@ -1,4 +1,4 @@
import { BrowserWindow } from 'electron';
import { BrowserWindow, type Session } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
@@ -7,6 +7,7 @@ import {
handleOverlayWindowBeforeInputEvent,
type OverlayWindowKind,
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -78,33 +79,10 @@ export function createOverlayWindow(
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
additionalArguments: [`--overlay-layer=${kind}`],
},
});
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
options.ensureOverlayWindowLevel(window);
loadOverlayWindowLayer(window, kind);
@@ -170,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
loadOverlayWindowLayer(window, layer);
}
export { buildOverlayWindowOptions } from './overlay-window-options';
export type { OverlayWindowKind } from './overlay-window-input';

View File

@@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.ensureDefaultConfigBootstrap();
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
@@ -194,6 +195,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);

View File

@@ -1,4 +1,4 @@
import type { BrowserWindow, Extension } from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import { mergeTokens } from '../../token-merger';
import { createLogger } from '../../logger';
import {
@@ -33,6 +33,7 @@ type MecabTokenEnrichmentFn = (
export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -63,6 +64,7 @@ interface MecabTokenizerLike {
export interface TokenizerDepsRuntimeOptions {
getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -182,6 +184,7 @@ export function createTokenizerDepsRuntime(
return {
getYomitanExt: options.getYomitanExt,
getYomitanSession: options.getYomitanSession,
getYomitanParserWindow: options.getYomitanParserWindow,
setYomitanParserWindow: options.setYomitanParserWindow,
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,

View File

@@ -1,4 +1,4 @@
import type { BrowserWindow, Extension } from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { selectYomitanParseTokens } from './parser-selection-stage';
@@ -10,6 +10,7 @@ interface LoggerLike {
interface YomitanParserRuntimeDeps {
getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -465,6 +466,7 @@ async function ensureYomitanParserWindow(
const initPromise = (async () => {
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const parserWindow = new BrowserWindow({
show: false,
width: 800,
@@ -472,7 +474,7 @@ async function ensureYomitanParserWindow(
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.defaultSession,
session: yomitanSession,
},
});
deps.setYomitanParserWindow(parserWindow);
@@ -539,6 +541,7 @@ async function createYomitanExtensionWindow(
}
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const window = new BrowserWindow({
show: false,
width: 1200,
@@ -546,7 +549,7 @@ async function createYomitanExtensionWindow(
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.defaultSession,
session: yomitanSession,
},
});

View File

@@ -1,12 +1,18 @@
import electron from 'electron';
import type { BrowserWindow, Extension } from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { ensureExtensionCopy } from './yomitan-extension-copy';
import {
getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
resolveExistingYomitanExtensionPath,
} from './yomitan-extension-paths';
import {
clearYomitanExtensionRuntimeState,
clearYomitanParserRuntimeState,
} from './yomitan-extension-runtime-state';
const { session } = electron;
const logger = createLogger('main:yomitan-extension-loader');
@@ -14,51 +20,82 @@ const logger = createLogger('main:yomitan-extension-loader');
export interface YomitanExtensionLoaderDeps {
userDataPath: string;
extensionPath?: string;
externalProfilePath?: string;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
setYomitanExtension: (extension: Extension | null) => void;
setYomitanSession: (session: Session | null) => void;
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
const searchPaths = getYomitanExtensionSearchPaths({
explicitPath: deps.extensionPath,
moduleDir: __dirname,
resourcesPath: process.resourcesPath,
userDataPath: deps.userDataPath,
});
let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
const clearRuntimeState = () =>
clearYomitanExtensionRuntimeState({
getYomitanParserWindow: deps.getYomitanParserWindow,
setYomitanParserWindow: deps.setYomitanParserWindow,
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
setYomitanExtension: () => deps.setYomitanExtension(null),
setYomitanSession: () => deps.setYomitanSession(null),
});
const clearParserState = () =>
clearYomitanParserRuntimeState({
getYomitanParserWindow: deps.getYomitanParserWindow,
setYomitanParserWindow: deps.setYomitanParserWindow,
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
});
const externalProfilePath = deps.externalProfilePath?.trim() ?? '';
let extPath: string | null = null;
let targetSession: Session = session.defaultSession;
if (!extPath) {
logger.error('Yomitan extension not found in any search path');
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
return null;
if (externalProfilePath) {
const resolvedProfilePath = path.resolve(externalProfilePath);
extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync);
if (!extPath) {
logger.error('External Yomitan extension not found in configured profile path');
logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions'));
clearRuntimeState();
return null;
}
targetSession = session.fromPath(resolvedProfilePath);
} else {
const searchPaths = getYomitanExtensionSearchPaths({
explicitPath: deps.extensionPath,
moduleDir: __dirname,
resourcesPath: process.resourcesPath,
userDataPath: deps.userDataPath,
});
extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
if (!extPath) {
logger.error('Yomitan extension not found in any search path');
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
clearRuntimeState();
return null;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
const parserWindow = deps.getYomitanParserWindow();
if (parserWindow && !parserWindow.isDestroyed()) {
parserWindow.destroy();
}
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
clearParserState();
deps.setYomitanSession(targetSession);
try {
const extensions = session.defaultSession.extensions;
const extensions = targetSession.extensions;
const extension = extensions
? await extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: await session.defaultSession.loadExtension(extPath, {
: await targetSession.loadExtension(extPath, {
allowFileAccess: true,
});
deps.setYomitanExtension(extension);
@@ -66,7 +103,7 @@ export async function loadYomitanExtension(
} catch (err) {
logger.error('Failed to load Yomitan extension:', (err as Error).message);
logger.error('Full error:', err);
deps.setYomitanExtension(null);
clearRuntimeState();
return null;
}
}

View File

@@ -4,6 +4,7 @@ import test from 'node:test';
import {
getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
resolveExistingYomitanExtensionPath,
} from './yomitan-extension-paths';
@@ -51,3 +52,20 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
assert.equal(resolved, null);
});
test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => {
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
const resolved = resolveExternalYomitanExtensionPath(
profilePath,
(candidate) => candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
);
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
});
test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => {
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false);
assert.equal(resolved, null);
});

View File

@@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath(
): string | null {
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync);
}
export function resolveExternalYomitanExtensionPath(
externalProfilePath: string,
existsSync: (path: string) => boolean = fs.existsSync,
): string | null {
const normalizedProfilePath = externalProfilePath.trim();
if (!normalizedProfilePath) {
return null;
}
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
}

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
const calls: string[] = [];
const parserWindow = {
isDestroyed: () => false,
destroy: () => {
calls.push('destroy');
},
};
clearYomitanParserRuntimeState({
getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
setYomitanParserReadyPromise: (promise) =>
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
setYomitanParserInitPromise: (promise) =>
calls.push(`init:${promise === null ? 'null' : 'set'}`),
});
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
});
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
const calls: string[] = [];
const parserWindow = {
isDestroyed: () => true,
destroy: () => {
calls.push('destroy');
},
};
clearYomitanParserRuntimeState({
getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
setYomitanParserReadyPromise: (promise) =>
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
setYomitanParserInitPromise: (promise) =>
calls.push(`init:${promise === null ? 'null' : 'set'}`),
});
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
});

View File

@@ -0,0 +1,32 @@
type ParserWindowLike = {
isDestroyed?: () => boolean;
destroy?: () => void;
} | null;
export interface YomitanParserRuntimeStateDeps {
getYomitanParserWindow: () => ParserWindowLike;
setYomitanParserWindow: (window: null) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
}
export interface YomitanExtensionRuntimeStateDeps extends YomitanParserRuntimeStateDeps {
setYomitanExtension: (extension: null) => void;
setYomitanSession: (session: null) => void;
}
export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDeps): void {
const parserWindow = deps.getYomitanParserWindow();
if (parserWindow && !parserWindow.isDestroyed?.()) {
parserWindow.destroy?.();
}
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
}
export function clearYomitanExtensionRuntimeState(deps: YomitanExtensionRuntimeStateDeps): void {
clearYomitanParserRuntimeState(deps);
deps.setYomitanExtension(null);
deps.setYomitanSession(null);
}

View File

@@ -1,5 +1,5 @@
import electron from 'electron';
import type { BrowserWindow, Extension } from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
@@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions {
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
yomitanSession?: Session | null;
onWindowClosed?: () => void;
}
@@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.defaultSession,
session: options.yomitanSession ?? session.defaultSession,
},
});
options.setWindow(settingsWindow);

View File

@@ -23,12 +23,14 @@ import {
shell,
protocol,
Extension,
Session,
Menu,
nativeImage,
Tray,
dialog,
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
@@ -359,7 +361,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -375,7 +377,14 @@ import { createCharacterDictionaryRuntimeService } from './main/character-dictio
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import {
createConfiguredNotificationHandler,
createShowLogicalOsdHandler,
createShowOverlayNotificationHandler,
} from './main/runtime/overlay-notifications';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
@@ -691,6 +700,8 @@ const firstRunSetupService = createFirstRunSetupService({
});
return dictionaries.length;
},
isExternalYomitanConfigured: () =>
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
@@ -1114,8 +1125,7 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{
showMpvOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
},
);
const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler();
@@ -1327,7 +1337,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
getConfig: () => {
const config = getResolvedConfig().anilist.characterDictionary;
return {
enabled: config.enabled,
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
maxLoaded: config.maxLoaded,
profileScope: config.profileScope,
};
@@ -1347,6 +1357,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
importYomitanDictionary: async (zipPath) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
@@ -1354,6 +1370,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
deleteYomitanDictionary: async (dictionaryTitle) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
@@ -1361,6 +1383,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await upsertYomitanDictionarySettings(
dictionaryTitle,
@@ -1814,6 +1842,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -1837,8 +1866,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return;
}
if (submission.action === 'open-yomitan-settings') {
openYomitanSettings();
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'refresh') {
@@ -1895,9 +1925,7 @@ const {
registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
logInfo: (message) => logger.info(message),
},
consumeTokenDeps: {
@@ -2320,6 +2348,7 @@ const {
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
appState.yomitanSession = null;
},
getWindowTracker: () => appState.windowTracker,
flushMpvLog: () => flushPendingMpvLogWrites(),
@@ -2737,6 +2766,9 @@ const {
);
},
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
@@ -2780,6 +2812,7 @@ const {
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
@@ -2813,7 +2846,9 @@ const {
'subtitle.annotation.jlpt',
getResolvedConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled,
getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption(
@@ -2987,7 +3022,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
async function loadYomitanExtension(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.loadYomitanExtension();
if (extension) {
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
@@ -2995,7 +3030,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
if (extension) {
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
@@ -3010,6 +3045,7 @@ function getPreferredYomitanAnkiServerUrl(): string {
function getYomitanParserRuntimeDeps() {
return {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
appState.yomitanParserWindow = window;
@@ -3026,6 +3062,10 @@ function getYomitanParserRuntimeDeps() {
}
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
return;
}
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
return;
@@ -3079,8 +3119,20 @@ function initializeOverlayRuntime(): void {
syncOverlayMpvSubtitleSuppression();
}
function openYomitanSettings(): void {
function openYomitanSettings(): boolean {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
);
showConfiguredNotification('SubMiner', {
kind: 'warning',
message,
});
return false;
}
openYomitanSettingsHandler();
return true;
}
const {
@@ -3129,7 +3181,7 @@ const {
},
});
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
const { flushMpvLog, showMpvOsd: showActualMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH,
dirname: (targetPath) => path.dirname(targetPath),
@@ -3152,6 +3204,21 @@ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
flushPendingMpvLogWrites = () => {
void flushMpvLog();
};
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
notificationChannel: IPC_CHANNELS.event.overlayNotification,
});
const showMpvOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => showOverlayNotification(payload),
showMpvOsd: (message) => showActualMpvOsd(message),
});
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
showLogicalOsd: (payload) => showMpvOsd(payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
});
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
@@ -3409,6 +3476,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getControllerConfig: () => getResolvedConfig().controller,
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
});
},
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
configService.patchRawConfig({
controller: {
@@ -3496,8 +3569,13 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
generateCharacterDictionary: async (targetPath?: string) => {
const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason();
if (disabledReason) {
throw new Error(disabledReason);
}
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
},
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
@@ -3522,6 +3600,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
@@ -3584,9 +3663,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
},
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
});
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
@@ -3600,6 +3685,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
setYomitanExtension: (extension) => {
appState.yomitanExt = extension;
},
setYomitanSession: (nextSession) => {
appState.yomitanSession = nextSession;
},
getYomitanExtension: () => appState.yomitanExt,
getLoadInFlight: () => yomitanLoadInFlight,
setLoadInFlight: (promise) => {
@@ -3641,11 +3729,13 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => {
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
openYomitanSettingsWindow({
yomitanExt: yomitanExt as Extension,
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
setWindow: (window) => setWindow(window as BrowserWindow | null),
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
onWindowClosed: () => {
if (appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyControllerConfigUpdate } from './controller-config-update.js';
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
const next = applyControllerConfigUpdate(
{
preferredGamepadId: 'pad-1',
bindings: {
toggleLookup: { kind: 'axis', axisIndex: 4, direction: 'positive' },
closeLookup: { kind: 'button', buttonIndex: 1 },
},
},
{
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
);
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 11 });
assert.deepEqual(next.bindings?.closeLookup, { kind: 'button', buttonIndex: 1 });
});
test('applyControllerConfigUpdate merges buttonIndices while replacing only updated binding leaves', () => {
const next = applyControllerConfigUpdate(
{
buttonIndices: {
select: 6,
buttonSouth: 0,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
},
},
{
buttonIndices: {
buttonSouth: 9,
},
bindings: {
closeLookup: { kind: 'none' },
},
},
);
assert.deepEqual(next.buttonIndices, {
select: 6,
buttonSouth: 9,
});
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
});

View File

@@ -0,0 +1,38 @@
import type { ControllerConfigUpdate, RawConfig } from '../types';
type RawControllerConfig = NonNullable<RawConfig['controller']>;
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
export function applyControllerConfigUpdate(
currentController: RawConfig['controller'] | undefined,
update: ControllerConfigUpdate,
): RawControllerConfig {
const nextController: RawControllerConfig = {
...(currentController ?? {}),
...update,
};
if (currentController?.buttonIndices || update.buttonIndices) {
nextController.buttonIndices = {
...(currentController?.buttonIndices ?? {}),
...(update.buttonIndices ?? {}),
};
}
if (currentController?.bindings || update.bindings) {
const nextBindings: RawControllerBindings = {
...(currentController?.bindings ?? {}),
};
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
>) {
if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
}
nextController.bindings = nextBindings;
}
return nextController;
}

View File

@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
@@ -216,6 +217,7 @@ export function createMainIpcRuntimeServiceDeps(
getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference,
focusMainWindow: params.focusMainWindow ?? (() => {}),
getSecondarySubMode: params.getSecondarySubMode,

View File

@@ -10,17 +10,14 @@ import {
test('notify anilist setup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.hasMpvClient(), true);
deps.showMpvOsd('ok');
deps.showDesktopNotification('SubMiner', { body: 'x' });
deps.showConfiguredNotification('SubMiner', { kind: 'success', message: 'ok' });
deps.logInfo('done');
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
assert.deepEqual(calls, ['configured:SubMiner:success:ok', 'log:done']);
});
test('consume anilist setup token main deps builder maps callbacks', () => {

View File

@@ -18,10 +18,7 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({
hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
logInfo: (message: string) => deps.logInfo(message),
});
}

View File

@@ -7,16 +7,18 @@ import {
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
test('createNotifyAnilistSetupHandler routes AniList setup messages through configured notifications', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: () => calls.push('desktop'),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']);
assert.deepEqual(calls, [
'configured:SubMiner AniList:success:AniList login success',
'log',
]);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {

View File

@@ -29,18 +29,28 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
});
}
import type { OverlayNotificationPayload } from '../../types';
function resolveAnilistNotificationKind(message: string): OverlayNotificationPayload['kind'] {
const normalized = message.toLowerCase();
if (normalized.includes('failed') || normalized.includes('error')) {
return 'error';
}
if (normalized.includes('success')) {
return 'success';
}
return 'info';
}
export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
}
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.showConfiguredNotification('SubMiner AniList', {
kind: resolveAnilistNotificationKind(message),
message,
});
deps.logInfo(`[AniList setup] ${message}`);
};
}

View File

@@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) =>
calls.push(`open:${(yomitanExt as { id: string }).id}`),
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;
calls.push('set-window');
},
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
})();
@@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => {
yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window),
yomitanSession: deps.getYomitanSession(),
});
deps.logWarn('warn');
deps.logError('error', new Error('boom'));
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']);
assert.deepEqual(currentWindow, { id: 'win' });
});

View File

@@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
getYomitanSession?: () => unknown | null;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
@@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});

View File

@@ -60,7 +60,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
]);
});
test('auto sync notifications never send desktop notifications', () => {
test('auto sync notifications send desktop notifications when the type includes system', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
@@ -88,5 +88,27 @@ test('auto sync notifications never send desktop notifications', () => {
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
assert.deepEqual(calls, [
'osd:syncing',
'desktop:SubMiner:syncing',
'osd:importing',
'desktop:SubMiner:importing',
'osd:ready',
'desktop:SubMiner:ready',
'osd:failed',
'desktop:SubMiner:failed',
]);
});
test('auto sync notifications respect system-only mode', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'system',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:syncing']);
});

View File

@@ -1,5 +1,6 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import { shouldShowDesktopNotification, shouldShowLogicalOsd } from './overlay-notifications';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
@@ -12,23 +13,23 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
};
}
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
}
export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps,
): void {
const type = deps.getNotificationType();
if (shouldShowOsd(type)) {
if (shouldShowLogicalOsd(type)) {
if (deps.startupOsdSequencer) {
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
});
return;
} else {
deps.showOsd(event.message);
}
deps.showOsd(event.message);
}
if (shouldShowDesktopNotification(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCharacterDictionaryDisabledReason,
isCharacterDictionaryRuntimeEnabled,
} from './character-dictionary-availability';
test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled(''), true);
assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true);
assert.equal(getCharacterDictionaryDisabledReason(''), null);
});
test('character dictionary runtime is disabled when external Yomitan profile is configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false);
assert.equal(
getCharacterDictionaryDisabledReason('/tmp/gsm-profile'),
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
);
});

View File

@@ -0,0 +1,10 @@
export function isCharacterDictionaryRuntimeEnabled(externalProfilePath: string): boolean {
return externalProfilePath.trim().length === 0;
}
export function getCharacterDictionaryDisabledReason(externalProfilePath: string): string | null {
if (isCharacterDictionaryRuntimeEnabled(externalProfilePath)) {
return null;
}
return 'Character dictionary is disabled while yomitan.externalProfilePath is configured.';
}

View File

@@ -5,9 +5,7 @@ import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const composed = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => false,
showMpvOsd: () => {},
showDesktopNotification: () => {},
showConfiguredNotification: () => {},
logInfo: () => {},
},
consumeTokenDeps: {

View File

@@ -52,6 +52,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never,
getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null,

View File

@@ -70,12 +70,12 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
assert.deepEqual(calls, ['configured:SubMiner:warning:Config reload failed']);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {

View File

@@ -1,7 +1,12 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
import type {
ConfigHotReloadPayload,
OverlayNotificationPayload,
ResolvedConfig,
SecondarySubMode,
} from '../../types';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
@@ -14,8 +19,7 @@ type ConfigHotReloadAppliedDeps = {
};
type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
@@ -66,8 +70,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
deps.showConfiguredNotification('SubMiner', {
kind: 'warning',
message,
});
};
}

View File

@@ -75,13 +75,12 @@ test('watch config path main deps builder maps filesystem callbacks', () => {
test('config hot reload message main deps builder maps notifications', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
})();
deps.showMpvOsd('updated');
deps.showDesktopNotification('SubMiner', { body: 'updated' });
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
deps.showConfiguredNotification('SubMiner', { kind: 'warning', message: 'updated' });
assert.deepEqual(calls, ['configured:SubMiner:warning:updated']);
});
test('config hot reload applied main deps builder maps callbacks', () => {

View File

@@ -54,9 +54,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps,
) {
return (): ConfigHotReloadMessageMainDeps => ({
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
});
}

View File

@@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.completionSource, 'user');
assert.equal(completed.state.yomitanSetupMode, 'internal');
});
});
test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.yomitanSetupMode, 'external');
assert.equal(completed.dictionaryCount, 0);
});
});
test('setup service does not probe internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => {
throw new Error('should not probe internal dictionaries in external mode');
},
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.externalYomitanConfigured, true);
assert.equal(snapshot.dictionaryCount, 0);
});
});
test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.state.yomitanSetupMode, null);
assert.equal(snapshot.canFinish, false);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
});
});

View File

@@ -26,6 +26,7 @@ export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
@@ -139,10 +140,50 @@ function getEffectiveWindowsMpvShortcutPreferences(
};
}
function isYomitanSetupSatisfied(options: {
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}): boolean {
if (!options.configReady) {
return false;
}
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
if (configReady && externalYomitanConfigured) {
return {
configReady,
dictionaryCount: 0,
externalYomitanConfigured,
};
}
return {
configReady,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
}
export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
@@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: {
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
@@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: {
state,
installedWindowsMpvShortcuts,
);
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
configReady,
dictionaryCount,
canFinish: dictionaryCount >= 1,
canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: {
@@ -217,20 +266,36 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
if (isSetupCompleted(state)) {
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (
isSetupCompleted(state) &&
!(
state.yomitanSetupMode === 'external' &&
!externalYomitanConfigured &&
!yomitanSetupSatisfied
)
) {
completed = true;
return refreshWithState(state);
}
const dictionaryCount = await deps.getYomitanDictionaryCount();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
if (configReady && dictionaryCount >= 1) {
if (yomitanSetupSatisfied) {
const completedState = writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'legacy_auto_detected',
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: dictionaryCount,
});
return buildSnapshot(completedState);
@@ -242,6 +307,7 @@ export function createFirstRunSetupService(deps: {
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: dictionaryCount,
}),
);
@@ -276,6 +342,7 @@ export function createFirstRunSetupService(deps: {
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'user',
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
}),
);

View File

@@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
configReady: true,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
@@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: {
@@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.match(html, /Reinstall mpv plugin/);
});
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: true,
externalYomitanConfigured: true,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /External profile configured/);
assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
@@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
configReady: false,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {

View File

@@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: {
@@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
? 'External'
: model.dictionaryCount >= 1
? 'Ready'
: 'Missing';
const yomitanBadgeTone = model.externalYomitanConfigured
? 'ready'
: model.dictionaryCount >= 1
? 'ready'
: 'warn';
const footerMessage = model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
<head>
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${windowsShortcutCard}
<div class="actions">
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
</main>
</body>
</html>`;

View File

@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayNotificationPayload } from '../../types';
import {
createConfiguredNotificationHandler,
createShowOverlayNotificationHandler,
createShowLogicalOsdHandler,
} from './overlay-notifications.js';
test('logical OSD prefers overlay notifications when available', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'success',
message: 'Subtitle annotations loaded',
});
assert.equal(result, 'overlay');
assert.deepEqual(calls, ['overlay:success:Subtitle annotations loaded']);
});
test('logical OSD normalizes spinner-frame loading messages for overlay notifications', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
showLogicalOsd('Loading subtitle annotations |');
assert.deepEqual(calls, ['overlay:loading:Loading subtitle annotations']);
});
test('logical OSD falls back to mpv OSD when overlay notifications are unavailable', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: () => false,
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(result, 'osd');
assert.deepEqual(calls, ['osd:Loading subtitle annotations']);
});
test('overlay notifications send to the visible overlay when it is enabled and visible', () => {
const calls: string[] = [];
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => false,
getURL: () => 'file:///overlay.html',
once: () => undefined,
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'success',
message: 'Overlay ready',
});
assert.equal(shown, true);
assert.deepEqual(calls, ['overlay:notification:success:Overlay ready']);
});
test('overlay notifications return false when the visible overlay is hidden', () => {
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => false,
getMainWindow: () => null,
notificationChannel: 'overlay:notification',
});
assert.equal(
showOverlayNotification({
kind: 'info',
message: 'Hidden overlay fallback',
}),
false,
);
});
test('overlay notifications queue until renderer load finishes when window is visible but still loading', () => {
const calls: string[] = [];
let didFinishLoad: (() => void) | null = null;
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => true,
getURL: () => 'about:blank',
once: (_event: string, listener: () => void) => {
didFinishLoad = listener;
},
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(shown, true);
assert.deepEqual(calls, []);
if (didFinishLoad === null) {
throw new Error('expected did-finish-load listener');
}
const runDidFinishLoad: () => void = didFinishLoad;
runDidFinishLoad();
assert.deepEqual(calls, ['overlay:notification:loading:Loading subtitle annotations']);
});
test('configured notifications treat osd as the overlay-or-fallback channel', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'osd',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'info',
message: 'Config reload failed',
});
assert.deepEqual(calls, ['logical:info:Config reload failed']);
});
test('configured notifications send both logical OSD and desktop notifications for both', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'both',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body}`);
},
});
showConfiguredNotification('SubMiner', {
kind: 'warning',
message: 'Restart required',
});
assert.deepEqual(calls, [
'logical:warning:Restart required',
'desktop:SubMiner:Restart required',
]);
});
test('configured notifications suppress all channels for none', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'none',
showLogicalOsd: () => {
calls.push('logical');
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'error',
message: 'Overlay failed to start',
});
assert.deepEqual(calls, []);
});
test('configured notifications default missing types to logical OSD only', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => undefined,
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
const payload: OverlayNotificationPayload = {
kind: 'success',
message: 'Card updated',
};
showConfiguredNotification('SubMiner', payload);
assert.deepEqual(calls, ['logical:success:Card updated']);
});

View File

@@ -0,0 +1,115 @@
import type { OverlayNotificationPayload } from '../../types';
export type NotificationType = 'osd' | 'system' | 'both' | 'none' | undefined;
function normalizeNotificationPayload(
payload: string | OverlayNotificationPayload,
): OverlayNotificationPayload {
if (typeof payload === 'string') {
const trimmed = payload.trim();
const spinnerNormalized = trimmed.replace(/\s+[|/\\-]$/, '');
if (
trimmed.startsWith('Loading subtitle annotations') ||
trimmed === 'Overlay loading...' ||
trimmed === 'Starting...' ||
trimmed === 'Restarting...'
) {
return {
kind: 'loading',
message: spinnerNormalized,
};
}
return {
kind: 'info',
message: payload,
};
}
return payload;
}
export function createShowLogicalOsdHandler(deps: {
showOverlayNotification: (payload: OverlayNotificationPayload) => boolean;
showMpvOsd: (message: string) => void;
}) {
return (payload: string | OverlayNotificationPayload): 'overlay' | 'osd' => {
const normalized = normalizeNotificationPayload(payload);
if (deps.showOverlayNotification(normalized)) {
return 'overlay';
}
deps.showMpvOsd(normalized.message);
return 'osd';
};
}
export function createShowOverlayNotificationHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => {
isDestroyed: () => boolean;
isVisible: () => boolean;
webContents: {
isLoading: () => boolean;
getURL: () => string;
once: (event: 'did-finish-load', listener: () => void) => void;
send: (channel: string, payload: OverlayNotificationPayload) => void;
};
} | null;
notificationChannel: string;
}) {
return (payload: OverlayNotificationPayload): boolean => {
if (!deps.isOverlayRuntimeInitialized()) {
return false;
}
if (!deps.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
const send = (): void => {
mainWindow.webContents.send(deps.notificationChannel, payload);
};
if (!mainWindow.webContents.isLoading() && mainWindow.webContents.getURL() !== 'about:blank') {
send();
return true;
}
mainWindow.webContents.once('did-finish-load', () => {
if (!mainWindow.isDestroyed() && mainWindow.isVisible()) {
send();
}
});
return true;
};
}
export function shouldShowLogicalOsd(type: NotificationType): boolean {
return type === undefined || type === 'osd' || type === 'both';
}
export function shouldShowDesktopNotification(type: NotificationType): boolean {
return type === 'system' || type === 'both';
}
export function createConfiguredNotificationHandler(deps: {
getNotificationType: () => NotificationType;
showLogicalOsd: (payload: OverlayNotificationPayload) => 'overlay' | 'osd';
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
}) {
return (title: string, payload: string | OverlayNotificationPayload): void => {
const normalized = normalizeNotificationPayload(payload);
const notificationType = deps.getNotificationType();
if (shouldShowLogicalOsd(notificationType)) {
deps.showLogicalOsd(normalized);
}
if (shouldShowDesktopNotification(notificationType)) {
deps.showDesktopNotification(title, { body: normalized.message });
}
};
}

View File

@@ -8,6 +8,7 @@ import {
test('overlay window factory main deps builders return mapped handlers', () => {
const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }),
isDev: true,
@@ -18,11 +19,13 @@ test('overlay window factory main deps builders return mapped handlers', () => {
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
});
const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
overlayDeps.forwardTabToMpv();
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'modal',
@@ -10,6 +12,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
yomitanSession?: Session | null;
},
) => TWindow;
isDev: boolean;
@@ -20,6 +23,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
getYomitanSession?: () => Session | null;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
@@ -31,6 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
});
}

View File

@@ -9,12 +9,14 @@ import {
test('create overlay window handler forwards options and kind', () => {
const calls: string[] = [];
const window = { id: 1 };
const yomitanSession = { id: 'session' } as never;
const createOverlayWindow = createCreateOverlayWindowHandler({
createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`);
assert.equal(options.isDev, true);
assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('modal'), false);
assert.equal(options.yomitanSession, yomitanSession);
options.forwardTabToMpv();
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
@@ -29,6 +31,7 @@ test('create overlay window handler forwards options and kind', () => {
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
});
assert.equal(createOverlayWindow('visible'), window);

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
type OverlayWindowKind = 'visible' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: {
@@ -12,6 +14,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
) => TWindow;
isDev: boolean;
@@ -22,6 +25,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
getYomitanSession?: () => Session | null;
}) {
return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, {
@@ -33,6 +37,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
});
};
}

View File

@@ -7,10 +7,14 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
let modalWindow: { kind: string } | null = null;
let debugEnabled = false;
const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const runtime = createOverlayWindowRuntimeHandlers({
const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
createOverlayWindowCore: (kind, options) => {
assert.equal(options.yomitanSession, yomitanSession);
return { kind };
},
isDev: true,
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
@@ -21,6 +25,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
},
setMainWindow: (window) => {
mainWindow = window;

View File

@@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
return (): TokenizerDepsRuntimeOptions => ({
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),

View File

@@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => {
return null;
},
userDataPath: '/tmp/subminer',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => calls.push('set-window'),
setYomitanParserReadyPromise: () => calls.push('set-ready'),
setYomitanParserInitPromise: () => calls.push('set-init'),
setYomitanExtension: () => calls.push('set-ext'),
setYomitanSession: () => calls.push('set-session'),
})();
assert.equal(deps.userDataPath, '/tmp/subminer');
assert.equal(deps.externalProfilePath, '/tmp/gsm-profile');
await deps.loadYomitanExtensionCore({} as never);
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
deps.setYomitanExtension(null);
assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']);
deps.setYomitanSession(null as never);
assert.deepEqual(calls, [
'load-core',
'set-window',
'set-ready',
'set-init',
'set-ext',
'set-session',
]);
});
test('ensure yomitan extension loaded main deps builder maps callbacks', async () => {

View File

@@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan
return (): LoadYomitanExtensionMainDeps => ({
loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options),
userDataPath: deps.userDataPath,
externalProfilePath: deps.externalProfilePath,
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise),
setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise),
setYomitanExtension: (extension) => deps.setYomitanExtension(extension),
setYomitanSession: (session) => deps.setYomitanSession(session),
});
}

Some files were not shown because too many files have changed in this diff Show More