Compare commits

..

6 Commits

Author SHA1 Message Date
9eed37420e Automate AUR publish in tagged release workflow (#22) 2026-03-14 19:49:46 -07:00
99f4d2baaf Harden subminer-scrum-master pre-handoff policy checks (#20) 2026-03-13 01:40:48 -07:00
f4e8c3feec chore: make verifier script executable 2026-03-13 00:44:00 -07:00
d0b308f340 test: harden agent verification workflow 2026-03-13 00:44:00 -07:00
1b56360a24 feat(yomitan): add read-only external profile support for shared dictionaries (#18) 2026-03-12 01:17:34 -07:00
68833c76c4 Release v0.6.1 and scope workflow write permissions
- Bump package version and changelog to v0.6.1
- Move `contents: write` permission to release job only
- Remove consumed changelog fragment files
2026-03-11 20:53:52 -07:00
104 changed files with 2995 additions and 843 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

13
.gitignore vendored
View File

@@ -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

View File

@@ -1,6 +1,15 @@
# Changelog
## v0.6.0 (2026-03-12)
## 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.
@@ -8,8 +17,12 @@
- 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)

View File

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

View File

@@ -0,0 +1,4 @@
type: internal
area: workflow
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.

View File

@@ -0,0 +1,4 @@
type: internal
area: release
- Automate `subminer-bin` AUR package updates from the tagged release workflow.

View File

@@ -1,4 +0,0 @@
type: docs
area: install
- Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.

View File

@@ -1,4 +0,0 @@
type: internal
area: config
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently

View File

@@ -1,7 +0,0 @@
type: added
area: 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.
- 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 stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.

View 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.

View File

@@ -385,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.

View File

@@ -1,5 +1,12 @@
# 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.

View File

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

View File

@@ -113,6 +113,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
@@ -1017,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.

View File

@@ -385,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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.6.0",
"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",

View File

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

View File

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

View File

@@ -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 });
}
});

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

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
EOF
}
pkg_dir=
version=
appimage=
wrapper=
assets=
while [[ $# -gt 0 ]]; do
case "$1" in
--pkg-dir)
pkg_dir="${2:-}"
shift 2
;;
--version)
version="${2:-}"
shift 2
;;
--appimage)
appimage="${2:-}"
shift 2
;;
--wrapper)
wrapper="${2:-}"
shift 2
;;
--assets)
assets="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
usage >&2
exit 1
fi
version="${version#v}"
pkgbuild="${pkg_dir}/PKGBUILD"
if [[ ! -f "$pkgbuild" ]]; then
echo "Missing PKGBUILD at $pkgbuild" >&2
exit 1
fi
for artifact in "$appimage" "$wrapper" "$assets"; do
if [[ ! -f "$artifact" ]]; then
echo "Missing artifact: $artifact" >&2
exit 1
fi
done
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
tmpfile="$(mktemp)"
awk \
-v version="$version" \
-v sum_appimage="${sha256sums[0]}" \
-v sum_wrapper="${sha256sums[1]}" \
-v sum_assets="${sha256sums[2]}" \
'
BEGIN {
in_sha_block = 0
found_pkgver = 0
found_sha_block = 0
}
/^pkgver=/ {
print "pkgver=" version
found_pkgver = 1
next
}
/^sha256sums=\(/ {
print "sha256sums=("
print "\047" sum_appimage "\047"
print "\047" sum_wrapper "\047"
print "\047" sum_assets "\047"
in_sha_block = 1
next
}
in_sha_block {
if ($0 ~ /^\)/) {
print ")"
in_sha_block = 0
found_sha_block = 1
}
next
}
{
print
}
END {
if (!found_pkgver) {
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
exit 1
}
if (!found_sha_block) {
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
exit 1
}
}
' "$pkgbuild" > "$tmpfile"
mv "$tmpfile" "$pkgbuild"
(
cd "$pkg_dir"
makepkg --printsrcinfo > .SRCINFO
)

View File

@@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
assert.equal(config.yomitan.externalProfilePath, '');
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
@@ -1194,14 +1195,32 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(
config.controller.scrollPixelsPerSecond,
DEFAULT_CONFIG.controller.scrollPixelsPerSecond,
);
assert.equal(
config.controller.horizontalJumpPixels,
DEFAULT_CONFIG.controller.horizontalJumpPixels,
);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.repeatDelayMs'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'),
true,
);
});
test('controller button index config rejects fractional values', () => {
@@ -1223,12 +1242,18 @@ test('controller button index config rejects fractional values', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
assert.equal(
config.controller.buttonIndices.select,
DEFAULT_CONFIG.controller.buttonIndices.select,
);
assert.equal(
config.controller.buttonIndices.leftStickPress,
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.select'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
true,

View File

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

View File

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

View File

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

View File

@@ -74,13 +74,15 @@ export function buildCoreConfigOptionRegistry(
kind: 'enum',
enumValues: ['auto', 'digital', 'analog'],
defaultValue: defaultConfig.controller.triggerInputMode,
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
description:
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
},
{
path: 'controller.triggerDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.triggerDeadzone,
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
description:
'Minimum analog trigger value required when trigger input uses auto or analog mode.',
},
{
path: 'controller.repeatDelayMs',

View File

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

View File

@@ -137,6 +137,16 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'anilist',
},
{
title: 'Yomitan',
description: [
'Optional external Yomitan profile integration.',
'Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.',
'For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.',
'In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.',
],
key: 'yomitan',
},
{
title: 'Jellyfin',
description: [

View File

@@ -17,7 +17,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'leftTrigger',
'rightTrigger',
] as const;
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
const controllerAxisBindings = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
@@ -178,7 +183,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
@@ -188,7 +198,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}

View File

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

View File

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

View File

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

View File

@@ -467,16 +467,16 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
assert.ok(saveHandler);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
await saveHandler!(
{},
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
/Invalid controller preference payload/,
);
await saveHandler!({}, {
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
});
assert.deepEqual(controllerSaves, [
{
@@ -570,10 +570,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
});

View File

@@ -265,13 +265,16 @@ 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.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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
shell,
protocol,
Extension,
Session,
Menu,
nativeImage,
Tray,
@@ -376,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,
@@ -691,6 +694,8 @@ const firstRunSetupService = createFirstRunSetupService({
});
return dictionaries.length;
},
isExternalYomitanConfigured: () =>
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
@@ -1327,7 +1332,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
getConfig: () => {
const config = getResolvedConfig().anilist.characterDictionary;
return {
enabled: config.enabled,
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
maxLoaded: config.maxLoaded,
profileScope: config.profileScope,
};
@@ -1347,6 +1352,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
importYomitanDictionary: async (zipPath) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
@@ -1354,6 +1365,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
deleteYomitanDictionary: async (dictionaryTitle) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
@@ -1361,6 +1378,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
});
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded();
return await upsertYomitanDictionarySettings(
dictionaryTitle,
@@ -1814,6 +1837,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -1837,8 +1861,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') {
@@ -2320,6 +2345,7 @@ const {
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
appState.yomitanSession = null;
},
getWindowTracker: () => appState.windowTracker,
flushMpvLog: () => flushPendingMpvLogWrites(),
@@ -2737,6 +2763,9 @@ const {
);
},
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
@@ -2780,6 +2809,7 @@ const {
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
@@ -2813,7 +2843,9 @@ const {
'subtitle.annotation.jlpt',
getResolvedConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled,
getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption(
@@ -2987,7 +3019,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
async function loadYomitanExtension(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.loadYomitanExtension();
if (extension) {
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
@@ -2995,7 +3027,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
if (extension) {
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
@@ -3010,6 +3042,7 @@ function getPreferredYomitanAnkiServerUrl(): string {
function getYomitanParserRuntimeDeps() {
return {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
appState.yomitanParserWindow = window;
@@ -3026,6 +3059,10 @@ function getYomitanParserRuntimeDeps() {
}
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
return;
}
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
return;
@@ -3079,8 +3116,18 @@ 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 {
@@ -3496,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(),
@@ -3522,6 +3574,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
@@ -3584,9 +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;
@@ -3600,6 +3659,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
setYomitanExtension: (extension) => {
appState.yomitanExt = extension;
},
setYomitanSession: (nextSession) => {
appState.yomitanSession = nextSession;
},
getYomitanExtension: () => appState.yomitanExt,
getLoadInFlight: () => yomitanLoadInFlight,
setLoadInFlight: (promise) => {
@@ -3641,11 +3703,13 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => {
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
openYomitanSettingsWindow({
yomitanExt: yomitanExt as Extension,
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
setWindow: (window) => setWindow(window as BrowserWindow | null),
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
onWindowClosed: () => {
if (appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -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,
});
};
}

View File

@@ -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' });
});

View File

@@ -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(),

View 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);
});

View 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`,
);
},
};
}

View 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>)',
);
});

View File

@@ -0,0 +1,31 @@
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)})`;
}

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -5,11 +5,16 @@ 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 +24,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 +33,32 @@ 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.',
]);
});

View File

@@ -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,

View File

@@ -67,6 +67,25 @@ test('windows release workflow publishes unsigned artifacts directly without Sig
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
});
test('release workflow publishes subminer-bin to AUR from tagged release artifacts', () => {
assert.match(releaseWorkflow, /aur-publish:/);
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
assert.match(releaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.match(releaseWorkflow, /ssh:\/\/aur@aur\.archlinux\.org\/subminer-bin\.git/);
assert.match(releaseWorkflow, /Install makepkg/);
assert.match(releaseWorkflow, /scripts\/update-aur-package\.sh/);
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/);
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
assert.doesNotMatch(
releaseWorkflow,
/SubMiner-\$\{\{ steps\.version\.outputs\.VERSION \}\}\.AppImage/,
);
});
test('release workflow skips empty AUR sync commits', () => {
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
});
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
assert.match(
makefile,

View File

@@ -25,20 +25,17 @@ test('controller status indicator shows once when a controller is first detected
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);
},
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: [],
@@ -78,13 +75,10 @@ test('controller status indicator announces newly detected controllers after sta
classList: createClassList(['hidden']),
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
setTimeout: () => 1 as never,
clearTimeout: () => {},
},
);
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
setTimeout: () => 1 as never,
clearTimeout: () => {},
});
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],

View File

@@ -58,7 +58,9 @@ export function createControllerStatusIndicator(
(device) => device.id === snapshot.activeGamepadId,
);
const announcedDevice =
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
newDevices.find((device) => device.id === snapshot.activeGamepadId) ??
newDevices[0] ??
activeDevice;
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
}

View File

@@ -39,8 +39,11 @@ function createControllerConfig(
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
} = {},
): ResolvedControllerConfig {
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
overrides;
const {
bindings: bindingOverrides,
buttonIndices: buttonIndexOverrides,
...restOverrides
} = overrides;
return {
enabled: true,
preferredGamepadId: '',
@@ -90,7 +93,11 @@ function createControllerConfig(
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 })],
getGamepads: () => [
null,
createGamepad('pad-2', { index: 1 }),
createGamepad('pad-3', { index: 2 }),
],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
@@ -310,13 +317,12 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
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,
}),
],
getGamepads: () => [
createGamepad('pad-1', {
axes: [0, -0.75, 0.1, 0, 0.8],
buttons,
}),
],
getConfig: () =>
createControllerConfig({
bindings: {
@@ -352,7 +358,10 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
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.deepEqual(
scrollCalls.map((value) => Math.round(value)),
[-67],
);
assert.equal(calls.includes('jump:160'), true);
});
@@ -492,7 +501,10 @@ test('gamepad controller maps d-pad left/right to selection and d-pad up/down to
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
assert.deepEqual(
scrollCalls.map((value) => Math.round(value)),
[-90],
);
});
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
@@ -524,7 +536,10 @@ test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll',
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
assert.deepEqual(
scrollCalls.map((value) => Math.round(value)),
[-90],
);
});
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {

View File

@@ -159,10 +159,7 @@ function resolveDpadValue(
);
}
function resolveDpadAxisValue(
gamepad: GamepadLike,
axisIndex: number,
): number {
function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
const value = resolveGamepadAxis(gamepad, axisIndex);
if (Math.abs(value) < 0.5) {
return 0;
@@ -175,7 +172,12 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
if (axisValue !== 0) {
return axisValue;
}
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
return resolveDpadValue(
gamepad,
DPAD_BUTTON_INDEX.left,
DPAD_BUTTON_INDEX.right,
triggerDeadzone,
);
}
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
@@ -201,7 +203,12 @@ function createHoldState(): HoldState {
};
}
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
function shouldFireHeldAction(
state: HoldState,
now: number,
repeatDelayMs: number,
repeatIntervalMs: number,
): boolean {
if (!state.initialFired) {
state.initialFired = true;
state.lastFireAt = now;
@@ -305,11 +312,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
}
function handleSelectionAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(selectionHold);
@@ -327,11 +330,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
}
function handleJumpAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(jumpHold);
@@ -418,9 +417,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
const interactionAllowed =
config.enabled &&
options.getKeyboardModeEnabled() &&
!options.getInteractionBlocked();
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (config.enabled) {
handleButtonEdge(
config.bindings.toggleKeyboardOnlyMode,

View File

@@ -3,10 +3,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import {
YOMITAN_POPUP_COMMAND_EVENT,
YOMITAN_POPUP_HIDDEN_EVENT,
} from '../yomitan-popup.js';
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
type CommandEventDetail = {
type?: string;
@@ -478,14 +475,11 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
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 },
],
);
assert.deepEqual(testGlobals.commandEvents.slice(-3), [
{ type: 'playCurrentAudio' },
{ type: 'cycleAudioSource', direction: 1 },
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
]);
} finally {
testGlobals.restore();
}
@@ -531,7 +525,8 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();

View File

@@ -187,7 +187,9 @@ export function createKeyboardHandlers(
);
}
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
function clearKeyboardSelectedWordClasses(
wordNodes: HTMLElement[] = getSubtitleWordNodes(),
): void {
for (const wordNode of wordNodes) {
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
}

View File

@@ -106,7 +106,10 @@ test('controller debug modal renders active controller axes, buttons, and config
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.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/);
@@ -224,8 +227,14 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
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.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 });

View File

@@ -18,21 +18,19 @@ function formatButtons(
}
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,
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.';
@@ -97,7 +95,9 @@ export function createControllerDebugModal(
);
setStatus(
activeDevice?.id ??
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
(ctx.state.connectedGamepads.length > 0
? 'Controller connected.'
: 'No controller detected.'),
);
ctx.dom.controllerDebugSummary.textContent =
ctx.state.connectedGamepads.length > 0

View File

@@ -45,7 +45,9 @@ export function createControllerSelectModal(
syncSelectedControllerId();
return;
}
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
const preferredIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === preferredId,
);
if (preferredIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
syncSelectedControllerId();

View File

@@ -63,8 +63,7 @@ body {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(138, 213, 202, 0.45);
background:
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
color: rgba(228, 255, 251, 0.98);
font-size: 12px;
font-weight: 700;

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