mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Compare commits
6 Commits
v0.6.2
...
99f4d2baaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f4d2baaf | |||
|
f4e8c3feec
|
|||
|
d0b308f340
|
|||
| 1b56360a24 | |||
|
68833c76c4
|
|||
| 4d7c80f2e4 |
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
127
.agents/skills/subminer-change-verification/SKILL.md
Normal 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.
|
||||
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable 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
|
||||
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable 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
|
||||
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal 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
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -9,9 +9,6 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -244,6 +241,8 @@ jobs:
|
||||
release:
|
||||
needs: [build-linux, build-macos, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -35,6 +35,19 @@ 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
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||
- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||
|
||||
### Docs
|
||||
- Install: Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
||||
|
||||
### Internal
|
||||
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||
- Release: Fixed the release workflow token permissions so tagged builds can download `oven-sh/setup-bun` and publish artifacts again.
|
||||
|
||||
## v0.5.6 (2026-03-10)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: TASK-159
|
||||
title: Add overlay controller support for keyboard-only mode
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-11 00:30'
|
||||
updated_date: '2026-03-11 04:05'
|
||||
labels:
|
||||
- enhancement
|
||||
- renderer
|
||||
- overlay
|
||||
- input
|
||||
dependencies:
|
||||
- TASK-86
|
||||
references:
|
||||
- src/renderer/handlers/keyboard.ts
|
||||
- src/renderer/renderer.ts
|
||||
- src/renderer/state.ts
|
||||
- src/renderer/index.html
|
||||
- src/renderer/style.css
|
||||
- src/preload.ts
|
||||
- src/types.ts
|
||||
- src/config/definitions/defaults-core.ts
|
||||
- src/config/definitions/options-core.ts
|
||||
- src/config/definitions/template-sections.ts
|
||||
- config.example.jsonc
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
|
||||
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
|
||||
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
|
||||
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
|
||||
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
|
||||
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
|
||||
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
|
||||
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
|
||||
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
|
||||
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
|
||||
- Added a transient in-overlay controller status indicator when a controller is first detected.
|
||||
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
|
||||
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
|
||||
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
|
||||
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
|
||||
- `bun run generate:config-example`
|
||||
- `bun run typecheck`
|
||||
- `bun run docs:test`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
- `bun run docs:build`
|
||||
- `bun run test:smoke:dist`
|
||||
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: workflow
|
||||
|
||||
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: install
|
||||
|
||||
- Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: config
|
||||
|
||||
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||
4
changes/yomitan-external-profile-read-only.md
Normal file
4
changes/yomitan-external-profile-read-only.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: yomitan
|
||||
|
||||
- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling.
|
||||
@@ -50,6 +50,55 @@
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// 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.
|
||||
// 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.
|
||||
"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.
|
||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||
"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.
|
||||
"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.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
// Startup Warmups
|
||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||
@@ -336,6 +385,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.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 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.0 (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.
|
||||
- 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.
|
||||
|
||||
## v0.5.6 (2026-03-10)
|
||||
- Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails.
|
||||
- Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -95,6 +95,7 @@ The configuration file includes several main sections:
|
||||
|
||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||
- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
|
||||
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||
@@ -112,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
|
||||
@@ -503,6 +505,88 @@ Set any shortcut to `null` to disable it.
|
||||
|
||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||
|
||||
### Controller Support
|
||||
|
||||
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
|
||||
|
||||
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+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||
- 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.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"controller": {
|
||||
"enabled": true,
|
||||
"preferredGamepadId": "",
|
||||
"preferredGamepadLabel": "",
|
||||
"smoothScroll": true,
|
||||
"scrollPixelsPerSecond": 900,
|
||||
"horizontalJumpPixels": 160,
|
||||
"stickDeadzone": 0.2,
|
||||
"triggerInputMode": "auto",
|
||||
"triggerDeadzone": 0.5,
|
||||
"repeatDelayMs": 320,
|
||||
"repeatIntervalMs": 120,
|
||||
"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": "none",
|
||||
"nextAudio": "rightShoulder",
|
||||
"playCurrentAudio": "leftShoulder",
|
||||
"toggleMpvPause": "leftStickPress",
|
||||
"leftStickHorizontal": "leftStickX",
|
||||
"leftStickVertical": "leftStickY",
|
||||
"rightStickHorizontal": "rightStickX",
|
||||
"rightStickVertical": "rightStickY"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default logical mapping:
|
||||
|
||||
- Left stick up/down: scroll Yomitan popup
|
||||
- Left stick left/right: move subtitle token selection
|
||||
- Right stick up/down: page-jump through Yomitan popup
|
||||
- Right stick left/right: unused by default
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `Minus` / `Select`: quit mpv
|
||||
- `L1`: play current Yomitan audio (falls back to the first available track)
|
||||
- `R1`: move to the next available Yomitan audio track
|
||||
- `L3`: toggle mpv pause
|
||||
- `L2` / `R2`: unbound by default
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||
@@ -934,6 +1018,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.
|
||||
|
||||
@@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
||||
3. Yomitan detects the selection and opens its lookup popup.
|
||||
4. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
## Creating Anki Cards
|
||||
|
||||
There are three ways to create cards, depending on your workflow.
|
||||
|
||||
@@ -50,6 +50,55 @@
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// 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.
|
||||
// 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.
|
||||
"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.
|
||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||
"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.
|
||||
"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.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
// Startup Warmups
|
||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||
@@ -336,6 +385,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.
|
||||
|
||||
@@ -69,6 +69,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
|
||||
## Controller Shortcuts
|
||||
|
||||
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
||||
|
||||
| Shortcut | Action | Configurable |
|
||||
| ------------- | ------------------------------ | ------------ |
|
||||
| `Alt+C` | Open controller selection 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.
|
||||
|
||||
## MPV Plugin Chords
|
||||
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -246,6 +246,45 @@ Notes:
|
||||
- `--whisper-threads`
|
||||
- `--yt-subgen-audio-format`
|
||||
|
||||
## Controller Support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
|
||||
### Getting Started
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Default Button Mapping
|
||||
|
||||
| Button | Action |
|
||||
| ------ | ------ |
|
||||
| `A` (South) | Toggle lookup |
|
||||
| `B` (East) | Close lookup |
|
||||
| `Y` (North) | Toggle keyboard-only mode |
|
||||
| `X` (West) | Mine card |
|
||||
| `L1` | Play current Yomitan audio |
|
||||
| `R1` | Next Yomitan audio track |
|
||||
| `L3` (left stick press) | Toggle mpv pause |
|
||||
| `Select` / `Minus` | Quit mpv |
|
||||
| `L2` / `R2` | Unbound (available for custom bindings) |
|
||||
|
||||
### Analog Controls
|
||||
|
||||
| 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 |
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
## Keybindings
|
||||
|
||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
||||
|
||||
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
|
||||
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# 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`.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,19 @@ 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 +42,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
131
scripts/subminer-change-verification.test.ts
Normal file
131
scripts/subminer-change-verification.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -1106,6 +1107,135 @@ test('parses global shortcuts and startup settings', () => {
|
||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||
});
|
||||
|
||||
test('parses controller settings with logical bindings and tuning knobs', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"enabled": true,
|
||||
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
|
||||
"preferredGamepadLabel": "Xbox Wireless Controller",
|
||||
"smoothScroll": false,
|
||||
"scrollPixelsPerSecond": 1440,
|
||||
"horizontalJumpPixels": 180,
|
||||
"stickDeadzone": 0.3,
|
||||
"triggerInputMode": "analog",
|
||||
"triggerDeadzone": 0.4,
|
||||
"repeatDelayMs": 220,
|
||||
"repeatIntervalMs": 70,
|
||||
"buttonIndices": {
|
||||
"select": 6,
|
||||
"leftStickPress": 9,
|
||||
"rightStickPress": 10
|
||||
},
|
||||
"bindings": {
|
||||
"toggleLookup": "buttonWest",
|
||||
"closeLookup": "buttonEast",
|
||||
"toggleKeyboardOnlyMode": "buttonNorth",
|
||||
"mineCard": "buttonSouth",
|
||||
"quitMpv": "select",
|
||||
"previousAudio": "leftShoulder",
|
||||
"nextAudio": "rightShoulder",
|
||||
"playCurrentAudio": "none",
|
||||
"toggleMpvPause": "leftStickPress",
|
||||
"leftStickHorizontal": "rightStickX",
|
||||
"leftStickVertical": "rightStickY",
|
||||
"rightStickHorizontal": "leftStickX",
|
||||
"rightStickVertical": "leftStickY"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.controller.enabled, true);
|
||||
assert.equal(
|
||||
config.controller.preferredGamepadId,
|
||||
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
|
||||
);
|
||||
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
|
||||
assert.equal(config.controller.smoothScroll, false);
|
||||
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
|
||||
assert.equal(config.controller.horizontalJumpPixels, 180);
|
||||
assert.equal(config.controller.stickDeadzone, 0.3);
|
||||
assert.equal(config.controller.triggerInputMode, 'analog');
|
||||
assert.equal(config.controller.triggerDeadzone, 0.4);
|
||||
assert.equal(config.controller.repeatDelayMs, 220);
|
||||
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');
|
||||
});
|
||||
|
||||
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"scrollPixelsPerSecond": 0.5,
|
||||
"horizontalJumpPixels": 0.2,
|
||||
"repeatDelayMs": 0.9,
|
||||
"repeatIntervalMs": 0.1
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
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.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);
|
||||
});
|
||||
|
||||
test('controller button index config rejects fractional values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"buttonIndices": {
|
||||
"select": 6.5,
|
||||
"leftStickPress": 9.1
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
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.leftStickPress'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
@@ -1638,6 +1768,7 @@ test('template generator includes known keys', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
assert.match(output, /"ai":/);
|
||||
assert.match(output, /"ankiConnect":/);
|
||||
assert.match(output, /"controller":/);
|
||||
assert.match(output, /"logging":/);
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"discordPresence":/);
|
||||
@@ -1662,6 +1793,14 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -25,13 +25,14 @@ const {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
subsync,
|
||||
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;
|
||||
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
ankiConnect,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
@@ -52,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
anilist,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'annotationWebsocket'
|
||||
| 'logging'
|
||||
| 'texthooker'
|
||||
| 'controller'
|
||||
| 'shortcuts'
|
||||
| 'secondarySub'
|
||||
| 'subsync'
|
||||
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
launchAtStartup: true,
|
||||
openBrowser: true,
|
||||
},
|
||||
controller: {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
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: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
copySubtitle: 'CommandOrControl+C',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -19,12 +19,15 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'annotationWebsocket.enabled',
|
||||
'controller.enabled',
|
||||
'controller.scrollPixelsPerSecond',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
@@ -38,9 +41,11 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'controller',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
const controllerButtonEnumValues = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'logging.level',
|
||||
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.logging.level,
|
||||
description: 'Minimum log level for runtime logging.',
|
||||
},
|
||||
{
|
||||
path: 'controller.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.controller.enabled,
|
||||
description: 'Enable overlay controller support through the Chrome Gamepad API.',
|
||||
},
|
||||
{
|
||||
path: 'controller.preferredGamepadId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.controller.preferredGamepadId,
|
||||
description: 'Preferred controller id saved from the controller selection modal.',
|
||||
},
|
||||
{
|
||||
path: 'controller.preferredGamepadLabel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.controller.preferredGamepadLabel,
|
||||
description: 'Preferred controller display label saved for diagnostics.',
|
||||
},
|
||||
{
|
||||
path: 'controller.smoothScroll',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.controller.smoothScroll,
|
||||
description: 'Use smooth scrolling for controller-driven popup scroll input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.scrollPixelsPerSecond',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
|
||||
description: 'Base popup scroll speed for controller stick input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.horizontalJumpPixels',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.horizontalJumpPixels,
|
||||
description: 'Popup page-jump distance for controller jump input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.stickDeadzone',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.stickDeadzone,
|
||||
description: 'Deadzone applied to controller stick axes.',
|
||||
},
|
||||
{
|
||||
path: 'controller.triggerInputMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'digital', 'analog'],
|
||||
defaultValue: defaultConfig.controller.triggerInputMode,
|
||||
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.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatDelayMs',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.repeatDelayMs,
|
||||
description: 'Delay before repeating held controller actions.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatIntervalMs',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.repeatIntervalMs,
|
||||
description: 'Repeat interval for held controller actions.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.select',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.select,
|
||||
description: 'Raw button index used for the controller select/minus/back button.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonSouth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
|
||||
description: 'Raw button index used for controller south/A button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonEast',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
|
||||
description: 'Raw button index used for controller east/B button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonNorth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
|
||||
description: 'Raw button index used for controller north/Y button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonWest',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
|
||||
description: 'Raw button index used for controller west/X button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftShoulder',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
|
||||
description: 'Raw button index used for controller left shoulder input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightShoulder',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
|
||||
description: 'Raw button index used for controller right shoulder input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftStickPress',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
|
||||
description: 'Raw button index used for controller L3 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightStickPress',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
|
||||
description: 'Raw button index used for controller R3 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftTrigger',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
|
||||
description: 'Raw button index used for controller L2 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightTrigger',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
|
||||
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: 'texthooker.launchAtStartup',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
'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.',
|
||||
],
|
||||
key: 'controller',
|
||||
},
|
||||
{
|
||||
title: 'Startup Warmups',
|
||||
description: [
|
||||
@@ -127,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: [
|
||||
|
||||
@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
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);
|
||||
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller)) {
|
||||
const enabled = asBoolean(src.controller.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.controller.enabled = enabled;
|
||||
} else if (src.controller.enabled !== undefined) {
|
||||
warn(
|
||||
'controller.enabled',
|
||||
src.controller.enabled,
|
||||
resolved.controller.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||
if (preferredGamepadId !== undefined) {
|
||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||
}
|
||||
|
||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||
if (preferredGamepadLabel !== undefined) {
|
||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||
}
|
||||
|
||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||
if (smoothScroll !== undefined) {
|
||||
resolved.controller.smoothScroll = smoothScroll;
|
||||
} else if (src.controller.smoothScroll !== undefined) {
|
||||
warn(
|
||||
'controller.smoothScroll',
|
||||
src.controller.smoothScroll,
|
||||
resolved.controller.smoothScroll,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||
if (
|
||||
triggerInputMode === 'auto' ||
|
||||
triggerInputMode === 'digital' ||
|
||||
triggerInputMode === 'analog'
|
||||
) {
|
||||
resolved.controller.triggerInputMode = triggerInputMode;
|
||||
} else if (src.controller.triggerInputMode !== undefined) {
|
||||
warn(
|
||||
'controller.triggerInputMode',
|
||||
src.controller.triggerInputMode,
|
||||
resolved.controller.triggerInputMode,
|
||||
"Expected 'auto', 'digital', or 'analog'.",
|
||||
);
|
||||
}
|
||||
|
||||
const boundedNumberKeys = [
|
||||
'scrollPixelsPerSecond',
|
||||
'horizontalJumpPixels',
|
||||
'repeatDelayMs',
|
||||
'repeatIntervalMs',
|
||||
] as const;
|
||||
for (const key of boundedNumberKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||
for (const key of deadzoneKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.buttonIndices)) {
|
||||
const buttonIndexKeys = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonIndexKeys) {
|
||||
const value = asNumber(src.controller.buttonIndices[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
resolved.controller.buttonIndices[key] = value;
|
||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||
warn(
|
||||
`controller.buttonIndices.${key}`,
|
||||
src.controller.buttonIndices[key],
|
||||
resolved.controller.buttonIndices[key],
|
||||
'Expected non-negative integer.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.bindings)) {
|
||||
const buttonBindingKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const axisBindingKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -53,6 +53,48 @@ 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',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
@@ -117,6 +159,48 @@ 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',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||
assert.ok(getPlaybackPausedHandler);
|
||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||
|
||||
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
|
||||
assert.ok(getControllerConfigHandler);
|
||||
assert.equal(
|
||||
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
|
||||
960,
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const controllerSaves: unknown[] = [];
|
||||
const closedModals: unknown[] = [];
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
@@ -207,6 +299,50 @@ 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',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerSaves: 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: () => ({
|
||||
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',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
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.saveControllerPreference);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerSaves, [
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
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: () => ({
|
||||
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',
|
||||
},
|
||||
}),
|
||||
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.saveControllerPreference);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import electron from 'electron';
|
||||
import type { IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
@@ -10,6 +12,7 @@ import type {
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
parseControllerPreferenceUpdate,
|
||||
parseOptionalForwardingOptions,
|
||||
parseOverlayHostedModal,
|
||||
parseRuntimeOptionDirection,
|
||||
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
handleMpvCommand: options.handleMpvCommand,
|
||||
getKeybindings: options.getKeybindings,
|
||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
getSecondarySubMode: options.getSecondarySubMode,
|
||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||
focusMainWindow: () => {
|
||||
@@ -256,6 +265,14 @@ 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);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
});
|
||||
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getConfiguredShortcuts();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||
return deps.getSecondarySubMode();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
39
src/core/services/overlay-window-options.ts
Normal file
39
src/core/services/overlay-window-options.ts
Normal 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}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExternalYomitanExtensionPath,
|
||||
resolveExistingYomitanExtensionPath,
|
||||
} from './yomitan-extension-paths';
|
||||
|
||||
@@ -51,3 +52,19 @@ 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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
45
src/core/services/yomitan-extension-runtime-state.test.ts
Normal file
45
src/core/services/yomitan-extension-runtime-state.test.ts
Normal 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']);
|
||||
});
|
||||
34
src/core/services/yomitan-extension-runtime-state.ts
Normal file
34
src/core/services/yomitan-extension-runtime-state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
109
src/main.ts
109
src/main.ts
@@ -23,6 +23,7 @@ import {
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
Session,
|
||||
Menu,
|
||||
nativeImage,
|
||||
Tray,
|
||||
@@ -358,7 +359,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import type { OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -375,6 +377,8 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
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,
|
||||
@@ -690,6 +694,7 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
detectPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
@@ -1326,7 +1331,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,
|
||||
};
|
||||
@@ -1346,6 +1351,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),
|
||||
@@ -1353,6 +1364,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),
|
||||
@@ -1360,6 +1377,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
});
|
||||
},
|
||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await upsertYomitanDictionarySettings(
|
||||
dictionaryTitle,
|
||||
@@ -1813,6 +1836,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,
|
||||
@@ -1836,8 +1860,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') {
|
||||
@@ -2319,6 +2344,7 @@ const {
|
||||
appState.yomitanParserWindow = null;
|
||||
appState.yomitanParserReadyPromise = null;
|
||||
appState.yomitanParserInitPromise = null;
|
||||
appState.yomitanSession = null;
|
||||
},
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
flushMpvLog: () => flushPendingMpvLogWrites(),
|
||||
@@ -2736,6 +2762,9 @@ const {
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
@@ -2779,6 +2808,7 @@ const {
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||
@@ -2812,7 +2842,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(
|
||||
@@ -2986,7 +3018,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;
|
||||
@@ -2994,7 +3026,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;
|
||||
@@ -3009,6 +3041,7 @@ function getPreferredYomitanAnkiServerUrl(): string {
|
||||
function getYomitanParserRuntimeDeps() {
|
||||
return {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||
appState.yomitanParserWindow = window;
|
||||
@@ -3025,6 +3058,10 @@ function getYomitanParserRuntimeDeps() {
|
||||
}
|
||||
|
||||
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
||||
return;
|
||||
@@ -3078,8 +3115,19 @@ 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.',
|
||||
);
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
showMpvOsd(message);
|
||||
return false;
|
||||
}
|
||||
openYomitanSettingsHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -3407,6 +3455,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||
configService.patchRawConfig({
|
||||
controller: {
|
||||
preferredGamepadId,
|
||||
preferredGamepadLabel,
|
||||
},
|
||||
});
|
||||
},
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
||||
@@ -3486,8 +3543,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(),
|
||||
@@ -3510,10 +3572,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
@@ -3574,9 +3637,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;
|
||||
@@ -3590,6 +3659,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
setYomitanExtension: (extension) => {
|
||||
appState.yomitanExt = extension;
|
||||
},
|
||||
setYomitanSession: (nextSession) => {
|
||||
appState.yomitanSession = nextSession;
|
||||
},
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getLoadInFlight: () => yomitanLoadInFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
@@ -3631,11 +3703,18 @@ 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);
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
export type { OverlayHostedModal };
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
20
src/main/runtime/character-dictionary-availability.test.ts
Normal file
20
src/main/runtime/character-dictionary-availability.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
10
src/main/runtime/character-dictionary-availability.ts
Normal file
10
src/main/runtime/character-dictionary-availability.ts
Normal 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.';
|
||||
}
|
||||
@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover' as never,
|
||||
getMpvClient: () => null,
|
||||
getAnkiConnectStatus: () => false,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,32 @@ 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 +303,7 @@ export function createFirstRunSetupService(deps: {
|
||||
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||
}),
|
||||
);
|
||||
@@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: {
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
|
||||
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||
|
||||
export function createSetOverlayVisibleHandler(deps: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeOptionState } from '../../types';
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
type RuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async
|
||||
const loadYomitanExtension = createLoadYomitanExtensionHandler({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
calls.push(`path:${options.userDataPath}`);
|
||||
calls.push(`external:${options.externalProfilePath ?? ''}`);
|
||||
assert.equal(options.getYomitanParserWindow(), parserWindow);
|
||||
options.setYomitanParserWindow(null);
|
||||
options.setYomitanParserReadyPromise(null);
|
||||
options.setYomitanParserInitPromise(null);
|
||||
options.setYomitanExtension(extension);
|
||||
options.setYomitanSession(null);
|
||||
return extension;
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
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(await loadYomitanExtension(), extension);
|
||||
assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']);
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/subminer',
|
||||
'external:/tmp/gsm-profile',
|
||||
'set-window',
|
||||
'set-ready',
|
||||
'set-init',
|
||||
'set-ext',
|
||||
'set-session',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensure yomitan loader returns existing extension when available', async () => {
|
||||
|
||||
@@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext
|
||||
export function createLoadYomitanExtensionHandler(deps: {
|
||||
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
||||
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
|
||||
externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath'];
|
||||
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
|
||||
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
|
||||
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
|
||||
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
|
||||
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
|
||||
setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession'];
|
||||
}) {
|
||||
return async (): Promise<Extension | null> => {
|
||||
return deps.loadYomitanExtensionCore({
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: deps.externalProfilePath,
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
setYomitanExtension: deps.setYomitanExtension,
|
||||
setYomitanSession: deps.setYomitanSession,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
let parserWindow: unknown = null;
|
||||
let readyPromise: Promise<void> | null = null;
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
let yomitanSession: unknown = null;
|
||||
let receivedExternalProfilePath = '';
|
||||
let loadCalls = 0;
|
||||
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
|
||||
releaseLoad: null,
|
||||
@@ -17,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
loadCalls += 1;
|
||||
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||
options.setYomitanParserWindow(null);
|
||||
options.setYomitanParserReadyPromise(Promise.resolve());
|
||||
options.setYomitanParserInitPromise(Promise.resolve(true));
|
||||
options.setYomitanSession({ id: 'session' } as never);
|
||||
return await new Promise<Extension | null>((resolve) => {
|
||||
releaseLoadState.releaseLoad = (value) => {
|
||||
options.setYomitanExtension(value);
|
||||
@@ -28,6 +32,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
});
|
||||
},
|
||||
userDataPath: '/tmp',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => {
|
||||
parserWindow = window;
|
||||
@@ -41,6 +46,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
setYomitanExtension: (next) => {
|
||||
extension = next;
|
||||
},
|
||||
setYomitanSession: (next) => {
|
||||
yomitanSession = next;
|
||||
},
|
||||
getYomitanExtension: () => extension,
|
||||
getLoadInFlight: () => inFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
@@ -55,6 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
assert.equal(parserWindow, null);
|
||||
assert.ok(readyPromise);
|
||||
assert.ok(initPromise);
|
||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||
|
||||
const fakeExtension = { id: 'yomitan' } as Extension;
|
||||
const releaseLoad = releaseLoadState.releaseLoad;
|
||||
@@ -74,18 +84,26 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
|
||||
test('yomitan extension runtime direct load delegates to core', async () => {
|
||||
let loadCalls = 0;
|
||||
let receivedExternalProfilePath = '';
|
||||
let yomitanSession: unknown = null;
|
||||
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async () => {
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
loadCalls += 1;
|
||||
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||
options.setYomitanSession({ id: 'session' } as never);
|
||||
return null;
|
||||
},
|
||||
userDataPath: '/tmp',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: () => {},
|
||||
setYomitanSession: (next) => {
|
||||
yomitanSession = next;
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getLoadInFlight: () => null,
|
||||
setLoadInFlight: () => {},
|
||||
@@ -93,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => {
|
||||
|
||||
assert.equal(await runtime.loadYomitanExtension(), null);
|
||||
assert.equal(loadCalls, 1);
|
||||
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||
});
|
||||
|
||||
@@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
|
||||
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
||||
loadYomitanExtensionCore: deps.loadYomitanExtensionCore,
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: deps.externalProfilePath,
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
setYomitanExtension: deps.setYomitanExtension,
|
||||
setYomitanSession: deps.setYomitanSession,
|
||||
});
|
||||
const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler(
|
||||
buildLoadYomitanExtensionMainDepsHandler(),
|
||||
|
||||
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal file
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createYomitanProfilePolicy } from './yomitan-profile-policy';
|
||||
|
||||
test('yomitan profile policy trims external profile path and marks read-only mode', () => {
|
||||
const calls: string[] = [];
|
||||
const policy = createYomitanProfilePolicy({
|
||||
externalProfilePath: ' /tmp/gsm-profile ',
|
||||
logInfo: (message) => calls.push(message),
|
||||
});
|
||||
|
||||
assert.equal(policy.externalProfilePath, '/tmp/gsm-profile');
|
||||
assert.equal(policy.isExternalReadOnlyMode(), true);
|
||||
assert.equal(policy.isCharacterDictionaryEnabled(), false);
|
||||
assert.equal(
|
||||
policy.getCharacterDictionaryDisabledReason(),
|
||||
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
|
||||
);
|
||||
|
||||
policy.logSkippedWrite('importYomitanDictionary(sample.zip)');
|
||||
assert.deepEqual(calls, [
|
||||
'[yomitan] skipping importYomitanDictionary(sample.zip): yomitan.externalProfilePath is configured; external profile mode is read-only',
|
||||
]);
|
||||
});
|
||||
|
||||
test('yomitan profile policy keeps character dictionary enabled without external profile path', () => {
|
||||
const policy = createYomitanProfilePolicy({
|
||||
externalProfilePath: ' ',
|
||||
logInfo: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(policy.externalProfilePath, '');
|
||||
assert.equal(policy.isExternalReadOnlyMode(), false);
|
||||
assert.equal(policy.isCharacterDictionaryEnabled(), true);
|
||||
assert.equal(policy.getCharacterDictionaryDisabledReason(), null);
|
||||
});
|
||||
25
src/main/runtime/yomitan-profile-policy.ts
Normal file
25
src/main/runtime/yomitan-profile-policy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
getCharacterDictionaryDisabledReason,
|
||||
isCharacterDictionaryRuntimeEnabled,
|
||||
} from './character-dictionary-availability';
|
||||
|
||||
export function createYomitanProfilePolicy(options: {
|
||||
externalProfilePath: string;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
const externalProfilePath = options.externalProfilePath.trim();
|
||||
|
||||
return {
|
||||
externalProfilePath,
|
||||
isExternalReadOnlyMode: (): boolean => externalProfilePath.length > 0,
|
||||
isCharacterDictionaryEnabled: (): boolean =>
|
||||
isCharacterDictionaryRuntimeEnabled(externalProfilePath),
|
||||
getCharacterDictionaryDisabledReason: (): string | null =>
|
||||
getCharacterDictionaryDisabledReason(externalProfilePath),
|
||||
logSkippedWrite: (action: string): void => {
|
||||
options.logInfo(
|
||||
`[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal file
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { formatSkippedYomitanWriteAction } from './yomitan-read-only-log';
|
||||
|
||||
test('formatSkippedYomitanWriteAction redacts full filesystem paths to basenames', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('importYomitanDictionary', '/tmp/private/merged.zip'),
|
||||
'importYomitanDictionary(merged.zip)',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatSkippedYomitanWriteAction redacts dictionary titles', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('deleteYomitanDictionary', 'SubMiner Character Dictionary'),
|
||||
'deleteYomitanDictionary(<redacted>)',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatSkippedYomitanWriteAction falls back when value is blank', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '),
|
||||
'upsertYomitanDictionarySettings(<redacted>)',
|
||||
);
|
||||
});
|
||||
25
src/main/runtime/yomitan-read-only-log.ts
Normal file
25
src/main/runtime/yomitan-read-only-log.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as path from 'path';
|
||||
|
||||
function redactSkippedYomitanWriteValue(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return '<redacted>';
|
||||
}
|
||||
|
||||
if (actionName === 'importYomitanDictionary') {
|
||||
const basename = path.basename(trimmed);
|
||||
return basename || '<redacted>';
|
||||
}
|
||||
|
||||
return '<redacted>';
|
||||
}
|
||||
|
||||
export function formatSkippedYomitanWriteAction(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||
}
|
||||
@@ -22,14 +22,16 @@ test('yomitan opener warns when extension cannot be loaded', async () => {
|
||||
});
|
||||
|
||||
test('yomitan opener opens settings window when extension is available', async () => {
|
||||
let opened = false;
|
||||
let forwardedSession: { id: string } | null | undefined;
|
||||
const yomitanSession = { id: 'session' };
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
opened = true;
|
||||
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
|
||||
forwardedSession = nextSession as { id: string } | null;
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
@@ -37,5 +39,5 @@ test('yomitan opener opens settings window when extension is available', async (
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(opened, true);
|
||||
assert.equal(forwardedSession, yomitanSession);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
type YomitanExtensionLike = unknown;
|
||||
type BrowserWindowLike = unknown;
|
||||
type SessionLike = unknown;
|
||||
|
||||
export function createOpenYomitanSettingsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||
@@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
yomitanExt: YomitanExtensionLike;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
yomitanSession?: SessionLike | null;
|
||||
onWindowClosed?: () => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
getYomitanSession?: () => SessionLike | null;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
@@ -21,10 +24,16 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
if (!yomitanSession) {
|
||||
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
|
||||
return;
|
||||
}
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
setWindow: deps.setWindow,
|
||||
yomitanSession,
|
||||
});
|
||||
})().catch((error) => {
|
||||
deps.logError('Failed to open Yomitan settings window.', error);
|
||||
|
||||
@@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime';
|
||||
test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
let existingWindow: { id: string } | null = null;
|
||||
const calls: string[] = [];
|
||||
const yomitanSession = { id: 'session' };
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => {
|
||||
calls.push('open-window');
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||
const current = getExistingWindow();
|
||||
if (!current) {
|
||||
setWindow({ id: 'settings' });
|
||||
@@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
setWindow: (window) => {
|
||||
existingWindow = window as { id: string } | null;
|
||||
},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
@@ -27,5 +29,30 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(existingWindow, { id: 'settings' });
|
||||
assert.deepEqual(calls, ['open-window']);
|
||||
assert.deepEqual(calls, ['open-window:session']);
|
||||
});
|
||||
|
||||
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
|
||||
let existingWindow: { id: string } | null = null;
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
calls.push('open-window');
|
||||
},
|
||||
getExistingWindow: () => existingWindow as never,
|
||||
setWindow: (window) => {
|
||||
existingWindow = window as { id: string } | null;
|
||||
},
|
||||
getYomitanSession: () => null,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
runtime.openYomitanSettings();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(existingWindow, null);
|
||||
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
|
||||
import type {
|
||||
Keybinding,
|
||||
@@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState(
|
||||
|
||||
export interface AppState {
|
||||
yomitanExt: Extension | null;
|
||||
yomitanSession: Session | null;
|
||||
yomitanSettingsWindow: BrowserWindow | null;
|
||||
yomitanParserWindow: BrowserWindow | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
@@ -219,6 +220,7 @@ export interface StartupState {
|
||||
export function createAppState(values: AppStateInitialValues): AppState {
|
||||
return {
|
||||
yomitanExt: null,
|
||||
yomitanSession: null,
|
||||
yomitanSettingsWindow: null,
|
||||
yomitanParserWindow: null,
|
||||
anilistSetupWindow: null,
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
||||
|
||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||
},
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
|
||||
107
src/renderer/controller-status-indicator.test.ts
Normal file
107
src/renderer/controller-status-indicator.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
|
||||
let nextTimerId = 1;
|
||||
const scheduled = new Map<number, () => void>();
|
||||
const classList = createClassList(['hidden']);
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList,
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
durationMs: 1500,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, callback);
|
||||
return id as never;
|
||||
},
|
||||
clearTimeout: (id) => {
|
||||
scheduled.delete(id as never as number);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /controller detected/i);
|
||||
assert.match(toast.textContent, /pad-1/i);
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
const [hide] = scheduled.values();
|
||||
hide?.();
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
});
|
||||
|
||||
test('controller status indicator announces newly detected controllers after startup', () => {
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList: createClassList(['hidden']),
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
toast.classList.add('hidden');
|
||||
toast.textContent = '';
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(toast.classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /pad-2/i);
|
||||
});
|
||||
69
src/renderer/controller-status-indicator.ts
Normal file
69
src/renderer/controller-status-indicator.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ControllerDeviceInfo } from '../types';
|
||||
|
||||
type ControllerSnapshot = {
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
};
|
||||
|
||||
type ControllerStatusIndicatorOptions = {
|
||||
durationMs?: number;
|
||||
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
|
||||
};
|
||||
|
||||
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
|
||||
if (!device) return 'Controller';
|
||||
return device.id || `Gamepad ${device.index}`;
|
||||
}
|
||||
|
||||
export function createControllerStatusIndicator(
|
||||
dom: {
|
||||
controllerStatusToast: {
|
||||
textContent: string;
|
||||
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
|
||||
};
|
||||
},
|
||||
options: ControllerStatusIndicatorOptions = {},
|
||||
) {
|
||||
const durationMs = options.durationMs ?? 2200;
|
||||
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
|
||||
const cancelTimeout =
|
||||
options.clearTimeout ??
|
||||
((timer: ReturnType<typeof setTimeout> | number) =>
|
||||
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
|
||||
let previousConnectedIds = new Set<string>();
|
||||
|
||||
function show(message: string): void {
|
||||
if (hideTimeout !== null) {
|
||||
cancelTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
dom.controllerStatusToast.textContent = message;
|
||||
dom.controllerStatusToast.classList.remove('hidden');
|
||||
hideTimeout = scheduleTimeout(() => {
|
||||
dom.controllerStatusToast.classList.add('hidden');
|
||||
dom.controllerStatusToast.textContent = '';
|
||||
hideTimeout = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
function update(snapshot: ControllerSnapshot): void {
|
||||
const newDevices = snapshot.connectedGamepads.filter(
|
||||
(device) => !previousConnectedIds.has(device.id),
|
||||
);
|
||||
if (newDevices.length > 0) {
|
||||
const activeDevice = snapshot.connectedGamepads.find(
|
||||
(device) => device.id === snapshot.activeGamepadId,
|
||||
);
|
||||
const announcedDevice =
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||
}
|
||||
|
||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import { createGamepadController } from './gamepad-controller.js';
|
||||
|
||||
type TestGamepad = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: number[];
|
||||
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||
};
|
||||
|
||||
function createGamepad(
|
||||
id: string,
|
||||
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
||||
): TestGamepad {
|
||||
return {
|
||||
id,
|
||||
index: options.index ?? 0,
|
||||
connected: true,
|
||||
mapping: 'standard',
|
||||
axes: options.axes ?? [0, 0, 0, 0],
|
||||
buttons:
|
||||
options.buttons ??
|
||||
Array.from({ length: 16 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createControllerConfig(
|
||||
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
||||
bindings?: Partial<ResolvedControllerConfig['bindings']>;
|
||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||
} = {},
|
||||
): ResolvedControllerConfig {
|
||||
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||
overrides;
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
...(buttonIndexOverrides ?? {}),
|
||||
},
|
||||
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',
|
||||
...(bindingOverrides ?? {}),
|
||||
},
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('gamepad controller selects the first connected controller by default', () => {
|
||||
const updates: string[] = [];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: (state) => {
|
||||
updates.push(state.activeGamepadId ?? 'none');
|
||||
},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
assert.deepEqual(updates.at(-1), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller prefers saved controller id when connected', () => {
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
|
||||
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
||||
});
|
||||
|
||||
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ enabled: false }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
||||
const calls: string[] = [];
|
||||
const selectionCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
let keyboardModeEnabled = true;
|
||||
let interactionBlocked = true;
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => interactionBlocked,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
interactionBlocked = false;
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(selectionCalls, []);
|
||||
|
||||
buttons[0] = { value: 0, pressed: false, touched: false };
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(200);
|
||||
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
axes = [0.9, 0, 0, 0];
|
||||
controller.poll(300);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-lookup']);
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
||||
const calls: number[] = [];
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => calls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
controller.poll(260);
|
||||
|
||||
assert.deepEqual(calls, [1]);
|
||||
|
||||
controller.poll(340);
|
||||
|
||||
assert.deepEqual(calls, [1, 1]);
|
||||
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(360);
|
||||
axes = [-0.9, 0, 0, 0];
|
||||
controller.poll(380);
|
||||
|
||||
assert.deepEqual(calls, [1, 1, -1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||
const calls: string[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[8] = { value: 1, pressed: true, touched: true };
|
||||
buttons[4] = { value: 1, pressed: true, touched: true };
|
||||
buttons[5] = { value: 1, pressed: true, touched: true };
|
||||
buttons[6] = { value: 0.8, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () =>
|
||||
[
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
previousAudio: 'none',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => calls.push('prev-audio'),
|
||||
nextAudio: () => calls.push('next-audio'),
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.equal(calls.includes('next-audio'), true);
|
||||
assert.equal(calls.includes('play-audio'), true);
|
||||
assert.equal(calls.includes('prev-audio'), false);
|
||||
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
||||
assert.equal(calls.includes('quit-mpv'), true);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
|
||||
assert.equal(calls.includes('jump:160'), true);
|
||||
});
|
||||
|
||||
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller honors configured raw button index overrides', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[11] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
buttonIndices: {
|
||||
select: 11,
|
||||
},
|
||||
bindings: { quitMpv: 'select' },
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
|
||||
const calls: string[] = [];
|
||||
let axes = [0, 0, 0.85, 0, 0];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
axes = [0, 0, 0.85, 0, -0.85];
|
||||
controller.poll(200);
|
||||
|
||||
assert.deepEqual(calls, ['jump:-160']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[15] = { value: 1, pressed: false, touched: true };
|
||||
buttons[12] = { value: 1, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.7, pressed: false, touched: true };
|
||||
buttons[7] = { value: 0.8, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'analog',
|
||||
triggerDeadzone: 0.6,
|
||||
bindings: {
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger digital mode uses pressed state only', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.9, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'digital',
|
||||
triggerDeadzone: 1,
|
||||
bindings: {
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[9] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
playCurrentAudio: 'none',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-mpv-pause']);
|
||||
});
|
||||
571
src/renderer/handlers/gamepad-controller.ts
Normal file
571
src/renderer/handlers/gamepad-controller.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerButtonBinding,
|
||||
ControllerDeviceInfo,
|
||||
ControllerRuntimeSnapshot,
|
||||
ControllerTriggerInputMode,
|
||||
ResolvedControllerConfig,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
pressed?: boolean;
|
||||
touched?: boolean;
|
||||
};
|
||||
|
||||
type GamepadLike = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
type GamepadControllerOptions = {
|
||||
getGamepads: () => Array<GamepadLike | null>;
|
||||
getConfig: () => ResolvedControllerConfig;
|
||||
getKeyboardModeEnabled: () => boolean;
|
||||
getLookupWindowOpen: () => boolean;
|
||||
getInteractionBlocked: () => boolean;
|
||||
toggleKeyboardMode: () => void;
|
||||
toggleLookup: () => void;
|
||||
closeLookup: () => void;
|
||||
moveSelection: (delta: -1 | 1) => void;
|
||||
mineCard: () => void;
|
||||
quitMpv: () => void;
|
||||
previousAudio: () => void;
|
||||
nextAudio: () => void;
|
||||
playCurrentAudio: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
onState: (state: ControllerRuntimeSnapshot) => void;
|
||||
};
|
||||
|
||||
type HoldState = {
|
||||
repeatStarted: boolean;
|
||||
direction: -1 | 1 | null;
|
||||
lastFireAt: number;
|
||||
initialFired: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
|
||||
select: 8,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
};
|
||||
|
||||
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const DPAD_BUTTON_INDEX = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
|
||||
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
|
||||
return binding === 'leftTrigger' || binding === 'rightTrigger';
|
||||
}
|
||||
|
||||
function resolveButtonIndex(
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
): number {
|
||||
if (binding === 'none') {
|
||||
return -1;
|
||||
}
|
||||
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
|
||||
}
|
||||
|
||||
function normalizeButtonState(
|
||||
gamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
triggerInputMode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (binding === 'none') {
|
||||
return false;
|
||||
}
|
||||
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
|
||||
if (isTriggerBinding(binding)) {
|
||||
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
|
||||
}
|
||||
return normalizeRawButtonState(button, triggerDeadzone);
|
||||
}
|
||||
|
||||
function normalizeRawButtonState(
|
||||
button: ControllerButtonState | undefined,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function normalizeTriggerState(
|
||||
button: ControllerButtonState | undefined,
|
||||
mode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
if (mode === 'digital') {
|
||||
return Boolean(button.pressed);
|
||||
}
|
||||
if (mode === 'analog') {
|
||||
return button.value >= triggerDeadzone;
|
||||
}
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
|
||||
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
|
||||
}
|
||||
|
||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||
const value = gamepad.axes[axisIndex];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function resolveDpadValue(
|
||||
gamepad: GamepadLike,
|
||||
negativeIndex: number,
|
||||
positiveIndex: number,
|
||||
triggerDeadzone: number,
|
||||
): number {
|
||||
const negative = gamepad.buttons[negativeIndex];
|
||||
const positive = gamepad.buttons[positiveIndex];
|
||||
return (
|
||||
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
||||
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadAxisValue(
|
||||
gamepad: GamepadLike,
|
||||
axisIndex: number,
|
||||
): number {
|
||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||
if (Math.abs(value) < 0.5) {
|
||||
return 0;
|
||||
}
|
||||
return Math.sign(value);
|
||||
}
|
||||
|
||||
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
||||
return gamepads
|
||||
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
||||
.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
function createHoldState(): HoldState {
|
||||
return {
|
||||
repeatStarted: false,
|
||||
direction: null,
|
||||
lastFireAt: 0,
|
||||
initialFired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||
if (!state.initialFired) {
|
||||
state.initialFired = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
const elapsed = now - state.lastFireAt;
|
||||
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
||||
if (elapsed < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.repeatStarted = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetHeldAction(state: HoldState): void {
|
||||
state.repeatStarted = false;
|
||||
state.direction = null;
|
||||
state.lastFireAt = 0;
|
||||
state.initialFired = false;
|
||||
}
|
||||
|
||||
function syncHeldActionBlocked(
|
||||
state: HoldState,
|
||||
value: number,
|
||||
now: number,
|
||||
activationThreshold: number,
|
||||
): void {
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
state.repeatStarted = false;
|
||||
state.direction = direction;
|
||||
state.lastFireAt = now;
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
let lastPollAt: number | null = null;
|
||||
|
||||
function getConnectedGamepads(): GamepadLike[] {
|
||||
return resolveConnectedGamepads(options.getGamepads());
|
||||
}
|
||||
|
||||
function resolveActiveGamepad(
|
||||
gamepads: GamepadLike[],
|
||||
config: ResolvedControllerConfig,
|
||||
): GamepadLike | null {
|
||||
if (gamepads.length === 0) return null;
|
||||
if (config.preferredGamepadId.trim().length > 0) {
|
||||
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
return gamepads[0] ?? null;
|
||||
}
|
||||
|
||||
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
||||
activeGamepadId = activeGamepad?.id ?? null;
|
||||
options.onState({
|
||||
connectedGamepads: gamepads.map((gamepad) => ({
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
rawButtons: activeGamepad?.buttons
|
||||
? activeGamepad.buttons.map((button) => ({
|
||||
value: button.value,
|
||||
pressed: Boolean(button.pressed),
|
||||
touched: button.touched,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonEdge(
|
||||
binding: ControllerButtonBinding,
|
||||
isPressed: boolean,
|
||||
action: () => void,
|
||||
): void {
|
||||
if (binding === 'none') {
|
||||
return;
|
||||
}
|
||||
const wasPressed = previousButtons.get(binding) ?? false;
|
||||
previousButtons.set(binding, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(selectionHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (selectionHold.direction !== direction) {
|
||||
resetHeldAction(selectionHold);
|
||||
selectionHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.moveSelection(direction);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(jumpHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (jumpHold.direction !== direction) {
|
||||
resetHeldAction(jumpHold);
|
||||
jumpHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.jumpPopup(direction * config.horizontalJumpPixels);
|
||||
}
|
||||
}
|
||||
|
||||
function syncBlockedInteractionState(
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
now: number,
|
||||
): void {
|
||||
const buttonBindings = new Set<ControllerButtonBinding>([
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.bindings.toggleLookup,
|
||||
config.bindings.closeLookup,
|
||||
config.bindings.mineCard,
|
||||
config.bindings.quitMpv,
|
||||
config.bindings.previousAudio,
|
||||
config.bindings.nextAudio,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.bindings.toggleMpvPause,
|
||||
]);
|
||||
|
||||
for (const binding of buttonBindings) {
|
||||
if (binding === 'none') continue;
|
||||
previousButtons.set(
|
||||
binding,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
binding,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const selectionValue = (() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})();
|
||||
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
Math.max(config.stickDeadzone, 0.55),
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
}
|
||||
|
||||
function poll(now: number): void {
|
||||
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
||||
lastPollAt = now;
|
||||
const config = options.getConfig();
|
||||
const connectedGamepads = getConnectedGamepads();
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
|
||||
if (!activeGamepad) {
|
||||
previousButtons = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionAllowed =
|
||||
config.enabled &&
|
||||
options.getKeyboardModeEnabled() &&
|
||||
!options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleKeyboardMode,
|
||||
);
|
||||
}
|
||||
if (!interactionAllowed) {
|
||||
syncBlockedInteractionState(activeGamepad, config, now);
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.closeLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.closeLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.closeLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.mineCard,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.mineCard,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.mineCard,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.quitMpv,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.quitMpv,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.quitMpv,
|
||||
);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleButtonEdge(
|
||||
config.bindings.previousAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.previousAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.previousAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.nextAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.nextAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.nextAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.playCurrentAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.playCurrentAudio,
|
||||
);
|
||||
|
||||
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
|
||||
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
|
||||
if (elapsedMs > 0) {
|
||||
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
if (dpadVertical !== 0) {
|
||||
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleMpvPause,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleMpvPause,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
(() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})(),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
poll,
|
||||
getActiveGamepadId: (): string | null => activeGamepadId,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
import {
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
type?: string;
|
||||
@@ -11,6 +14,9 @@ type CommandEventDetail = {
|
||||
key?: string;
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
direction?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
};
|
||||
|
||||
function createClassList() {
|
||||
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
|
||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
|
||||
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
|
||||
};
|
||||
|
||||
const selection = {
|
||||
removeAllRanges: () => {},
|
||||
addRange: () => {},
|
||||
removeAllRanges: () => {
|
||||
selectionClearCount += 1;
|
||||
},
|
||||
addRange: () => {
|
||||
selectionAddCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: () => {},
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
windowListeners.set(type, listeners);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||
commandEvents.push(detail ?? {});
|
||||
}
|
||||
const listeners = windowListeners.get(event.type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWindowEvent(type: string): void {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(new Event(type));
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
|
||||
windowFocusCalls: () => windowFocusCalls,
|
||||
dispatchKeydown,
|
||||
dispatchFocusInOnPopup,
|
||||
dispatchWindowEvent,
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
},
|
||||
selectionClearCount: () => selectionClearCount,
|
||||
selectionAddCount: () => selectionAddCount,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handleControllerDebugKeydown: () => false,
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectOpenCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugOpenCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
assert.equal(handlers.playCurrentAudioForController(), true);
|
||||
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
||||
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.slice(-3),
|
||||
[
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(controllerSelectKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||
assert.equal(testGlobals.selectionAddCount() > 0, true);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.closeLookupWindow();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
assert.equal(testGlobals.selectionClearCount() > 0, true);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(-1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(3);
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleSubtitleContentUpdated();
|
||||
setWordCount(4);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
|
||||
}) => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'cycleAudioSource',
|
||||
direction,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupPlayCurrentAudio() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'playCurrentAudio',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'scrollBy',
|
||||
deltaX,
|
||||
deltaY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendScanSelectedText() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendClearActiveTextSource() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'clearActiveTextSource',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey || e.metaKey;
|
||||
}
|
||||
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function getSubtitleWordNodes(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||
);
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||
for (const wordNode of wordNodes) {
|
||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNativeSubtitleSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
clearKeyboardSelectedWordClasses(wordNodes);
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
ctx.state.keyboardSelectionVisible = false;
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
}
|
||||
|
||||
const selectedIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
);
|
||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (selectedWordNode) {
|
||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
const nextIndex = currentIndex + delta;
|
||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
return 'moved';
|
||||
}
|
||||
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (!selectedWordNode) return false;
|
||||
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
selectWordNodeText(selectedWordNode);
|
||||
|
||||
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
|
||||
toggleKeyboardDrivenMode();
|
||||
}
|
||||
|
||||
function handleSubtitleContentUpdated(): void {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
return;
|
||||
}
|
||||
resetSelectionToStartOnNextSubtitleSync = true;
|
||||
}
|
||||
|
||||
function handleLookupWindowToggleRequested(): void {
|
||||
if (ctx.state.yomitanPopupVisible) {
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
closeLookupWindow();
|
||||
return;
|
||||
}
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
function closeLookupWindow(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveSelectionForController(delta: -1 | 1): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const result = moveKeyboardSelection(delta);
|
||||
if (result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result === 'start-boundary' || result === 'end-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function forwardPopupKeydownForController(
|
||||
key: string,
|
||||
code: string,
|
||||
repeat: boolean = true,
|
||||
): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mineSelectedFromController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupMineSelected();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupCycleAudioSource(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
function playCurrentAudioForController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupPlayCurrentAudio();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreOverlayKeyboardFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
window.focus();
|
||||
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
|
||||
const key = e.code;
|
||||
if (key === 'ArrowLeft') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
clearNativeSubtitleSelection();
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
syncKeyboardTokenSelection();
|
||||
return;
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerSelectModalOpen) {
|
||||
options.handleControllerSelectKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
options.handleControllerDebugKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.sessionHelpModalOpen) {
|
||||
options.handleSessionHelpKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControllerModalShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
options.openControllerDebugModal();
|
||||
} else {
|
||||
options.openControllerSelectModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
|
||||
setupMpvInputForwarding,
|
||||
updateKeybindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
handleKeyboardModeToggleRequested,
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
moveSelectionForController,
|
||||
forwardPopupKeydownForController,
|
||||
mineSelectedFromController,
|
||||
cyclePopupAudioSourceForController,
|
||||
playCurrentAudioForController,
|
||||
scrollPopupByController,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<body>
|
||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||
<div id="overlay" tabindex="-1">
|
||||
<div
|
||||
id="controllerStatusToast"
|
||||
class="controller-status-toast hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="overlayErrorToast"
|
||||
class="overlay-error-toast hidden"
|
||||
@@ -192,6 +198,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Selection</div>
|
||||
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerSelectHint" class="runtime-options-hint">
|
||||
Arrow keys: select controller · Enter: save · Esc: close
|
||||
</div>
|
||||
<ul id="controllerSelectList" class="runtime-options-list"></ul>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
<div class="subsync-footer">
|
||||
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
||||
Save Controller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content controller-debug-content">
|
||||
<div
|
||||
id="controllerDebugToast"
|
||||
class="controller-debug-toast hidden"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Debug</div>
|
||||
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerDebugStatus" class="runtime-options-status"></div>
|
||||
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
|
||||
<div class="controller-debug-grid">
|
||||
<div>
|
||||
<div class="jimaku-section-title">Axes</div>
|
||||
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="jimaku-section-title">Buttons</div>
|
||||
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div class="controller-debug-span">
|
||||
<div class="controller-debug-section-header">
|
||||
<div class="jimaku-section-title">Config</div>
|
||||
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content session-help-content">
|
||||
<div class="modal-header">
|
||||
|
||||
237
src/renderer/modals/controller-debug.test.ts
Normal file
237
src/renderer/modals/controller-debug.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerDebugModal } from './controller-debug.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
state.controllerRawAxes = [0.5, -0.25];
|
||||
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
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: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerDebugModal();
|
||||
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
||||
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
||||
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
||||
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller debug modal copies buttonIndices config to clipboard', async () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
window?: unknown;
|
||||
navigator?: unknown;
|
||||
};
|
||||
const previousWindow = globals.window;
|
||||
const previousNavigator = globals.navigator;
|
||||
const copied: string[] = [];
|
||||
const handlers: { copy: null | (() => void) } = { copy: null };
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: async (text: string) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
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: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugCopy: {
|
||||
addEventListener: (_event: string, handler: () => void) => {
|
||||
handlers.copy = handler;
|
||||
},
|
||||
},
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerDebugModal();
|
||||
if (handlers.copy) {
|
||||
handlers.copy();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
|
||||
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
|
||||
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
192
src/renderer/modals/controller-debug.ts
Normal file
192
src/renderer/modals/controller-debug.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function formatAxes(values: number[]): string {
|
||||
if (values.length === 0) return 'No controller axes available.';
|
||||
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
|
||||
}
|
||||
|
||||
function formatButtons(
|
||||
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
|
||||
): string {
|
||||
if (values.length === 0) return 'No controller buttons available.';
|
||||
return values
|
||||
.map(
|
||||
(button, index) =>
|
||||
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatButtonIndices(
|
||||
value:
|
||||
| {
|
||||
select: number;
|
||||
buttonSouth: number;
|
||||
buttonEast: number;
|
||||
buttonNorth: number;
|
||||
buttonWest: number;
|
||||
leftShoulder: number;
|
||||
rightShoulder: number;
|
||||
leftStickPress: number;
|
||||
rightStickPress: number;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
}
|
||||
| null,
|
||||
): string {
|
||||
if (!value) {
|
||||
return 'No controller config loaded.';
|
||||
}
|
||||
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API unavailable.');
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function createControllerDebugModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function setStatus(message: string, isError: boolean = false): void {
|
||||
ctx.dom.controllerDebugStatus.textContent = message;
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugStatus.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugStatus.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearToastTimer(): void {
|
||||
if (toastTimer === null) return;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = null;
|
||||
}
|
||||
|
||||
function hideToast(): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.classList.add('hidden');
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
|
||||
function showToast(message: string, isError: boolean = false): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.textContent = message;
|
||||
ctx.dom.controllerDebugToast.classList.remove('hidden');
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugToast.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
toastTimer = setTimeout(() => {
|
||||
hideToast();
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const activeDevice = ctx.state.connectedGamepads.find(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
);
|
||||
setStatus(
|
||||
activeDevice?.id ??
|
||||
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||
);
|
||||
ctx.dom.controllerDebugSummary.textContent =
|
||||
ctx.state.connectedGamepads.length > 0
|
||||
? ctx.state.connectedGamepads
|
||||
.map((device) => {
|
||||
const tags = [
|
||||
`#${device.index}`,
|
||||
device.mapping,
|
||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||
].filter(Boolean);
|
||||
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
|
||||
})
|
||||
.join('\n')
|
||||
: 'Connect a controller and press any button to populate raw input values.';
|
||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async function copyButtonIndicesToClipboard(): Promise<void> {
|
||||
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
|
||||
if (text.length === 0 || text === 'No controller config loaded.') {
|
||||
setStatus('No buttonIndices config available to copy.', true);
|
||||
showToast('No buttonIndices config available to copy.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeTextToClipboard(text);
|
||||
setStatus('Copied controller buttonIndices config.');
|
||||
showToast('Copied controller buttonIndices config.');
|
||||
} catch {
|
||||
setStatus('Failed to copy controller buttonIndices config.', true);
|
||||
showToast('Failed to copy controller buttonIndices config.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerDebugModal(): void {
|
||||
ctx.state.controllerDebugModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerDebugModal.classList.remove('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||
hideToast();
|
||||
render();
|
||||
}
|
||||
|
||||
function closeControllerDebugModal(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
ctx.state.controllerDebugModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerDebugModal.classList.add('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
|
||||
hideToast();
|
||||
window.electronAPI.notifyOverlayModalClosed('controller-debug');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeControllerDebugModal();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSnapshot(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
render();
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.controllerDebugClose.addEventListener('click', () => {
|
||||
closeControllerDebugModal();
|
||||
});
|
||||
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
|
||||
void copyButtonIndicesToClipboard();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openControllerDebugModal,
|
||||
closeControllerDebugModal,
|
||||
handleControllerDebugKeydown,
|
||||
updateSnapshot,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user