mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
5 Commits
v0.6.0
...
99f4d2baaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f4d2baaf | |||
|
f4e8c3feec
|
|||
|
d0b308f340
|
|||
| 1b56360a24 | |||
|
68833c76c4
|
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
name: "subminer-change-verification"
|
||||||
|
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
|
||||||
|
---
|
||||||
|
|
||||||
|
# SubMiner Change Verification
|
||||||
|
|
||||||
|
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `scripts/classify_subminer_diff.sh`
|
||||||
|
- Emits suggested lanes and flags from explicit paths or current git changes.
|
||||||
|
- `scripts/verify_subminer_change.sh`
|
||||||
|
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
||||||
|
|
||||||
|
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default workflow
|
||||||
|
|
||||||
|
1. Inspect the changed files or user-requested area.
|
||||||
|
2. Run the classifier unless you already know the right lane.
|
||||||
|
3. Run the verifier with the cheapest sufficient lane set.
|
||||||
|
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
||||||
|
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
||||||
|
6. Return:
|
||||||
|
- verification summary
|
||||||
|
- exact commands run
|
||||||
|
- artifact paths
|
||||||
|
- skipped lanes and blockers
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Repo-source quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed-skill quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Classify explicit files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
||||||
|
launcher/main.ts \
|
||||||
|
plugin/subminer/lifecycle.lua \
|
||||||
|
src/main/runtime/mpv-client-runtime-service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Run automatic lane selection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed-skill form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run targeted lanes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||||
|
--lane launcher-plugin \
|
||||||
|
--lane runtime-compat
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run to inspect planned commands and artifact layout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||||
|
--dry-run \
|
||||||
|
launcher/main.ts \
|
||||||
|
src/main.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lane guidance
|
||||||
|
|
||||||
|
- `docs`
|
||||||
|
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||||
|
- `config`
|
||||||
|
- For `src/config/` and config-template-sensitive edits.
|
||||||
|
- `core`
|
||||||
|
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||||
|
- `launcher-plugin`
|
||||||
|
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||||
|
- `runtime-compat`
|
||||||
|
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
||||||
|
- `real-runtime`
|
||||||
|
- Only after deliberate escalation.
|
||||||
|
|
||||||
|
## Real Runtime Escalation
|
||||||
|
|
||||||
|
Escalate only when the change claim depends on actual runtime behavior, for example:
|
||||||
|
|
||||||
|
- overlay appears, hides, or tracks a real mpv window
|
||||||
|
- mpv launch flags or pause-until-ready behavior
|
||||||
|
- plugin/socket/auto-start handshake under a real player
|
||||||
|
- macOS/window-tracker/focus-sensitive behavior
|
||||||
|
|
||||||
|
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
||||||
|
|
||||||
|
## Artifact contract
|
||||||
|
|
||||||
|
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
||||||
|
|
||||||
|
- `summary.json`
|
||||||
|
- `summary.txt`
|
||||||
|
- `classification.txt`
|
||||||
|
- `env.txt`
|
||||||
|
- `lanes.txt`
|
||||||
|
- `steps.tsv`
|
||||||
|
- `steps/*.stdout.log`
|
||||||
|
- `steps/*.stderr.log`
|
||||||
|
|
||||||
|
On failure, quote the exact failing command and point at the artifact directory.
|
||||||
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: classify_subminer_diff.sh [path ...]
|
||||||
|
|
||||||
|
Emit suggested verification lanes for explicit paths or current local git changes.
|
||||||
|
|
||||||
|
Output format:
|
||||||
|
lane:<name>
|
||||||
|
flag:<name>
|
||||||
|
reason:<text>
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
has_item() {
|
||||||
|
local needle=$1
|
||||||
|
shift || true
|
||||||
|
local item
|
||||||
|
for item in "$@"; do
|
||||||
|
if [[ "$item" == "$needle" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
add_lane() {
|
||||||
|
local lane=$1
|
||||||
|
if ! has_item "$lane" "${LANES[@]:-}"; then
|
||||||
|
LANES+=("$lane")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_flag() {
|
||||||
|
local flag=$1
|
||||||
|
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
||||||
|
FLAGS+=("$flag")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_reason() {
|
||||||
|
REASONS+=("$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_git_paths() {
|
||||||
|
local top_level
|
||||||
|
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$top_level"
|
||||||
|
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||||
|
git diff --name-only --relative HEAD --
|
||||||
|
git diff --name-only --relative --cached --
|
||||||
|
else
|
||||||
|
git diff --name-only --relative --
|
||||||
|
git diff --name-only --relative --cached --
|
||||||
|
fi
|
||||||
|
git ls-files --others --exclude-standard
|
||||||
|
) | awk 'NF' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -a PATHS=()
|
||||||
|
declare -a LANES=()
|
||||||
|
declare -a FLAGS=()
|
||||||
|
declare -a REASONS=()
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
PATHS+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
else
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] && PATHS+=("$line")
|
||||||
|
done < <(collect_git_paths)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "no changed paths detected -> default to core"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "${PATHS[@]}"; do
|
||||||
|
specialized=0
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
docs-site/*|docs/*|changes/*|README.md)
|
||||||
|
add_lane "docs"
|
||||||
|
add_reason "$path -> docs"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
||||||
|
add_lane "config"
|
||||||
|
add_reason "$path -> config"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||||
|
add_lane "launcher-plugin"
|
||||||
|
add_reason "$path -> launcher-plugin"
|
||||||
|
add_flag "real-runtime-candidate"
|
||||||
|
add_reason "$path -> real-runtime-candidate"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
||||||
|
add_lane "runtime-compat"
|
||||||
|
add_reason "$path -> runtime-compat"
|
||||||
|
add_flag "real-runtime-candidate"
|
||||||
|
add_reason "$path -> real-runtime-candidate"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$specialized" == "0" ]]; then
|
||||||
|
case "$path" in
|
||||||
|
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "$path -> core"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
||||||
|
add_flag "broad-impact"
|
||||||
|
add_reason "$path -> broad-impact"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#LANES[@]} -eq 0 ]]; then
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "no lane-specific matches -> default to core"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for lane in "${LANES[@]}"; do
|
||||||
|
printf 'lane:%s\n' "$lane"
|
||||||
|
done
|
||||||
|
|
||||||
|
for flag in "${FLAGS[@]}"; do
|
||||||
|
printf 'flag:%s\n' "$flag"
|
||||||
|
done
|
||||||
|
|
||||||
|
for reason in "${REASONS[@]}"; do
|
||||||
|
printf 'reason:%s\n' "$reason"
|
||||||
|
done
|
||||||
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
@@ -0,0 +1,566 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: verify_subminer_change.sh [options] [path ...]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--lane <name> Force a verification lane. Repeatable.
|
||||||
|
--artifact-dir <dir> Use an explicit artifact directory.
|
||||||
|
--allow-real-runtime Allow explicit real-runtime execution.
|
||||||
|
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
||||||
|
--dry-run Record planned steps without executing commands.
|
||||||
|
--help Show this help text.
|
||||||
|
|
||||||
|
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
||||||
|
provided, it classifies the current local git changes.
|
||||||
|
|
||||||
|
Authoritative real-runtime verification should be requested with explicit path
|
||||||
|
arguments instead of relying on inferred local git changes.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp() {
|
||||||
|
date +%Y%m%d-%H%M%S
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp_iso() {
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_session_id() {
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
||||||
|
basename "$tmp_dir"
|
||||||
|
rmdir "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
has_item() {
|
||||||
|
local needle=$1
|
||||||
|
shift || true
|
||||||
|
local item
|
||||||
|
for item in "$@"; do
|
||||||
|
if [[ "$item" == "$needle" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_lane_name() {
|
||||||
|
case "$1" in
|
||||||
|
real-gui)
|
||||||
|
printf '%s' "real-runtime"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s' "$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
add_lane() {
|
||||||
|
local lane
|
||||||
|
lane=$(normalize_lane_name "$1")
|
||||||
|
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
||||||
|
SELECTED_LANES+=("$lane")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_blocker() {
|
||||||
|
BLOCKERS+=("$1")
|
||||||
|
BLOCKED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
append_step_record() {
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_env() {
|
||||||
|
{
|
||||||
|
printf 'repo_root=%s\n' "$REPO_ROOT"
|
||||||
|
printf 'session_id=%s\n' "$SESSION_ID"
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
||||||
|
printf 'dry_run=%s\n' "$DRY_RUN"
|
||||||
|
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
||||||
|
printf 'session_home=%s\n' "$SESSION_HOME"
|
||||||
|
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
||||||
|
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
||||||
|
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
||||||
|
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
||||||
|
printf 'pwd=%s\n' "$(pwd)"
|
||||||
|
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
||||||
|
git status --short 2>/dev/null || true
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
printf 'requested_paths=\n'
|
||||||
|
printf ' %s\n' "${PATH_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
} >"$ARTIFACT_DIR/env.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
local lane=$1
|
||||||
|
local name=$2
|
||||||
|
local command=$3
|
||||||
|
local note=${4:-}
|
||||||
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
|
local stdout_rel="steps/${slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${slug}.stderr.log"
|
||||||
|
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||||
|
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||||
|
local status exit_code
|
||||||
|
|
||||||
|
COMMANDS_RUN+=("$command")
|
||||||
|
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "1" ]]; then
|
||||||
|
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||||
|
: >"$stderr_path"
|
||||||
|
status="dry-run"
|
||||||
|
exit_code=0
|
||||||
|
else
|
||||||
|
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||||
|
status="passed"
|
||||||
|
exit_code=0
|
||||||
|
EXECUTED_REAL_STEPS=1
|
||||||
|
else
|
||||||
|
exit_code=$?
|
||||||
|
status="failed"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
||||||
|
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||||
|
|
||||||
|
if [[ "$status" == "failed" ]]; then
|
||||||
|
FAILURE_STEP="$name"
|
||||||
|
FAILURE_COMMAND="$command"
|
||||||
|
FAILURE_STDOUT="$stdout_rel"
|
||||||
|
FAILURE_STDERR="$stderr_rel"
|
||||||
|
return "$exit_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
record_nonpassing_step() {
|
||||||
|
local lane=$1
|
||||||
|
local name=$2
|
||||||
|
local status=$3
|
||||||
|
local note=$4
|
||||||
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
|
local stdout_rel="steps/${slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${slug}.stderr.log"
|
||||||
|
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||||
|
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||||
|
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||||
|
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_skipped_step() {
|
||||||
|
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_blocked_step() {
|
||||||
|
add_blocker "$3"
|
||||||
|
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_failed_step() {
|
||||||
|
FAILED=1
|
||||||
|
FAILURE_STEP=$2
|
||||||
|
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||||
|
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
||||||
|
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
||||||
|
add_blocker "$3"
|
||||||
|
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
find_real_runtime_helper() {
|
||||||
|
local candidate
|
||||||
|
for candidate in \
|
||||||
|
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
||||||
|
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
||||||
|
if [[ -x "$candidate" ]]; then
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire_real_runtime_lease() {
|
||||||
|
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
||||||
|
local lease_dir="$lease_root/exclusive-real-runtime"
|
||||||
|
mkdir -p "$lease_root"
|
||||||
|
if mkdir "$lease_dir" 2>/dev/null; then
|
||||||
|
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
||||||
|
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local owner=""
|
||||||
|
if [[ -f "$lease_dir/session_id" ]]; then
|
||||||
|
owner=$(cat "$lease_dir/session_id")
|
||||||
|
fi
|
||||||
|
add_blocker "real-runtime lease already held${owner:+ by $owner}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
release_real_runtime_lease() {
|
||||||
|
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
||||||
|
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
||||||
|
local owner
|
||||||
|
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
||||||
|
if [[ "$owner" != "$SESSION_ID" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compute_final_status() {
|
||||||
|
if [[ "$FAILED" == "1" ]]; then
|
||||||
|
FINAL_STATUS="failed"
|
||||||
|
elif [[ "$BLOCKED" == "1" ]]; then
|
||||||
|
FINAL_STATUS="blocked"
|
||||||
|
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
||||||
|
FINAL_STATUS="passed"
|
||||||
|
else
|
||||||
|
FINAL_STATUS="skipped"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_summary_files() {
|
||||||
|
local lane_lines
|
||||||
|
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||||
|
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||||
|
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||||
|
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||||
|
|
||||||
|
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||||
|
SESSION_ID_ENV="$SESSION_ID" \
|
||||||
|
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
||||||
|
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
||||||
|
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
||||||
|
SESSION_HOME_ENV="$SESSION_HOME" \
|
||||||
|
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
||||||
|
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
||||||
|
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
||||||
|
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
||||||
|
STARTED_AT_ENV="$STARTED_AT" \
|
||||||
|
FINISHED_AT_ENV="$FINISHED_AT" \
|
||||||
|
FAILED_ENV="$FAILED" \
|
||||||
|
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
||||||
|
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
||||||
|
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
||||||
|
bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function readLines(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return [];
|
||||||
|
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactDir = process.env.ARTIFACT_DIR_ENV;
|
||||||
|
const reportsDir = path.join(artifactDir, "reports");
|
||||||
|
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
|
||||||
|
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
|
||||||
|
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
|
||||||
|
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
|
||||||
|
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
||||||
|
return {
|
||||||
|
lane,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
exitCode: Number(exitCode || 0),
|
||||||
|
command,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const summary = {
|
||||||
|
sessionId: process.env.SESSION_ID_ENV || "",
|
||||||
|
artifactDir,
|
||||||
|
reportsDir,
|
||||||
|
status: process.env.FINAL_STATUS_ENV || "failed",
|
||||||
|
selectedLanes: lanes,
|
||||||
|
failed: process.env.FAILED_ENV === "1",
|
||||||
|
failure:
|
||||||
|
process.env.FAILED_ENV === "1"
|
||||||
|
? {
|
||||||
|
command: process.env.FAILURE_COMMAND_ENV || "",
|
||||||
|
stdout: process.env.FAILURE_STDOUT_ENV || "",
|
||||||
|
stderr: process.env.FAILURE_STDERR_ENV || "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
blockers,
|
||||||
|
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
|
||||||
|
requestedPaths,
|
||||||
|
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
||||||
|
startedAt: process.env.STARTED_AT_ENV || "",
|
||||||
|
finishedAt: process.env.FINISHED_AT_ENV || "",
|
||||||
|
env: {
|
||||||
|
home: process.env.SESSION_HOME_ENV || "",
|
||||||
|
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
|
||||||
|
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
|
||||||
|
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
|
||||||
|
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
|
||||||
|
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
|
||||||
|
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`session_id: ${summary.sessionId}`);
|
||||||
|
lines.push(`artifact_dir: ${artifactDir}`);
|
||||||
|
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
|
||||||
|
lines.push(`status: ${summary.status}`);
|
||||||
|
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
|
||||||
|
if (requestedPaths.length > 0) {
|
||||||
|
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
lines.push(`blockers: ${blockers.join(" | ")}`);
|
||||||
|
}
|
||||||
|
for (const step of steps) {
|
||||||
|
lines.push(`${step.lane}/${step.name}: ${step.status}`);
|
||||||
|
if (step.command) lines.push(` command: ${step.command}`);
|
||||||
|
lines.push(` stdout: ${step.stdout}`);
|
||||||
|
lines.push(` stderr: ${step.stderr}`);
|
||||||
|
if (step.note) lines.push(` note: ${step.note}`);
|
||||||
|
}
|
||||||
|
if (summary.failed) {
|
||||||
|
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
|
||||||
|
}
|
||||||
|
const summaryText = lines.join("\n") + "\n";
|
||||||
|
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
|
||||||
|
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
release_real_runtime_lease
|
||||||
|
}
|
||||||
|
|
||||||
|
CLASSIFIER_OUTPUT=""
|
||||||
|
ARTIFACT_DIR=""
|
||||||
|
ALLOW_REAL_RUNTIME=0
|
||||||
|
DRY_RUN=0
|
||||||
|
FAILED=0
|
||||||
|
BLOCKED=0
|
||||||
|
EXECUTED_REAL_STEPS=0
|
||||||
|
FINAL_STATUS=""
|
||||||
|
FAILURE_STEP=""
|
||||||
|
FAILURE_COMMAND=""
|
||||||
|
FAILURE_STDOUT=""
|
||||||
|
FAILURE_STDERR=""
|
||||||
|
REAL_RUNTIME_LEASE_DIR=""
|
||||||
|
STARTED_AT=""
|
||||||
|
FINISHED_AT=""
|
||||||
|
|
||||||
|
declare -a EXPLICIT_LANES=()
|
||||||
|
declare -a SELECTED_LANES=()
|
||||||
|
declare -a PATH_ARGS=()
|
||||||
|
declare -a COMMANDS_RUN=()
|
||||||
|
declare -a BLOCKERS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--lane)
|
||||||
|
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--artifact-dir)
|
||||||
|
ARTIFACT_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--allow-real-runtime|--allow-real-gui)
|
||||||
|
ALLOW_REAL_RUNTIME=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
PATH_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PATH_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||||
|
SESSION_ID=$(generate_session_id)
|
||||||
|
PATH_SELECTION_MODE="git-inferred"
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
PATH_SELECTION_MODE="explicit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$ARTIFACT_DIR" ]]; then
|
||||||
|
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
|
||||||
|
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SESSION_HOME="$ARTIFACT_DIR/home"
|
||||||
|
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
|
||||||
|
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
|
||||||
|
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
|
||||||
|
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
||||||
|
|
||||||
|
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
||||||
|
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
||||||
|
: >"$STEPS_TSV"
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
STARTED_AT=$(timestamp_iso)
|
||||||
|
|
||||||
|
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
|
||||||
|
local_lane=""
|
||||||
|
for local_lane in "${EXPLICIT_LANES[@]}"; do
|
||||||
|
add_lane "$local_lane"
|
||||||
|
done
|
||||||
|
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
|
||||||
|
else
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
|
||||||
|
else
|
||||||
|
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
lane:*)
|
||||||
|
add_lane "${line#lane:}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<<"$CLASSIFIER_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
record_env
|
||||||
|
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
|
||||||
|
|
||||||
|
for lane in "${SELECTED_LANES[@]}"; do
|
||||||
|
case "$lane" in
|
||||||
|
docs)
|
||||||
|
run_step "$lane" "docs-test" "bun run docs:test" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "docs-build" "bun run docs:build" || break
|
||||||
|
;;
|
||||||
|
config)
|
||||||
|
run_step "$lane" "test-config" "bun run test:config" || break
|
||||||
|
;;
|
||||||
|
core)
|
||||||
|
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-fast" "bun run test:fast" || break
|
||||||
|
;;
|
||||||
|
launcher-plugin)
|
||||||
|
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
||||||
|
;;
|
||||||
|
runtime-compat)
|
||||||
|
run_step "$lane" "build" "bun run build" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
|
||||||
|
;;
|
||||||
|
real-runtime)
|
||||||
|
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-guard" \
|
||||||
|
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-guard" \
|
||||||
|
"real-runtime lane requested but --allow-real-runtime was not supplied"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! acquire_real_runtime_lease; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-lease" \
|
||||||
|
"real-runtime lease already held; rerun after the active runtime verification finishes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-helper" \
|
||||||
|
"real-runtime helper not implemented yet"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -v REAL_RUNTIME_COMMAND \
|
||||||
|
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
|
||||||
|
"$SESSION_ID" \
|
||||||
|
"$SESSION_HOME" \
|
||||||
|
"$SESSION_XDG_CONFIG_HOME" \
|
||||||
|
"$SESSION_MPV_LOG" \
|
||||||
|
"$REAL_RUNTIME_HELPER"
|
||||||
|
|
||||||
|
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
FINISHED_AT=$(timestamp_iso)
|
||||||
|
compute_final_status
|
||||||
|
write_summary_files
|
||||||
|
|
||||||
|
printf 'status=%s\n' "$FINAL_STATUS"
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
|
||||||
|
case "$FINAL_STATUS" in
|
||||||
|
failed)
|
||||||
|
printf 'result=failed\n'
|
||||||
|
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
blocked)
|
||||||
|
printf 'result=blocked\n'
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'result=ok\n'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
name: "subminer-scrum-master"
|
||||||
|
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
|
||||||
|
---
|
||||||
|
|
||||||
|
# SubMiner Scrum Master
|
||||||
|
|
||||||
|
Own workflow, not code by default.
|
||||||
|
|
||||||
|
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
1. Decide first whether backlog tracking is warranted.
|
||||||
|
2. If backlog is needed, search first. Update existing work when it clearly matches.
|
||||||
|
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
|
||||||
|
4. Record a plan before dispatching coding work.
|
||||||
|
5. Use parent + subtasks for multi-part work when backlog is used.
|
||||||
|
6. Dispatch conservatively. Parallelize only disjoint write scopes.
|
||||||
|
7. Require verification before handoff, typically via `subminer-change-verification`.
|
||||||
|
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
|
||||||
|
|
||||||
|
## Backlog Decision
|
||||||
|
|
||||||
|
Skip backlog when the request is:
|
||||||
|
- question only
|
||||||
|
- obvious mechanical edit
|
||||||
|
- tiny isolated change with no real planning
|
||||||
|
|
||||||
|
Use backlog when the work:
|
||||||
|
- needs planning or scope decisions
|
||||||
|
- spans multiple phases or subsystems
|
||||||
|
- is likely to need subagent dispatch
|
||||||
|
- should remain traceable for handoff/resume
|
||||||
|
|
||||||
|
If backlog is used:
|
||||||
|
- search existing tasks first
|
||||||
|
- create/update a standalone task for one focused deliverable
|
||||||
|
- create/update a parent task plus subtasks for multi-part work
|
||||||
|
- record the implementation plan in the task before implementation begins
|
||||||
|
|
||||||
|
## Intake Workflow
|
||||||
|
|
||||||
|
1. Parse the request.
|
||||||
|
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
||||||
|
2. Decide whether backlog is needed.
|
||||||
|
3. If backlog is needed:
|
||||||
|
- search first
|
||||||
|
- update existing task if clearly relevant
|
||||||
|
- otherwise create the right structure
|
||||||
|
- write the implementation plan before dispatch
|
||||||
|
4. If backlog is skipped:
|
||||||
|
- write a short working plan in-thread
|
||||||
|
- proceed without fake ticketing
|
||||||
|
5. Choose execution mode:
|
||||||
|
- no subagents for trivial work
|
||||||
|
- one worker for focused work
|
||||||
|
- parallel workers only for disjoint scopes
|
||||||
|
6. Run verification before handoff.
|
||||||
|
|
||||||
|
## Dispatch Rules
|
||||||
|
|
||||||
|
The scrum master orchestrates. Workers implement.
|
||||||
|
|
||||||
|
- Do not become the default implementer unless delegation is unnecessary.
|
||||||
|
- Do not parallelize overlapping files or tightly coupled runtime work.
|
||||||
|
- Give every worker explicit ownership of files/modules.
|
||||||
|
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
||||||
|
- Require each worker to report:
|
||||||
|
- changed files
|
||||||
|
- tests run
|
||||||
|
- blockers
|
||||||
|
|
||||||
|
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Every nontrivial code task gets verification.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
1. use `subminer-change-verification`
|
||||||
|
2. start with the cheapest sufficient lane
|
||||||
|
3. escalate only when needed
|
||||||
|
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
||||||
|
|
||||||
|
Never hand off nontrivial work without stating what was verified and what was skipped.
|
||||||
|
|
||||||
|
## Pre-Handoff Policy Checks (Required)
|
||||||
|
|
||||||
|
Before handoff, always ask and answer both of these questions explicitly:
|
||||||
|
|
||||||
|
1. **Docs update required?**
|
||||||
|
2. **Changelog fragment required?**
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
|
||||||
|
- If the answer is yes, either complete the update or report the blocker before handoff.
|
||||||
|
- Include the final answers in the handoff summary even when both answers are "no."
|
||||||
|
|
||||||
|
## Failure / Scope Handling
|
||||||
|
|
||||||
|
- If a worker hits ambiguity, pause and ask the user.
|
||||||
|
- If verification fails, either:
|
||||||
|
- send the worker back with exact failure context, or
|
||||||
|
- fix it directly if it is tiny and clearly in scope
|
||||||
|
- If new scope appears, revisit backlog structure before silently expanding work.
|
||||||
|
|
||||||
|
## Representative Flows
|
||||||
|
|
||||||
|
### Trivial no-ticket work
|
||||||
|
|
||||||
|
- decide backlog is unnecessary
|
||||||
|
- keep a short plan
|
||||||
|
- implement directly or with one worker if helpful
|
||||||
|
- run targeted verification
|
||||||
|
- report outcome concisely
|
||||||
|
|
||||||
|
### Single-task implementation
|
||||||
|
|
||||||
|
- search/create/update one task
|
||||||
|
- record plan
|
||||||
|
- dispatch one worker
|
||||||
|
- integrate
|
||||||
|
- verify
|
||||||
|
- update task and report outcome
|
||||||
|
|
||||||
|
### Parent + subtasks execution
|
||||||
|
|
||||||
|
- search/create/update parent task
|
||||||
|
- create subtasks for distinct deliverables/phases
|
||||||
|
- record sequencing in the plan
|
||||||
|
- dispatch workers only where scopes are disjoint
|
||||||
|
- integrate
|
||||||
|
- run consolidated verification
|
||||||
|
- update task state and report outcome
|
||||||
|
|
||||||
|
## Output Expectations
|
||||||
|
|
||||||
|
At the end, report:
|
||||||
|
- whether backlog was used and what changed
|
||||||
|
- which workers were dispatched and what they owned
|
||||||
|
- what verification ran
|
||||||
|
- explicit answers to:
|
||||||
|
- docs update required?
|
||||||
|
- changelog fragment required?
|
||||||
|
- blockers, skips, and risks
|
||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -9,9 +9,6 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -244,6 +241,8 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: [build-linux, build-macos, build-windows]
|
needs: [build-linux, build-macos, build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -35,6 +35,19 @@ docs/.vitepress/cache/
|
|||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
tests/*
|
tests/*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.tmp/
|
||||||
.codex/*
|
.codex/*
|
||||||
.agents/*
|
.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
|
favicon.png
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,6 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
### Added
|
||||||
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
- Overlay: Added 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: 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.
|
- 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
|
### Internal
|
||||||
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
- 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)
|
## v0.5.6 (2026-03-10)
|
||||||
|
|
||||||
|
|||||||
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: workflow
|
||||||
|
|
||||||
|
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: install
|
|
||||||
|
|
||||||
- Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
|
||||||
@@ -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.
|
|
||||||
4
changes/yomitan-external-profile-read-only.md
Normal file
4
changes/yomitan-external-profile-read-only.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: yomitan
|
||||||
|
|
||||||
|
- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling.
|
||||||
@@ -385,6 +385,17 @@
|
|||||||
} // Character dictionary setting.
|
} // Character dictionary setting.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // 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
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## v0.6.0 (2026-03-12)
|
||||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
||||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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:
|
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ The configuration file includes several main sections:
|
|||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**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
|
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
- [**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-setup`: open AniList setup/auth flow helper window.
|
||||||
- `--anilist-retry-queue`: process one ready retry queue item immediately.
|
- `--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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -385,6 +385,17 @@
|
|||||||
} // Character dictionary setting.
|
} // Character dictionary setting.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // 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
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|||||||
@@ -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".
|
- 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.
|
- 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.
|
- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
|
||||||
|
|
||||||
## MeCab / Tokenization
|
## MeCab / Tokenization
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
readSetupState,
|
||||||
} from '../../src/shared/setup-state.js';
|
} from '../../src/shared/setup-state.js';
|
||||||
|
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
@@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const statePath = getSetupStatePath(configDir);
|
const statePath = getSetupStatePath(configDir);
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
|
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
const setupArgs = ['--background', '--setup'];
|
const setupArgs = ['--background', '--setup'];
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
|
import { readExternalYomitanProfilePath } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPluginConfigCandidates,
|
getPluginConfigCandidates,
|
||||||
parsePluginRuntimeConfigContent,
|
parsePluginRuntimeConfigContent,
|
||||||
@@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
|||||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
|
||||||
|
assert.equal(
|
||||||
|
readExternalYomitanProfilePath({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: ' ~/.config/gsm_overlay ',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'~/.config/gsm_overlay',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readExternalYomitanProfilePath({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: ' ',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readExternalYomitanProfilePath({
|
||||||
|
yomitan: null,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readExternalYomitanProfilePath({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: 123,
|
||||||
|
},
|
||||||
|
} as never),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
|||||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.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 {
|
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
||||||
const root = readLauncherMainConfigObject();
|
const root = readLauncherMainConfigObject();
|
||||||
if (!root) return {};
|
if (!root) return {};
|
||||||
@@ -29,6 +42,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
|||||||
return parseLauncherJellyfinConfig(root);
|
return parseLauncherJellyfinConfig(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||||
|
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
return readPluginRuntimeConfigValue(logLevel);
|
return readPluginRuntimeConfigValue(logLevel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
|||||||
const sequence: Array<SetupState | null> = [
|
const sequence: Array<SetupState | null> = [
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
|||||||
windowsMpvShortcutLastStatus: 'unknown',
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
pluginInstallStatus: 'skipped',
|
pluginInstallStatus: 'skipped',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
if (reads === 1) return null;
|
if (reads === 1) return null;
|
||||||
if (reads === 2) {
|
if (reads === 2) {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
@@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
assert.deepEqual(calls, ['launch']);
|
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 () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: () => ({
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
|
|
||||||
export async function ensureLauncherSetupReady(deps: {
|
export async function ensureLauncherSetupReady(deps: {
|
||||||
readSetupState: () => SetupState | null;
|
readSetupState: () => SetupState | null;
|
||||||
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
launchSetupApp: () => void;
|
launchSetupApp: () => void;
|
||||||
sleep: (ms: number) => Promise<void>;
|
sleep: (ms: number) => Promise<void>;
|
||||||
now: () => number;
|
now: () => number;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
|
if (deps.isExternalYomitanConfigured?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isSetupCompleted(deps.readSetupState())) {
|
if (isSetupCompleted(deps.readSetupState())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.6.0",
|
"version": "0.6.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
131
scripts/subminer-change-verification.test.ts
Normal file
131
scripts/subminer-change-verification.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const classifyScript = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
||||||
|
);
|
||||||
|
const verifyScript = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
||||||
|
);
|
||||||
|
|
||||||
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
||||||
|
try {
|
||||||
|
return fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBash(args: string[]) {
|
||||||
|
return spawnSync('bash', args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: process.env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArtifactDir(stdout: string): string {
|
||||||
|
const match = stdout.match(/^artifact_dir=(.+)$/m);
|
||||||
|
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||||
|
return match[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSummaryJson(artifactDir: string) {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
selectedLanes: string[];
|
||||||
|
blockers?: string[];
|
||||||
|
artifactDir: string;
|
||||||
|
pathSelectionMode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
||||||
|
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
||||||
|
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
||||||
|
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const artifactDir = path.join(root, 'artifacts');
|
||||||
|
const result = runBash([
|
||||||
|
verifyScript,
|
||||||
|
'--dry-run',
|
||||||
|
'--artifact-dir',
|
||||||
|
artifactDir,
|
||||||
|
'--lane',
|
||||||
|
'real-runtime',
|
||||||
|
'launcher/mpv.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, result.stdout);
|
||||||
|
assert.match(result.stdout, /^result=blocked$/m);
|
||||||
|
|
||||||
|
const summary = readSummaryJson(artifactDir);
|
||||||
|
assert.equal(summary.status, 'blocked');
|
||||||
|
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
|
||||||
|
assert.ok(summary.sessionId.length > 0);
|
||||||
|
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
||||||
|
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier fails closed for unknown lanes', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const artifactDir = path.join(root, 'artifacts');
|
||||||
|
const result = runBash([
|
||||||
|
verifyScript,
|
||||||
|
'--dry-run',
|
||||||
|
'--artifact-dir',
|
||||||
|
artifactDir,
|
||||||
|
'--lane',
|
||||||
|
'not-a-lane',
|
||||||
|
'src/main.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, result.stdout);
|
||||||
|
assert.match(result.stdout, /^result=failed$/m);
|
||||||
|
|
||||||
|
const summary = readSummaryJson(artifactDir);
|
||||||
|
assert.equal(summary.status, 'failed');
|
||||||
|
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
||||||
|
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||||
|
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||||
|
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||||
|
|
||||||
|
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||||
|
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||||
|
|
||||||
|
const firstArtifactDir = parseArtifactDir(first.stdout);
|
||||||
|
const secondArtifactDir = parseArtifactDir(second.stdout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstSummary = readSummaryJson(firstArtifactDir);
|
||||||
|
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||||
|
|
||||||
|
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||||
|
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
||||||
|
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
||||||
|
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
||||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
||||||
|
assert.equal(config.yomitan.externalProfilePath, '');
|
||||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } =
|
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||||
INTEGRATIONS_DEFAULT_CONFIG;
|
INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
jimaku,
|
jimaku,
|
||||||
anilist,
|
anilist,
|
||||||
|
yomitan,
|
||||||
jellyfin,
|
jellyfin,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
ai,
|
ai,
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types';
|
|||||||
|
|
||||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
|
| 'ankiConnect'
|
||||||
|
| 'jimaku'
|
||||||
|
| 'anilist'
|
||||||
|
| 'yomitan'
|
||||||
|
| 'jellyfin'
|
||||||
|
| 'discordPresence'
|
||||||
|
| 'ai'
|
||||||
|
| 'youtubeSubgen'
|
||||||
> = {
|
> = {
|
||||||
ankiConnect: {
|
ankiConnect: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: '',
|
||||||
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'anilist.characterDictionary.enabled',
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
|
'yomitan.externalProfilePath',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||||
@@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
'startupWarmups',
|
'startupWarmups',
|
||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
|
'yomitan',
|
||||||
'immersionTracking',
|
'immersionTracking',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Open the Voiced by section by default in character dictionary glossary entries.',
|
'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',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
],
|
],
|
||||||
key: 'anilist',
|
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',
|
title: 'Jellyfin',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
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 {
|
export function applyIntegrationConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
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)) {
|
if (isObject(src.jellyfin)) {
|
||||||
const enabled = asBoolean(src.jellyfin.enabled);
|
const enabled = asBoolean(src.jellyfin.enabled);
|
||||||
if (enabled !== undefined) {
|
if (enabled !== undefined) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { createResolveContext } from './context';
|
import { createResolveContext } from './context';
|
||||||
import { applyIntegrationConfig } from './integrations';
|
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'),
|
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('yomitan externalProfilePath is trimmed and invalid values warn', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: ' /tmp/gsm-profile ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.yomitan.externalProfilePath, '/tmp/gsm-profile');
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: 42 as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyIntegrationConfig(invalid.context);
|
||||||
|
|
||||||
|
assert.equal(invalid.context.resolved.yomitan.externalProfilePath, '');
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'yomitan.externalProfilePath'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yomitan externalProfilePath expands leading tilde to the current home directory', () => {
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const { context } = createResolveContext({
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: '~/.config/gsm_overlay',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.yomitan.externalProfilePath,
|
||||||
|
path.join(homeDir, '.config', 'gsm_overlay'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
await runAppReadyRuntime(deps);
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
|
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('getResolvedConfig'), false);
|
||||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
assert.equal(calls.includes('getConfigWarnings'), false);
|
||||||
assert.equal(calls.includes('setLogLevel:warn:config'), 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('loadYomitanExtension'), true);
|
||||||
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
||||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
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('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
|
||||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
|
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
|
||||||
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
|
const options = buildOverlayWindowOptions('visible', {
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
isDev: false,
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
|
||||||
assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m);
|
assert.equal(options.webPreferences?.sandbox, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||||
|
const yomitanSession = { id: 'session' } as never;
|
||||||
|
const withSession = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
|
yomitanSession,
|
||||||
|
});
|
||||||
|
const withoutSession = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(withSession.webPreferences?.session, yomitanSession);
|
||||||
|
assert.equal(withoutSession.webPreferences?.session, undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
39
src/core/services/overlay-window-options.ts
Normal file
39
src/core/services/overlay-window-options.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BrowserWindowConstructorOptions, Session } from 'electron';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { OverlayWindowKind } from './overlay-window-input';
|
||||||
|
|
||||||
|
export function buildOverlayWindowOptions(
|
||||||
|
kind: OverlayWindowKind,
|
||||||
|
options: {
|
||||||
|
isDev: boolean;
|
||||||
|
yomitanSession?: Session | null;
|
||||||
|
},
|
||||||
|
): BrowserWindowConstructorOptions {
|
||||||
|
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: false,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
transparent: true,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
hasShadow: false,
|
||||||
|
focusable: true,
|
||||||
|
acceptFirstMouse: true,
|
||||||
|
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
webSecurity: true,
|
||||||
|
session: options.yomitanSession ?? undefined,
|
||||||
|
additionalArguments: [`--overlay-layer=${kind}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
import { BrowserWindow, type Session } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
handleOverlayWindowBeforeInputEvent,
|
handleOverlayWindowBeforeInputEvent,
|
||||||
type OverlayWindowKind,
|
type OverlayWindowKind,
|
||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
|
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
@@ -78,33 +79,10 @@ export function createOverlayWindow(
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||||
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
): BrowserWindow {
|
): BrowserWindow {
|
||||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||||
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}`],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
options.ensureOverlayWindowLevel(window);
|
options.ensureOverlayWindowLevel(window);
|
||||||
loadOverlayWindowLayer(window, kind);
|
loadOverlayWindowLayer(window, kind);
|
||||||
@@ -170,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
|
|||||||
loadOverlayWindowLayer(window, layer);
|
loadOverlayWindowLayer(window, layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
export type { OverlayWindowKind } from './overlay-window-input';
|
export type { OverlayWindowKind } from './overlay-window-input';
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
deps.ensureDefaultConfigBootstrap();
|
deps.ensureDefaultConfigBootstrap();
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
return;
|
return;
|
||||||
@@ -194,6 +195,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
|
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
import { mergeTokens } from '../../token-merger';
|
import { mergeTokens } from '../../token-merger';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,7 @@ type MecabTokenEnrichmentFn = (
|
|||||||
|
|
||||||
export interface TokenizerServiceDeps {
|
export interface TokenizerServiceDeps {
|
||||||
getYomitanExt: () => Extension | null;
|
getYomitanExt: () => Extension | null;
|
||||||
|
getYomitanSession?: () => Session | null;
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||||
@@ -63,6 +64,7 @@ interface MecabTokenizerLike {
|
|||||||
|
|
||||||
export interface TokenizerDepsRuntimeOptions {
|
export interface TokenizerDepsRuntimeOptions {
|
||||||
getYomitanExt: () => Extension | null;
|
getYomitanExt: () => Extension | null;
|
||||||
|
getYomitanSession?: () => Session | null;
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||||
@@ -182,6 +184,7 @@ export function createTokenizerDepsRuntime(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getYomitanExt: options.getYomitanExt,
|
getYomitanExt: options.getYomitanExt,
|
||||||
|
getYomitanSession: options.getYomitanSession,
|
||||||
getYomitanParserWindow: options.getYomitanParserWindow,
|
getYomitanParserWindow: options.getYomitanParserWindow,
|
||||||
setYomitanParserWindow: options.setYomitanParserWindow,
|
setYomitanParserWindow: options.setYomitanParserWindow,
|
||||||
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,
|
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||||
@@ -10,6 +10,7 @@ interface LoggerLike {
|
|||||||
|
|
||||||
interface YomitanParserRuntimeDeps {
|
interface YomitanParserRuntimeDeps {
|
||||||
getYomitanExt: () => Extension | null;
|
getYomitanExt: () => Extension | null;
|
||||||
|
getYomitanSession?: () => Session | null;
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||||
@@ -465,6 +466,7 @@ async function ensureYomitanParserWindow(
|
|||||||
|
|
||||||
const initPromise = (async () => {
|
const initPromise = (async () => {
|
||||||
const { BrowserWindow, session } = electron;
|
const { BrowserWindow, session } = electron;
|
||||||
|
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||||
const parserWindow = new BrowserWindow({
|
const parserWindow = new BrowserWindow({
|
||||||
show: false,
|
show: false,
|
||||||
width: 800,
|
width: 800,
|
||||||
@@ -472,7 +474,7 @@ async function ensureYomitanParserWindow(
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
session: session.defaultSession,
|
session: yomitanSession,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
deps.setYomitanParserWindow(parserWindow);
|
deps.setYomitanParserWindow(parserWindow);
|
||||||
@@ -539,6 +541,7 @@ async function createYomitanExtensionWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { BrowserWindow, session } = electron;
|
const { BrowserWindow, session } = electron;
|
||||||
|
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
show: false,
|
show: false,
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -546,7 +549,7 @@ async function createYomitanExtensionWindow(
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
session: session.defaultSession,
|
session: yomitanSession,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
||||||
import {
|
import {
|
||||||
getYomitanExtensionSearchPaths,
|
getYomitanExtensionSearchPaths,
|
||||||
|
resolveExternalYomitanExtensionPath,
|
||||||
resolveExistingYomitanExtensionPath,
|
resolveExistingYomitanExtensionPath,
|
||||||
} from './yomitan-extension-paths';
|
} from './yomitan-extension-paths';
|
||||||
|
import {
|
||||||
|
clearYomitanExtensionRuntimeState,
|
||||||
|
clearYomitanParserRuntimeState,
|
||||||
|
} from './yomitan-extension-runtime-state';
|
||||||
|
|
||||||
const { session } = electron;
|
const { session } = electron;
|
||||||
const logger = createLogger('main:yomitan-extension-loader');
|
const logger = createLogger('main:yomitan-extension-loader');
|
||||||
@@ -14,51 +20,82 @@ const logger = createLogger('main:yomitan-extension-loader');
|
|||||||
export interface YomitanExtensionLoaderDeps {
|
export interface YomitanExtensionLoaderDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
extensionPath?: string;
|
extensionPath?: string;
|
||||||
|
externalProfilePath?: string;
|
||||||
getYomitanParserWindow: () => BrowserWindow | null;
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
setYomitanExtension: (extension: Extension | null) => void;
|
setYomitanExtension: (extension: Extension | null) => void;
|
||||||
|
setYomitanSession: (session: Session | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadYomitanExtension(
|
export async function loadYomitanExtension(
|
||||||
deps: YomitanExtensionLoaderDeps,
|
deps: YomitanExtensionLoaderDeps,
|
||||||
): Promise<Extension | null> {
|
): Promise<Extension | null> {
|
||||||
const searchPaths = getYomitanExtensionSearchPaths({
|
const clearRuntimeState = () =>
|
||||||
explicitPath: deps.extensionPath,
|
clearYomitanExtensionRuntimeState({
|
||||||
moduleDir: __dirname,
|
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||||
resourcesPath: process.resourcesPath,
|
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||||
userDataPath: deps.userDataPath,
|
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||||
});
|
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||||
let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
|
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) {
|
if (externalProfilePath) {
|
||||||
logger.error('Yomitan extension not found in any search path');
|
const resolvedProfilePath = path.resolve(externalProfilePath);
|
||||||
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
|
extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync);
|
||||||
return null;
|
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);
|
clearParserState();
|
||||||
if (extensionCopy.copied) {
|
deps.setYomitanSession(targetSession);
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extensions = session.defaultSession.extensions;
|
const extensions = targetSession.extensions;
|
||||||
const extension = extensions
|
const extension = extensions
|
||||||
? await extensions.loadExtension(extPath, {
|
? await extensions.loadExtension(extPath, {
|
||||||
allowFileAccess: true,
|
allowFileAccess: true,
|
||||||
})
|
})
|
||||||
: await session.defaultSession.loadExtension(extPath, {
|
: await targetSession.loadExtension(extPath, {
|
||||||
allowFileAccess: true,
|
allowFileAccess: true,
|
||||||
});
|
});
|
||||||
deps.setYomitanExtension(extension);
|
deps.setYomitanExtension(extension);
|
||||||
@@ -66,7 +103,7 @@ export async function loadYomitanExtension(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load Yomitan extension:', (err as Error).message);
|
logger.error('Failed to load Yomitan extension:', (err as Error).message);
|
||||||
logger.error('Full error:', err);
|
logger.error('Full error:', err);
|
||||||
deps.setYomitanExtension(null);
|
clearRuntimeState();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getYomitanExtensionSearchPaths,
|
getYomitanExtensionSearchPaths,
|
||||||
|
resolveExternalYomitanExtensionPath,
|
||||||
resolveExistingYomitanExtensionPath,
|
resolveExistingYomitanExtensionPath,
|
||||||
} from './yomitan-extension-paths';
|
} from './yomitan-extension-paths';
|
||||||
|
|
||||||
@@ -51,3 +52,19 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
|
|||||||
|
|
||||||
assert.equal(resolved, null);
|
assert.equal(resolved, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => {
|
||||||
|
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
||||||
|
const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) =>
|
||||||
|
candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => {
|
||||||
|
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
||||||
|
const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false);
|
||||||
|
|
||||||
|
assert.equal(resolved, null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath(
|
|||||||
): string | null {
|
): string | null {
|
||||||
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync);
|
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveExternalYomitanExtensionPath(
|
||||||
|
externalProfilePath: string,
|
||||||
|
existsSync: (path: string) => boolean = fs.existsSync,
|
||||||
|
): string | null {
|
||||||
|
const normalizedProfilePath = externalProfilePath.trim();
|
||||||
|
if (!normalizedProfilePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||||
|
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
|
||||||
|
}
|
||||||
|
|||||||
45
src/core/services/yomitan-extension-runtime-state.test.ts
Normal file
45
src/core/services/yomitan-extension-runtime-state.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
|
||||||
|
|
||||||
|
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const parserWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => {
|
||||||
|
calls.push('destroy');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
clearYomitanParserRuntimeState({
|
||||||
|
getYomitanParserWindow: () => parserWindow as never,
|
||||||
|
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||||
|
setYomitanParserReadyPromise: (promise) =>
|
||||||
|
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||||
|
setYomitanParserInitPromise: (promise) =>
|
||||||
|
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const parserWindow = {
|
||||||
|
isDestroyed: () => true,
|
||||||
|
destroy: () => {
|
||||||
|
calls.push('destroy');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
clearYomitanParserRuntimeState({
|
||||||
|
getYomitanParserWindow: () => parserWindow as never,
|
||||||
|
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||||
|
setYomitanParserReadyPromise: (promise) =>
|
||||||
|
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||||
|
setYomitanParserInitPromise: (promise) =>
|
||||||
|
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
|
||||||
|
});
|
||||||
34
src/core/services/yomitan-extension-runtime-state.ts
Normal file
34
src/core/services/yomitan-extension-runtime-state.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
type ParserWindowLike = {
|
||||||
|
isDestroyed?: () => boolean;
|
||||||
|
destroy?: () => void;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export interface YomitanParserRuntimeStateDeps {
|
||||||
|
getYomitanParserWindow: () => ParserWindowLike;
|
||||||
|
setYomitanParserWindow: (window: null) => void;
|
||||||
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YomitanExtensionRuntimeStateDeps extends YomitanParserRuntimeStateDeps {
|
||||||
|
setYomitanExtension: (extension: null) => void;
|
||||||
|
setYomitanSession: (session: null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDeps): void {
|
||||||
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
|
if (parserWindow && !parserWindow.isDestroyed?.()) {
|
||||||
|
parserWindow.destroy?.();
|
||||||
|
}
|
||||||
|
deps.setYomitanParserWindow(null);
|
||||||
|
deps.setYomitanParserReadyPromise(null);
|
||||||
|
deps.setYomitanParserInitPromise(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearYomitanExtensionRuntimeState(
|
||||||
|
deps: YomitanExtensionRuntimeStateDeps,
|
||||||
|
): void {
|
||||||
|
clearYomitanParserRuntimeState(deps);
|
||||||
|
deps.setYomitanExtension(null);
|
||||||
|
deps.setYomitanSession(null);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
||||||
@@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions {
|
|||||||
yomitanExt: Extension | null;
|
yomitanExt: Extension | null;
|
||||||
getExistingWindow: () => BrowserWindow | null;
|
getExistingWindow: () => BrowserWindow | null;
|
||||||
setWindow: (window: BrowserWindow | null) => void;
|
setWindow: (window: BrowserWindow | null) => void;
|
||||||
|
yomitanSession?: Session | null;
|
||||||
onWindowClosed?: () => void;
|
onWindowClosed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
session: session.defaultSession,
|
session: options.yomitanSession ?? session.defaultSession,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
options.setWindow(settingsWindow);
|
options.setWindow(settingsWindow);
|
||||||
|
|||||||
97
src/main.ts
97
src/main.ts
@@ -23,6 +23,7 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
protocol,
|
protocol,
|
||||||
Extension,
|
Extension,
|
||||||
|
Session,
|
||||||
Menu,
|
Menu,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
Tray,
|
Tray,
|
||||||
@@ -376,6 +377,8 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
|
|||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
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 {
|
import {
|
||||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
shouldForceOverrideYomitanAnkiServer,
|
shouldForceOverrideYomitanAnkiServer,
|
||||||
@@ -691,6 +694,7 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
});
|
});
|
||||||
return dictionaries.length;
|
return dictionaries.length;
|
||||||
},
|
},
|
||||||
|
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||||
detectPluginInstalled: () => {
|
detectPluginInstalled: () => {
|
||||||
const installPaths = resolveDefaultMpvInstallPaths(
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
process.platform,
|
process.platform,
|
||||||
@@ -1327,7 +1331,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
const config = getResolvedConfig().anilist.characterDictionary;
|
const config = getResolvedConfig().anilist.characterDictionary;
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||||
maxLoaded: config.maxLoaded,
|
maxLoaded: config.maxLoaded,
|
||||||
profileScope: config.profileScope,
|
profileScope: config.profileScope,
|
||||||
};
|
};
|
||||||
@@ -1347,6 +1351,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
importYomitanDictionary: async (zipPath) => {
|
importYomitanDictionary: async (zipPath) => {
|
||||||
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
|
yomitanProfilePolicy.logSkippedWrite(
|
||||||
|
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
@@ -1354,6 +1364,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||||
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
|
yomitanProfilePolicy.logSkippedWrite(
|
||||||
|
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
|
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
|
||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
@@ -1361,6 +1377,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||||
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
|
yomitanProfilePolicy.logSkippedWrite(
|
||||||
|
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await upsertYomitanDictionarySettings(
|
return await upsertYomitanDictionarySettings(
|
||||||
dictionaryTitle,
|
dictionaryTitle,
|
||||||
@@ -1814,6 +1836,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
configReady: snapshot.configReady,
|
configReady: snapshot.configReady,
|
||||||
dictionaryCount: snapshot.dictionaryCount,
|
dictionaryCount: snapshot.dictionaryCount,
|
||||||
canFinish: snapshot.canFinish,
|
canFinish: snapshot.canFinish,
|
||||||
|
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||||
pluginStatus: snapshot.pluginStatus,
|
pluginStatus: snapshot.pluginStatus,
|
||||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||||
@@ -1837,8 +1860,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (submission.action === 'open-yomitan-settings') {
|
if (submission.action === 'open-yomitan-settings') {
|
||||||
openYomitanSettings();
|
firstRunSetupMessage = openYomitanSettings()
|
||||||
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
|
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||||
|
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (submission.action === 'refresh') {
|
if (submission.action === 'refresh') {
|
||||||
@@ -2320,6 +2344,7 @@ const {
|
|||||||
appState.yomitanParserWindow = null;
|
appState.yomitanParserWindow = null;
|
||||||
appState.yomitanParserReadyPromise = null;
|
appState.yomitanParserReadyPromise = null;
|
||||||
appState.yomitanParserInitPromise = null;
|
appState.yomitanParserInitPromise = null;
|
||||||
|
appState.yomitanSession = null;
|
||||||
},
|
},
|
||||||
getWindowTracker: () => appState.windowTracker,
|
getWindowTracker: () => appState.windowTracker,
|
||||||
flushMpvLog: () => flushPendingMpvLogWrites(),
|
flushMpvLog: () => flushPendingMpvLogWrites(),
|
||||||
@@ -2737,6 +2762,9 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
scheduleCharacterDictionarySync: () => {
|
scheduleCharacterDictionarySync: () => {
|
||||||
|
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||||
},
|
},
|
||||||
updateCurrentMediaTitle: (title) => {
|
updateCurrentMediaTitle: (title) => {
|
||||||
@@ -2780,6 +2808,7 @@ const {
|
|||||||
tokenizer: {
|
tokenizer: {
|
||||||
buildTokenizerDepsMainDeps: {
|
buildTokenizerDepsMainDeps: {
|
||||||
getYomitanExt: () => appState.yomitanExt,
|
getYomitanExt: () => appState.yomitanExt,
|
||||||
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
setYomitanParserWindow: (window) => {
|
setYomitanParserWindow: (window) => {
|
||||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||||
@@ -2813,7 +2842,9 @@ const {
|
|||||||
'subtitle.annotation.jlpt',
|
'subtitle.annotation.jlpt',
|
||||||
getResolvedConfig().subtitleStyle.enableJlpt,
|
getResolvedConfig().subtitleStyle.enableJlpt,
|
||||||
),
|
),
|
||||||
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled,
|
getCharacterDictionaryEnabled: () =>
|
||||||
|
getResolvedConfig().anilist.characterDictionary.enabled &&
|
||||||
|
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||||
getFrequencyDictionaryEnabled: () =>
|
getFrequencyDictionaryEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
@@ -2987,7 +3018,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
|||||||
|
|
||||||
async function loadYomitanExtension(): Promise<Extension | null> {
|
async function loadYomitanExtension(): Promise<Extension | null> {
|
||||||
const extension = await yomitanExtensionRuntime.loadYomitanExtension();
|
const extension = await yomitanExtensionRuntime.loadYomitanExtension();
|
||||||
if (extension) {
|
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
await syncYomitanDefaultProfileAnkiServer();
|
await syncYomitanDefaultProfileAnkiServer();
|
||||||
}
|
}
|
||||||
return extension;
|
return extension;
|
||||||
@@ -2995,7 +3026,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
|
|||||||
|
|
||||||
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
||||||
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||||
if (extension) {
|
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
await syncYomitanDefaultProfileAnkiServer();
|
await syncYomitanDefaultProfileAnkiServer();
|
||||||
}
|
}
|
||||||
return extension;
|
return extension;
|
||||||
@@ -3010,6 +3041,7 @@ function getPreferredYomitanAnkiServerUrl(): string {
|
|||||||
function getYomitanParserRuntimeDeps() {
|
function getYomitanParserRuntimeDeps() {
|
||||||
return {
|
return {
|
||||||
getYomitanExt: () => appState.yomitanExt,
|
getYomitanExt: () => appState.yomitanExt,
|
||||||
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||||
appState.yomitanParserWindow = window;
|
appState.yomitanParserWindow = window;
|
||||||
@@ -3026,6 +3058,10 @@ function getYomitanParserRuntimeDeps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||||
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
||||||
return;
|
return;
|
||||||
@@ -3079,8 +3115,19 @@ function initializeOverlayRuntime(): void {
|
|||||||
syncOverlayMpvSubtitleSuppression();
|
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();
|
openYomitanSettingsHandler();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -3496,8 +3543,13 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
|||||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||||
generateCharacterDictionary: (targetPath?: string) =>
|
generateCharacterDictionary: async (targetPath?: string) => {
|
||||||
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason();
|
||||||
|
if (disabledReason) {
|
||||||
|
throw new Error(disabledReason);
|
||||||
|
}
|
||||||
|
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
|
||||||
|
},
|
||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
@@ -3520,10 +3572,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||||
setOverlayDebugVisualizationEnabled(enabled),
|
setOverlayDebugVisualizationEnabled(enabled),
|
||||||
isOverlayVisible: (windowKind) =>
|
isOverlayVisible: (windowKind) =>
|
||||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||||
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
@@ -3584,9 +3637,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
},
|
},
|
||||||
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
||||||
});
|
});
|
||||||
|
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
||||||
|
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
|
||||||
|
logInfo: (message) => logger.info(message),
|
||||||
|
});
|
||||||
|
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
|
||||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||||
loadYomitanExtensionCore,
|
loadYomitanExtensionCore,
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
|
externalProfilePath: configuredExternalYomitanProfilePath,
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
setYomitanParserWindow: (window) => {
|
setYomitanParserWindow: (window) => {
|
||||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||||
@@ -3600,6 +3659,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
|||||||
setYomitanExtension: (extension) => {
|
setYomitanExtension: (extension) => {
|
||||||
appState.yomitanExt = extension;
|
appState.yomitanExt = extension;
|
||||||
},
|
},
|
||||||
|
setYomitanSession: (nextSession) => {
|
||||||
|
appState.yomitanSession = nextSession;
|
||||||
|
},
|
||||||
getYomitanExtension: () => appState.yomitanExt,
|
getYomitanExtension: () => appState.yomitanExt,
|
||||||
getLoadInFlight: () => yomitanLoadInFlight,
|
getLoadInFlight: () => yomitanLoadInFlight,
|
||||||
setLoadInFlight: (promise) => {
|
setLoadInFlight: (promise) => {
|
||||||
@@ -3641,11 +3703,18 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
});
|
});
|
||||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => {
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
|
openYomitanSettingsWindow: ({
|
||||||
|
yomitanExt,
|
||||||
|
getExistingWindow,
|
||||||
|
setWindow,
|
||||||
|
yomitanSession,
|
||||||
|
}) => {
|
||||||
openYomitanSettingsWindow({
|
openYomitanSettingsWindow({
|
||||||
yomitanExt: yomitanExt as Extension,
|
yomitanExt: yomitanExt as Extension,
|
||||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||||
setWindow: (window) => setWindow(window as BrowserWindow | null),
|
setWindow: (window) => setWindow(window as BrowserWindow | null),
|
||||||
|
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
|
||||||
onWindowClosed: () => {
|
onWindowClosed: () => {
|
||||||
if (appState.yomitanParserWindow) {
|
if (appState.yomitanParserWindow) {
|
||||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||||
|
|||||||
@@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let currentWindow: unknown = null;
|
let currentWindow: unknown = null;
|
||||||
const extension = { id: 'ext' };
|
const extension = { id: 'ext' };
|
||||||
|
const yomitanSession = { id: 'session' };
|
||||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||||
ensureYomitanExtensionLoaded: async () => extension,
|
ensureYomitanExtensionLoaded: async () => extension,
|
||||||
openYomitanSettingsWindow: ({ yomitanExt }) =>
|
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
|
||||||
calls.push(`open:${(yomitanExt as { id: string }).id}`),
|
calls.push(
|
||||||
|
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
|
||||||
|
),
|
||||||
getExistingWindow: () => currentWindow,
|
getExistingWindow: () => currentWindow,
|
||||||
setWindow: (window) => {
|
setWindow: (window) => {
|
||||||
currentWindow = window;
|
currentWindow = window;
|
||||||
calls.push('set-window');
|
calls.push('set-window');
|
||||||
},
|
},
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
logError: (message) => calls.push(`error:${message}`),
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
})();
|
})();
|
||||||
@@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
|||||||
yomitanExt: extension,
|
yomitanExt: extension,
|
||||||
getExistingWindow: () => deps.getExistingWindow(),
|
getExistingWindow: () => deps.getExistingWindow(),
|
||||||
setWindow: (window) => deps.setWindow(window),
|
setWindow: (window) => deps.setWindow(window),
|
||||||
|
yomitanSession: deps.getYomitanSession(),
|
||||||
});
|
});
|
||||||
deps.logWarn('warn');
|
deps.logWarn('warn');
|
||||||
deps.logError('error', new Error('boom'));
|
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' });
|
assert.deepEqual(currentWindow, { id: 'win' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
|||||||
yomitanExt: TYomitanExt;
|
yomitanExt: TYomitanExt;
|
||||||
getExistingWindow: () => TWindow | null;
|
getExistingWindow: () => TWindow | null;
|
||||||
setWindow: (window: TWindow | null) => void;
|
setWindow: (window: TWindow | null) => void;
|
||||||
|
yomitanSession?: unknown | null;
|
||||||
onWindowClosed?: () => void;
|
onWindowClosed?: () => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
getExistingWindow: () => TWindow | null;
|
getExistingWindow: () => TWindow | null;
|
||||||
setWindow: (window: TWindow | null) => void;
|
setWindow: (window: TWindow | null) => void;
|
||||||
|
getYomitanSession?: () => unknown | null;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
|||||||
yomitanExt: TYomitanExt;
|
yomitanExt: TYomitanExt;
|
||||||
getExistingWindow: () => TWindow | null;
|
getExistingWindow: () => TWindow | null;
|
||||||
setWindow: (window: TWindow | null) => void;
|
setWindow: (window: TWindow | null) => void;
|
||||||
|
yomitanSession?: unknown | null;
|
||||||
onWindowClosed?: () => void;
|
onWindowClosed?: () => void;
|
||||||
}) => deps.openYomitanSettingsWindow(params),
|
}) => deps.openYomitanSettingsWindow(params),
|
||||||
getExistingWindow: () => deps.getExistingWindow(),
|
getExistingWindow: () => deps.getExistingWindow(),
|
||||||
setWindow: (window: TWindow | null) => deps.setWindow(window),
|
setWindow: (window: TWindow | null) => deps.setWindow(window),
|
||||||
|
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||||
logWarn: (message: string) => deps.logWarn(message),
|
logWarn: (message: string) => deps.logWarn(message),
|
||||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||||
});
|
});
|
||||||
|
|||||||
20
src/main/runtime/character-dictionary-availability.test.ts
Normal file
20
src/main/runtime/character-dictionary-availability.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
getCharacterDictionaryDisabledReason,
|
||||||
|
isCharacterDictionaryRuntimeEnabled,
|
||||||
|
} from './character-dictionary-availability';
|
||||||
|
|
||||||
|
test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => {
|
||||||
|
assert.equal(isCharacterDictionaryRuntimeEnabled(''), true);
|
||||||
|
assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true);
|
||||||
|
assert.equal(getCharacterDictionaryDisabledReason(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('character dictionary runtime is disabled when external Yomitan profile is configured', () => {
|
||||||
|
assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false);
|
||||||
|
assert.equal(
|
||||||
|
getCharacterDictionaryDisabledReason('/tmp/gsm-profile'),
|
||||||
|
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
|
||||||
|
);
|
||||||
|
});
|
||||||
10
src/main/runtime/character-dictionary-availability.ts
Normal file
10
src/main/runtime/character-dictionary-availability.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function isCharacterDictionaryRuntimeEnabled(externalProfilePath: string): boolean {
|
||||||
|
return externalProfilePath.trim().length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharacterDictionaryDisabledReason(externalProfilePath: string): string | null {
|
||||||
|
if (isCharacterDictionaryRuntimeEnabled(externalProfilePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'Character dictionary is disabled while yomitan.externalProfilePath is configured.';
|
||||||
|
}
|
||||||
@@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support
|
|||||||
const completed = await service.markSetupCompleted();
|
const completed = await service.markSetupCompleted();
|
||||||
assert.equal(completed.state.status, 'completed');
|
assert.equal(completed.state.status, 'completed');
|
||||||
assert.equal(completed.state.completionSource, 'user');
|
assert.equal(completed.state.completionSource, 'user');
|
||||||
|
assert.equal(completed.state.yomitanSetupMode, 'internal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const configDir = path.join(root, 'SubMiner');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
|
|
||||||
|
const service = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 0,
|
||||||
|
isExternalYomitanConfigured: () => true,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = await service.ensureSetupStateInitialized();
|
||||||
|
assert.equal(initial.canFinish, true);
|
||||||
|
|
||||||
|
const completed = await service.markSetupCompleted();
|
||||||
|
assert.equal(completed.state.status, 'completed');
|
||||||
|
assert.equal(completed.state.yomitanSetupMode, 'external');
|
||||||
|
assert.equal(completed.dictionaryCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup service does not probe internal dictionaries when external yomitan is configured', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const configDir = path.join(root, 'SubMiner');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
|
|
||||||
|
const service = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => {
|
||||||
|
throw new Error('should not probe internal dictionaries in external mode');
|
||||||
|
},
|
||||||
|
isExternalYomitanConfigured: () => true,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await service.ensureSetupStateInitialized();
|
||||||
|
assert.equal(snapshot.state.status, 'completed');
|
||||||
|
assert.equal(snapshot.canFinish, true);
|
||||||
|
assert.equal(snapshot.externalYomitanConfigured, true);
|
||||||
|
assert.equal(snapshot.dictionaryCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const configDir = path.join(root, 'SubMiner');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
|
|
||||||
|
const service = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 0,
|
||||||
|
isExternalYomitanConfigured: () => true,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.ensureSetupStateInitialized();
|
||||||
|
await service.markSetupCompleted();
|
||||||
|
|
||||||
|
const relaunched = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 0,
|
||||||
|
isExternalYomitanConfigured: () => false,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await relaunched.ensureSetupStateInitialized();
|
||||||
|
assert.equal(snapshot.state.status, 'incomplete');
|
||||||
|
assert.equal(snapshot.state.yomitanSetupMode, null);
|
||||||
|
assert.equal(snapshot.canFinish, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const configDir = path.join(root, 'SubMiner');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
|
|
||||||
|
const service = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 0,
|
||||||
|
isExternalYomitanConfigured: () => true,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.ensureSetupStateInitialized();
|
||||||
|
await service.markSetupCompleted();
|
||||||
|
|
||||||
|
const relaunched = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 2,
|
||||||
|
isExternalYomitanConfigured: () => false,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await relaunched.ensureSetupStateInitialized();
|
||||||
|
assert.equal(snapshot.state.status, 'completed');
|
||||||
|
assert.equal(snapshot.canFinish, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface SetupStatusSnapshot {
|
|||||||
configReady: boolean;
|
configReady: boolean;
|
||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
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: {
|
export function createFirstRunSetupService(deps: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
getYomitanDictionaryCount: () => Promise<number>;
|
getYomitanDictionaryCount: () => Promise<number>;
|
||||||
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||||
installPlugin: () => Promise<PluginInstallResult>;
|
installPlugin: () => Promise<PluginInstallResult>;
|
||||||
detectWindowsMpvShortcuts?: () =>
|
detectWindowsMpvShortcuts?: () =>
|
||||||
@@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
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 pluginInstalled = await deps.detectPluginInstalled();
|
||||||
const detectedWindowsMpvShortcuts = isWindows
|
const detectedWindowsMpvShortcuts = isWindows
|
||||||
? await deps.detectWindowsMpvShortcuts?.()
|
? await deps.detectWindowsMpvShortcuts?.()
|
||||||
@@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: {
|
|||||||
state,
|
state,
|
||||||
installedWindowsMpvShortcuts,
|
installedWindowsMpvShortcuts,
|
||||||
);
|
);
|
||||||
const configReady =
|
|
||||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
|
||||||
return {
|
return {
|
||||||
configReady,
|
configReady,
|
||||||
dictionaryCount,
|
dictionaryCount,
|
||||||
canFinish: dictionaryCount >= 1,
|
canFinish: isYomitanSetupSatisfied({
|
||||||
|
configReady,
|
||||||
|
dictionaryCount,
|
||||||
|
externalYomitanConfigured,
|
||||||
|
}),
|
||||||
|
externalYomitanConfigured,
|
||||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: {
|
|||||||
return {
|
return {
|
||||||
ensureSetupStateInitialized: async () => {
|
ensureSetupStateInitialized: async () => {
|
||||||
const state = readState();
|
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;
|
completed = true;
|
||||||
return refreshWithState(state);
|
return refreshWithState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dictionaryCount = await deps.getYomitanDictionaryCount();
|
if (yomitanSetupSatisfied) {
|
||||||
const configReady =
|
|
||||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
|
||||||
if (configReady && dictionaryCount >= 1) {
|
|
||||||
const completedState = writeState({
|
const completedState = writeState({
|
||||||
...state,
|
...state,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
completionSource: 'legacy_auto_detected',
|
completionSource: 'legacy_auto_detected',
|
||||||
|
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
|
||||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||||
});
|
});
|
||||||
return buildSnapshot(completedState);
|
return buildSnapshot(completedState);
|
||||||
@@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
|
||||||
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
configReady: true,
|
configReady: true,
|
||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'optional',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
configReady: true,
|
configReady: true,
|
||||||
dictionaryCount: 1,
|
dictionaryCount: 1,
|
||||||
canFinish: true,
|
canFinish: true,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'installed',
|
pluginStatus: 'installed',
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
assert.match(html, /Reinstall mpv plugin/);
|
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', () => {
|
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||||
action: 'refresh',
|
action: 'refresh',
|
||||||
@@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
|||||||
configReady: false,
|
configReady: false,
|
||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'optional',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel {
|
|||||||
configReady: boolean;
|
configReady: boolean;
|
||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</div>`
|
</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>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div>
|
<div>
|
||||||
<strong>Yomitan dictionaries</strong>
|
<strong>Yomitan dictionaries</strong>
|
||||||
<div class="meta">${model.dictionaryCount} installed</div>
|
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||||
</div>
|
</div>
|
||||||
${renderStatusBadge(
|
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||||
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
|
|
||||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
${windowsShortcutCard}
|
${windowsShortcutCard}
|
||||||
<div class="actions">
|
<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>
|
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
|
|
||||||
test('overlay window factory main deps builders return mapped handlers', () => {
|
test('overlay window factory main deps builders return mapped handlers', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const yomitanSession = { id: 'session' } as never;
|
||||||
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
|
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
|
||||||
createOverlayWindowCore: (kind) => ({ kind }),
|
createOverlayWindowCore: (kind) => ({ kind }),
|
||||||
isDev: true,
|
isDev: true,
|
||||||
@@ -18,11 +19,13 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
|||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlayDeps = buildOverlayDeps();
|
const overlayDeps = buildOverlayDeps();
|
||||||
assert.equal(overlayDeps.isDev, true);
|
assert.equal(overlayDeps.isDev, true);
|
||||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||||
|
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
|
||||||
overlayDeps.forwardTabToMpv();
|
overlayDeps.forwardTabToMpv();
|
||||||
|
|
||||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Session } from 'electron';
|
||||||
|
|
||||||
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||||
createOverlayWindowCore: (
|
createOverlayWindowCore: (
|
||||||
kind: 'visible' | 'modal',
|
kind: 'visible' | 'modal',
|
||||||
@@ -10,6 +12,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
) => TWindow;
|
) => TWindow;
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
@@ -20,6 +23,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
createOverlayWindowCore: deps.createOverlayWindowCore,
|
createOverlayWindowCore: deps.createOverlayWindowCore,
|
||||||
@@ -31,6 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
|
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
test('create overlay window handler forwards options and kind', () => {
|
test('create overlay window handler forwards options and kind', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const window = { id: 1 };
|
const window = { id: 1 };
|
||||||
|
const yomitanSession = { id: 'session' } as never;
|
||||||
const createOverlayWindow = createCreateOverlayWindowHandler({
|
const createOverlayWindow = createCreateOverlayWindowHandler({
|
||||||
createOverlayWindowCore: (kind, options) => {
|
createOverlayWindowCore: (kind, options) => {
|
||||||
calls.push(`kind:${kind}`);
|
calls.push(`kind:${kind}`);
|
||||||
assert.equal(options.isDev, true);
|
assert.equal(options.isDev, true);
|
||||||
assert.equal(options.isOverlayVisible('visible'), true);
|
assert.equal(options.isOverlayVisible('visible'), true);
|
||||||
assert.equal(options.isOverlayVisible('modal'), false);
|
assert.equal(options.isOverlayVisible('modal'), false);
|
||||||
|
assert.equal(options.yomitanSession, yomitanSession);
|
||||||
options.forwardTabToMpv();
|
options.forwardTabToMpv();
|
||||||
options.onRuntimeOptionsChanged();
|
options.onRuntimeOptionsChanged();
|
||||||
options.setOverlayDebugVisualizationEnabled(true);
|
options.setOverlayDebugVisualizationEnabled(true);
|
||||||
@@ -29,6 +31,7 @@ test('create overlay window handler forwards options and kind', () => {
|
|||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(createOverlayWindow('visible'), window);
|
assert.equal(createOverlayWindow('visible'), window);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Session } from 'electron';
|
||||||
|
|
||||||
type OverlayWindowKind = 'visible' | 'modal';
|
type OverlayWindowKind = 'visible' | 'modal';
|
||||||
|
|
||||||
export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||||
@@ -12,6 +14,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
) => TWindow;
|
) => TWindow;
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
@@ -22,6 +25,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
return (kind: OverlayWindowKind): TWindow => {
|
return (kind: OverlayWindowKind): TWindow => {
|
||||||
return deps.createOverlayWindowCore(kind, {
|
return deps.createOverlayWindowCore(kind, {
|
||||||
@@ -33,6 +37,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
|
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
|||||||
let modalWindow: { kind: string } | null = null;
|
let modalWindow: { kind: string } | null = null;
|
||||||
let debugEnabled = false;
|
let debugEnabled = false;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const yomitanSession = { id: 'session' } as never;
|
||||||
|
|
||||||
const runtime = createOverlayWindowRuntimeHandlers({
|
const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({
|
||||||
createOverlayWindowDeps: {
|
createOverlayWindowDeps: {
|
||||||
createOverlayWindowCore: (kind) => ({ kind }),
|
createOverlayWindowCore: (kind, options) => {
|
||||||
|
assert.equal(options.yomitanSession, yomitanSession);
|
||||||
|
return { kind };
|
||||||
|
},
|
||||||
isDev: true,
|
isDev: true,
|
||||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||||
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
|
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
|
||||||
@@ -21,6 +25,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
|||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
},
|
},
|
||||||
setMainWindow: (window) => {
|
setMainWindow: (window) => {
|
||||||
mainWindow = window;
|
mainWindow = window;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
|||||||
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||||
return (): TokenizerDepsRuntimeOptions => ({
|
return (): TokenizerDepsRuntimeOptions => ({
|
||||||
getYomitanExt: () => deps.getYomitanExt(),
|
getYomitanExt: () => deps.getYomitanExt(),
|
||||||
|
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||||
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
||||||
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||||
|
|||||||
@@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
userDataPath: '/tmp/subminer',
|
userDataPath: '/tmp/subminer',
|
||||||
|
externalProfilePath: '/tmp/gsm-profile',
|
||||||
getYomitanParserWindow: () => null,
|
getYomitanParserWindow: () => null,
|
||||||
setYomitanParserWindow: () => calls.push('set-window'),
|
setYomitanParserWindow: () => calls.push('set-window'),
|
||||||
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
||||||
setYomitanParserInitPromise: () => calls.push('set-init'),
|
setYomitanParserInitPromise: () => calls.push('set-init'),
|
||||||
setYomitanExtension: () => calls.push('set-ext'),
|
setYomitanExtension: () => calls.push('set-ext'),
|
||||||
|
setYomitanSession: () => calls.push('set-session'),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.userDataPath, '/tmp/subminer');
|
assert.equal(deps.userDataPath, '/tmp/subminer');
|
||||||
|
assert.equal(deps.externalProfilePath, '/tmp/gsm-profile');
|
||||||
await deps.loadYomitanExtensionCore({} as never);
|
await deps.loadYomitanExtensionCore({} as never);
|
||||||
deps.setYomitanParserWindow(null);
|
deps.setYomitanParserWindow(null);
|
||||||
deps.setYomitanParserReadyPromise(null);
|
deps.setYomitanParserReadyPromise(null);
|
||||||
deps.setYomitanParserInitPromise(null);
|
deps.setYomitanParserInitPromise(null);
|
||||||
deps.setYomitanExtension(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 () => {
|
test('ensure yomitan extension loaded main deps builder maps callbacks', async () => {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan
|
|||||||
return (): LoadYomitanExtensionMainDeps => ({
|
return (): LoadYomitanExtensionMainDeps => ({
|
||||||
loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options),
|
loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options),
|
||||||
userDataPath: deps.userDataPath,
|
userDataPath: deps.userDataPath,
|
||||||
|
externalProfilePath: deps.externalProfilePath,
|
||||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||||
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
||||||
setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise),
|
setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise),
|
||||||
setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise),
|
setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise),
|
||||||
setYomitanExtension: (extension) => deps.setYomitanExtension(extension),
|
setYomitanExtension: (extension) => deps.setYomitanExtension(extension),
|
||||||
|
setYomitanSession: (session) => deps.setYomitanSession(session),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async
|
|||||||
const loadYomitanExtension = createLoadYomitanExtensionHandler({
|
const loadYomitanExtension = createLoadYomitanExtensionHandler({
|
||||||
loadYomitanExtensionCore: async (options) => {
|
loadYomitanExtensionCore: async (options) => {
|
||||||
calls.push(`path:${options.userDataPath}`);
|
calls.push(`path:${options.userDataPath}`);
|
||||||
|
calls.push(`external:${options.externalProfilePath ?? ''}`);
|
||||||
assert.equal(options.getYomitanParserWindow(), parserWindow);
|
assert.equal(options.getYomitanParserWindow(), parserWindow);
|
||||||
options.setYomitanParserWindow(null);
|
options.setYomitanParserWindow(null);
|
||||||
options.setYomitanParserReadyPromise(null);
|
options.setYomitanParserReadyPromise(null);
|
||||||
options.setYomitanParserInitPromise(null);
|
options.setYomitanParserInitPromise(null);
|
||||||
options.setYomitanExtension(extension);
|
options.setYomitanExtension(extension);
|
||||||
|
options.setYomitanSession(null);
|
||||||
return extension;
|
return extension;
|
||||||
},
|
},
|
||||||
userDataPath: '/tmp/subminer',
|
userDataPath: '/tmp/subminer',
|
||||||
|
externalProfilePath: '/tmp/gsm-profile',
|
||||||
getYomitanParserWindow: () => parserWindow,
|
getYomitanParserWindow: () => parserWindow,
|
||||||
setYomitanParserWindow: () => calls.push('set-window'),
|
setYomitanParserWindow: () => calls.push('set-window'),
|
||||||
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
||||||
setYomitanParserInitPromise: () => calls.push('set-init'),
|
setYomitanParserInitPromise: () => calls.push('set-init'),
|
||||||
setYomitanExtension: () => calls.push('set-ext'),
|
setYomitanExtension: () => calls.push('set-ext'),
|
||||||
|
setYomitanSession: () => calls.push('set-session'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(await loadYomitanExtension(), extension);
|
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 () => {
|
test('ensure yomitan loader returns existing extension when available', async () => {
|
||||||
|
|||||||
@@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext
|
|||||||
export function createLoadYomitanExtensionHandler(deps: {
|
export function createLoadYomitanExtensionHandler(deps: {
|
||||||
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
||||||
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
|
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
|
||||||
|
externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath'];
|
||||||
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
|
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
|
||||||
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
|
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
|
||||||
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
|
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
|
||||||
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
|
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
|
||||||
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
|
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
|
||||||
|
setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession'];
|
||||||
}) {
|
}) {
|
||||||
return async (): Promise<Extension | null> => {
|
return async (): Promise<Extension | null> => {
|
||||||
return deps.loadYomitanExtensionCore({
|
return deps.loadYomitanExtensionCore({
|
||||||
userDataPath: deps.userDataPath,
|
userDataPath: deps.userDataPath,
|
||||||
|
externalProfilePath: deps.externalProfilePath,
|
||||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||||
setYomitanExtension: deps.setYomitanExtension,
|
setYomitanExtension: deps.setYomitanExtension,
|
||||||
|
setYomitanSession: deps.setYomitanSession,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
|||||||
let parserWindow: unknown = null;
|
let parserWindow: unknown = null;
|
||||||
let readyPromise: Promise<void> | null = null;
|
let readyPromise: Promise<void> | null = null;
|
||||||
let initPromise: Promise<boolean> | null = null;
|
let initPromise: Promise<boolean> | null = null;
|
||||||
|
let yomitanSession: unknown = null;
|
||||||
|
let receivedExternalProfilePath = '';
|
||||||
let loadCalls = 0;
|
let loadCalls = 0;
|
||||||
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
|
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
|
||||||
releaseLoad: null,
|
releaseLoad: null,
|
||||||
@@ -17,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
|||||||
const runtime = createYomitanExtensionRuntime({
|
const runtime = createYomitanExtensionRuntime({
|
||||||
loadYomitanExtensionCore: async (options) => {
|
loadYomitanExtensionCore: async (options) => {
|
||||||
loadCalls += 1;
|
loadCalls += 1;
|
||||||
|
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||||
options.setYomitanParserWindow(null);
|
options.setYomitanParserWindow(null);
|
||||||
options.setYomitanParserReadyPromise(Promise.resolve());
|
options.setYomitanParserReadyPromise(Promise.resolve());
|
||||||
options.setYomitanParserInitPromise(Promise.resolve(true));
|
options.setYomitanParserInitPromise(Promise.resolve(true));
|
||||||
|
options.setYomitanSession({ id: 'session' } as never);
|
||||||
return await new Promise<Extension | null>((resolve) => {
|
return await new Promise<Extension | null>((resolve) => {
|
||||||
releaseLoadState.releaseLoad = (value) => {
|
releaseLoadState.releaseLoad = (value) => {
|
||||||
options.setYomitanExtension(value);
|
options.setYomitanExtension(value);
|
||||||
@@ -28,6 +32,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
userDataPath: '/tmp',
|
userDataPath: '/tmp',
|
||||||
|
externalProfilePath: '/tmp/gsm-profile',
|
||||||
getYomitanParserWindow: () => parserWindow as never,
|
getYomitanParserWindow: () => parserWindow as never,
|
||||||
setYomitanParserWindow: (window) => {
|
setYomitanParserWindow: (window) => {
|
||||||
parserWindow = window;
|
parserWindow = window;
|
||||||
@@ -41,6 +46,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
|||||||
setYomitanExtension: (next) => {
|
setYomitanExtension: (next) => {
|
||||||
extension = next;
|
extension = next;
|
||||||
},
|
},
|
||||||
|
setYomitanSession: (next) => {
|
||||||
|
yomitanSession = next;
|
||||||
|
},
|
||||||
getYomitanExtension: () => extension,
|
getYomitanExtension: () => extension,
|
||||||
getLoadInFlight: () => inFlight,
|
getLoadInFlight: () => inFlight,
|
||||||
setLoadInFlight: (promise) => {
|
setLoadInFlight: (promise) => {
|
||||||
@@ -55,6 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
|||||||
assert.equal(parserWindow, null);
|
assert.equal(parserWindow, null);
|
||||||
assert.ok(readyPromise);
|
assert.ok(readyPromise);
|
||||||
assert.ok(initPromise);
|
assert.ok(initPromise);
|
||||||
|
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||||
|
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||||
|
|
||||||
const fakeExtension = { id: 'yomitan' } as Extension;
|
const fakeExtension = { id: 'yomitan' } as Extension;
|
||||||
const releaseLoad = releaseLoadState.releaseLoad;
|
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 () => {
|
test('yomitan extension runtime direct load delegates to core', async () => {
|
||||||
let loadCalls = 0;
|
let loadCalls = 0;
|
||||||
|
let receivedExternalProfilePath = '';
|
||||||
|
let yomitanSession: unknown = null;
|
||||||
|
|
||||||
const runtime = createYomitanExtensionRuntime({
|
const runtime = createYomitanExtensionRuntime({
|
||||||
loadYomitanExtensionCore: async () => {
|
loadYomitanExtensionCore: async (options) => {
|
||||||
loadCalls += 1;
|
loadCalls += 1;
|
||||||
|
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||||
|
options.setYomitanSession({ id: 'session' } as never);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
userDataPath: '/tmp',
|
userDataPath: '/tmp',
|
||||||
|
externalProfilePath: '/tmp/gsm-profile',
|
||||||
getYomitanParserWindow: () => null,
|
getYomitanParserWindow: () => null,
|
||||||
setYomitanParserWindow: () => {},
|
setYomitanParserWindow: () => {},
|
||||||
setYomitanParserReadyPromise: () => {},
|
setYomitanParserReadyPromise: () => {},
|
||||||
setYomitanParserInitPromise: () => {},
|
setYomitanParserInitPromise: () => {},
|
||||||
setYomitanExtension: () => {},
|
setYomitanExtension: () => {},
|
||||||
|
setYomitanSession: (next) => {
|
||||||
|
yomitanSession = next;
|
||||||
|
},
|
||||||
getYomitanExtension: () => null,
|
getYomitanExtension: () => null,
|
||||||
getLoadInFlight: () => null,
|
getLoadInFlight: () => null,
|
||||||
setLoadInFlight: () => {},
|
setLoadInFlight: () => {},
|
||||||
@@ -93,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => {
|
|||||||
|
|
||||||
assert.equal(await runtime.loadYomitanExtension(), null);
|
assert.equal(await runtime.loadYomitanExtension(), null);
|
||||||
assert.equal(loadCalls, 1);
|
assert.equal(loadCalls, 1);
|
||||||
|
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||||
|
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
|
|||||||
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
||||||
loadYomitanExtensionCore: deps.loadYomitanExtensionCore,
|
loadYomitanExtensionCore: deps.loadYomitanExtensionCore,
|
||||||
userDataPath: deps.userDataPath,
|
userDataPath: deps.userDataPath,
|
||||||
|
externalProfilePath: deps.externalProfilePath,
|
||||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||||
setYomitanExtension: deps.setYomitanExtension,
|
setYomitanExtension: deps.setYomitanExtension,
|
||||||
|
setYomitanSession: deps.setYomitanSession,
|
||||||
});
|
});
|
||||||
const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler(
|
const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler(
|
||||||
buildLoadYomitanExtensionMainDepsHandler(),
|
buildLoadYomitanExtensionMainDepsHandler(),
|
||||||
|
|||||||
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal file
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createYomitanProfilePolicy } from './yomitan-profile-policy';
|
||||||
|
|
||||||
|
test('yomitan profile policy trims external profile path and marks read-only mode', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const policy = createYomitanProfilePolicy({
|
||||||
|
externalProfilePath: ' /tmp/gsm-profile ',
|
||||||
|
logInfo: (message) => calls.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(policy.externalProfilePath, '/tmp/gsm-profile');
|
||||||
|
assert.equal(policy.isExternalReadOnlyMode(), true);
|
||||||
|
assert.equal(policy.isCharacterDictionaryEnabled(), false);
|
||||||
|
assert.equal(
|
||||||
|
policy.getCharacterDictionaryDisabledReason(),
|
||||||
|
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
|
||||||
|
);
|
||||||
|
|
||||||
|
policy.logSkippedWrite('importYomitanDictionary(sample.zip)');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'[yomitan] skipping importYomitanDictionary(sample.zip): yomitan.externalProfilePath is configured; external profile mode is read-only',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yomitan profile policy keeps character dictionary enabled without external profile path', () => {
|
||||||
|
const policy = createYomitanProfilePolicy({
|
||||||
|
externalProfilePath: ' ',
|
||||||
|
logInfo: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(policy.externalProfilePath, '');
|
||||||
|
assert.equal(policy.isExternalReadOnlyMode(), false);
|
||||||
|
assert.equal(policy.isCharacterDictionaryEnabled(), true);
|
||||||
|
assert.equal(policy.getCharacterDictionaryDisabledReason(), null);
|
||||||
|
});
|
||||||
25
src/main/runtime/yomitan-profile-policy.ts
Normal file
25
src/main/runtime/yomitan-profile-policy.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
getCharacterDictionaryDisabledReason,
|
||||||
|
isCharacterDictionaryRuntimeEnabled,
|
||||||
|
} from './character-dictionary-availability';
|
||||||
|
|
||||||
|
export function createYomitanProfilePolicy(options: {
|
||||||
|
externalProfilePath: string;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const externalProfilePath = options.externalProfilePath.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalProfilePath,
|
||||||
|
isExternalReadOnlyMode: (): boolean => externalProfilePath.length > 0,
|
||||||
|
isCharacterDictionaryEnabled: (): boolean =>
|
||||||
|
isCharacterDictionaryRuntimeEnabled(externalProfilePath),
|
||||||
|
getCharacterDictionaryDisabledReason: (): string | null =>
|
||||||
|
getCharacterDictionaryDisabledReason(externalProfilePath),
|
||||||
|
logSkippedWrite: (action: string): void => {
|
||||||
|
options.logInfo(
|
||||||
|
`[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal file
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { formatSkippedYomitanWriteAction } from './yomitan-read-only-log';
|
||||||
|
|
||||||
|
test('formatSkippedYomitanWriteAction redacts full filesystem paths to basenames', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSkippedYomitanWriteAction('importYomitanDictionary', '/tmp/private/merged.zip'),
|
||||||
|
'importYomitanDictionary(merged.zip)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSkippedYomitanWriteAction redacts dictionary titles', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSkippedYomitanWriteAction('deleteYomitanDictionary', 'SubMiner Character Dictionary'),
|
||||||
|
'deleteYomitanDictionary(<redacted>)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSkippedYomitanWriteAction falls back when value is blank', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '),
|
||||||
|
'upsertYomitanDictionarySettings(<redacted>)',
|
||||||
|
);
|
||||||
|
});
|
||||||
25
src/main/runtime/yomitan-read-only-log.ts
Normal file
25
src/main/runtime/yomitan-read-only-log.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
function redactSkippedYomitanWriteValue(
|
||||||
|
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||||
|
rawValue: string,
|
||||||
|
): string {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '<redacted>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionName === 'importYomitanDictionary') {
|
||||||
|
const basename = path.basename(trimmed);
|
||||||
|
return basename || '<redacted>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<redacted>';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkippedYomitanWriteAction(
|
||||||
|
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||||
|
rawValue: string,
|
||||||
|
): string {
|
||||||
|
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||||
|
}
|
||||||
@@ -22,14 +22,16 @@ test('yomitan opener warns when extension cannot be loaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('yomitan opener opens settings window when extension is available', async () => {
|
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({
|
const openSettings = createOpenYomitanSettingsHandler({
|
||||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||||
openYomitanSettingsWindow: () => {
|
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
|
||||||
opened = true;
|
forwardedSession = nextSession as { id: string } | null;
|
||||||
},
|
},
|
||||||
getExistingWindow: () => null,
|
getExistingWindow: () => null,
|
||||||
setWindow: () => {},
|
setWindow: () => {},
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
});
|
});
|
||||||
@@ -37,5 +39,5 @@ test('yomitan opener opens settings window when extension is available', async (
|
|||||||
openSettings();
|
openSettings();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(opened, true);
|
assert.equal(forwardedSession, yomitanSession);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
type YomitanExtensionLike = unknown;
|
type YomitanExtensionLike = unknown;
|
||||||
type BrowserWindowLike = unknown;
|
type BrowserWindowLike = unknown;
|
||||||
|
type SessionLike = unknown;
|
||||||
|
|
||||||
export function createOpenYomitanSettingsHandler(deps: {
|
export function createOpenYomitanSettingsHandler(deps: {
|
||||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||||
@@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: {
|
|||||||
yomitanExt: YomitanExtensionLike;
|
yomitanExt: YomitanExtensionLike;
|
||||||
getExistingWindow: () => BrowserWindowLike | null;
|
getExistingWindow: () => BrowserWindowLike | null;
|
||||||
setWindow: (window: BrowserWindowLike | null) => void;
|
setWindow: (window: BrowserWindowLike | null) => void;
|
||||||
|
yomitanSession?: SessionLike | null;
|
||||||
onWindowClosed?: () => void;
|
onWindowClosed?: () => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
getExistingWindow: () => BrowserWindowLike | null;
|
getExistingWindow: () => BrowserWindowLike | null;
|
||||||
setWindow: (window: BrowserWindowLike | null) => void;
|
setWindow: (window: BrowserWindowLike | null) => void;
|
||||||
|
getYomitanSession?: () => SessionLike | null;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => 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.');
|
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||||
|
if (!yomitanSession) {
|
||||||
|
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.openYomitanSettingsWindow({
|
deps.openYomitanSettingsWindow({
|
||||||
yomitanExt: extension,
|
yomitanExt: extension,
|
||||||
getExistingWindow: deps.getExistingWindow,
|
getExistingWindow: deps.getExistingWindow,
|
||||||
setWindow: deps.setWindow,
|
setWindow: deps.setWindow,
|
||||||
|
yomitanSession,
|
||||||
});
|
});
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
deps.logError('Failed to open Yomitan settings window.', error);
|
deps.logError('Failed to open Yomitan settings window.', error);
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime';
|
|||||||
test('yomitan settings runtime composes opener with built deps', async () => {
|
test('yomitan settings runtime composes opener with built deps', async () => {
|
||||||
let existingWindow: { id: string } | null = null;
|
let existingWindow: { id: string } | null = null;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const yomitanSession = { id: 'session' };
|
||||||
|
|
||||||
const runtime = createYomitanSettingsRuntime({
|
const runtime = createYomitanSettingsRuntime({
|
||||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => {
|
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||||
calls.push('open-window');
|
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||||
const current = getExistingWindow();
|
const current = getExistingWindow();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setWindow({ id: 'settings' });
|
setWindow({ id: 'settings' });
|
||||||
@@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
|||||||
setWindow: (window) => {
|
setWindow: (window) => {
|
||||||
existingWindow = window as { id: string } | null;
|
existingWindow = window as { id: string } | null;
|
||||||
},
|
},
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
logError: (message) => calls.push(`error:${message}`),
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
});
|
});
|
||||||
@@ -27,5 +29,30 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
assert.deepEqual(existingWindow, { id: 'settings' });
|
assert.deepEqual(existingWindow, { id: 'settings' });
|
||||||
assert.deepEqual(calls, ['open-window']);
|
assert.deepEqual(calls, ['open-window:session']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
|
||||||
|
let existingWindow: { id: string } | null = null;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYomitanSettingsRuntime({
|
||||||
|
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||||
|
openYomitanSettingsWindow: () => {
|
||||||
|
calls.push('open-window');
|
||||||
|
},
|
||||||
|
getExistingWindow: () => existingWindow as never,
|
||||||
|
setWindow: (window) => {
|
||||||
|
existingWindow = window as { id: string } | null;
|
||||||
|
},
|
||||||
|
getYomitanSession: () => null,
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.openYomitanSettings();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(existingWindow, null);
|
||||||
|
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Keybinding,
|
Keybinding,
|
||||||
@@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState(
|
|||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
yomitanExt: Extension | null;
|
yomitanExt: Extension | null;
|
||||||
|
yomitanSession: Session | null;
|
||||||
yomitanSettingsWindow: BrowserWindow | null;
|
yomitanSettingsWindow: BrowserWindow | null;
|
||||||
yomitanParserWindow: BrowserWindow | null;
|
yomitanParserWindow: BrowserWindow | null;
|
||||||
anilistSetupWindow: BrowserWindow | null;
|
anilistSetupWindow: BrowserWindow | null;
|
||||||
@@ -219,6 +220,7 @@ export interface StartupState {
|
|||||||
export function createAppState(values: AppStateInitialValues): AppState {
|
export function createAppState(values: AppStateInitialValues): AppState {
|
||||||
return {
|
return {
|
||||||
yomitanExt: null,
|
yomitanExt: null,
|
||||||
|
yomitanSession: null,
|
||||||
yomitanSettingsWindow: null,
|
yomitanSettingsWindow: null,
|
||||||
yomitanParserWindow: null,
|
yomitanParserWindow: null,
|
||||||
anilistSetupWindow: null,
|
anilistSetupWindow: null,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
|
test('ensureDefaultConfigBootstrap seeds default config into an existing config directory when missing', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
@@ -74,10 +74,13 @@ test('ensureDefaultConfigBootstrap does not seed default config into an existing
|
|||||||
ensureDefaultConfigBootstrap({
|
ensureDefaultConfigBootstrap({
|
||||||
configDir,
|
configDir,
|
||||||
configFilePaths: getDefaultConfigFilePaths(configDir),
|
configFilePaths: getDefaultConfigFilePaths(configDir),
|
||||||
generateTemplate: () => 'should-not-write',
|
generateTemplate: () => '{\n "logging": {}\n}\n',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'),
|
||||||
|
'{\n "logging": {}\n}\n',
|
||||||
|
);
|
||||||
assert.equal(fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'), 'keep\n');
|
assert.equal(fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'), 'keep\n');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -91,6 +94,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
|||||||
const state = createDefaultSetupState();
|
const state = createDefaultSetupState();
|
||||||
state.status = 'completed';
|
state.status = 'completed';
|
||||||
state.completionSource = 'user';
|
state.completionSource = 'user';
|
||||||
|
state.yomitanSetupMode = 'internal';
|
||||||
state.lastSeenYomitanDictionaryCount = 2;
|
state.lastSeenYomitanDictionaryCount = 2;
|
||||||
writeSetupState(statePath, state);
|
writeSetupState(statePath, state);
|
||||||
|
|
||||||
@@ -98,7 +102,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const statePath = getSetupStatePath(root);
|
const statePath = getSetupStatePath(root);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -115,10 +119,11 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(readSetupState(statePath), {
|
assert.deepEqual(readSetupState(statePath), {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'incomplete',
|
status: 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -131,6 +136,45 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('readSetupState migrates completed v2 state to internal yomitan setup mode', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const statePath = getSetupStatePath(root);
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
completionSource: 'user',
|
||||||
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: {
|
||||||
|
startMenuEnabled: true,
|
||||||
|
desktopEnabled: true,
|
||||||
|
},
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(readSetupState(statePath), {
|
||||||
|
version: 3,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: {
|
||||||
|
startMenuEnabled: true,
|
||||||
|
desktopEnabled: true,
|
||||||
|
},
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
||||||
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
||||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveConfigDir } from '../config/path-resolution';
|
|||||||
|
|
||||||
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
|
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
||||||
|
export type SetupYomitanMode = 'internal' | 'external' | null;
|
||||||
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||||
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||||
|
|
||||||
@@ -14,10 +15,11 @@ export interface SetupWindowsMpvShortcutPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupState {
|
export interface SetupState {
|
||||||
version: 2;
|
version: 3;
|
||||||
status: SetupStateStatus;
|
status: SetupStateStatus;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
completionSource: SetupCompletionSource;
|
completionSource: SetupCompletionSource;
|
||||||
|
yomitanSetupMode: SetupYomitanMode;
|
||||||
lastSeenYomitanDictionaryCount: number;
|
lastSeenYomitanDictionaryCount: number;
|
||||||
pluginInstallStatus: SetupPluginInstallStatus;
|
pluginInstallStatus: SetupPluginInstallStatus;
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
@@ -52,10 +54,11 @@ function asObject(value: unknown): Record<string, unknown> | null {
|
|||||||
|
|
||||||
export function createDefaultSetupState(): SetupState {
|
export function createDefaultSetupState(): SetupState {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'incomplete',
|
status: 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -74,11 +77,12 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
|||||||
const status = record.status;
|
const status = record.status;
|
||||||
const pluginInstallStatus = record.pluginInstallStatus;
|
const pluginInstallStatus = record.pluginInstallStatus;
|
||||||
const completionSource = record.completionSource;
|
const completionSource = record.completionSource;
|
||||||
|
const yomitanSetupMode = record.yomitanSetupMode;
|
||||||
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
||||||
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(version !== 1 && version !== 2) ||
|
(version !== 1 && version !== 2 && version !== 3) ||
|
||||||
(status !== 'incomplete' &&
|
(status !== 'incomplete' &&
|
||||||
status !== 'in_progress' &&
|
status !== 'in_progress' &&
|
||||||
status !== 'completed' &&
|
status !== 'completed' &&
|
||||||
@@ -94,16 +98,26 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
|||||||
windowsMpvShortcutLastStatus !== 'failed') ||
|
windowsMpvShortcutLastStatus !== 'failed') ||
|
||||||
(completionSource !== null &&
|
(completionSource !== null &&
|
||||||
completionSource !== 'user' &&
|
completionSource !== 'user' &&
|
||||||
completionSource !== 'legacy_auto_detected')
|
completionSource !== 'legacy_auto_detected') ||
|
||||||
|
(version === 3 &&
|
||||||
|
yomitanSetupMode !== null &&
|
||||||
|
yomitanSetupMode !== 'internal' &&
|
||||||
|
yomitanSetupMode !== 'external')
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status,
|
status,
|
||||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||||
completionSource,
|
completionSource,
|
||||||
|
yomitanSetupMode:
|
||||||
|
version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external')
|
||||||
|
? yomitanSetupMode
|
||||||
|
: status === 'completed'
|
||||||
|
? 'internal'
|
||||||
|
: null,
|
||||||
lastSeenYomitanDictionaryCount:
|
lastSeenYomitanDictionaryCount:
|
||||||
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
|
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
|
||||||
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
|
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
|
||||||
@@ -208,13 +222,8 @@ export function ensureDefaultConfigBootstrap(options: {
|
|||||||
const existsSync = options.existsSync ?? fs.existsSync;
|
const existsSync = options.existsSync ?? fs.existsSync;
|
||||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
||||||
const configDirExists = existsSync(options.configDir);
|
|
||||||
|
|
||||||
if (
|
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
|
||||||
existsSync(options.configFilePaths.jsoncPath) ||
|
|
||||||
existsSync(options.configFilePaths.jsonPath) ||
|
|
||||||
configDirExists
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -501,6 +501,10 @@ export interface AnilistConfig {
|
|||||||
characterDictionary?: AnilistCharacterDictionaryConfig;
|
characterDictionary?: AnilistCharacterDictionaryConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YomitanConfig {
|
||||||
|
externalProfilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinConfig {
|
export interface JellyfinConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@@ -585,6 +589,7 @@ export interface Config {
|
|||||||
auto_start_overlay?: boolean;
|
auto_start_overlay?: boolean;
|
||||||
jimaku?: JimakuConfig;
|
jimaku?: JimakuConfig;
|
||||||
anilist?: AnilistConfig;
|
anilist?: AnilistConfig;
|
||||||
|
yomitan?: YomitanConfig;
|
||||||
jellyfin?: JellyfinConfig;
|
jellyfin?: JellyfinConfig;
|
||||||
discordPresence?: DiscordPresenceConfig;
|
discordPresence?: DiscordPresenceConfig;
|
||||||
ai?: AiConfig;
|
ai?: AiConfig;
|
||||||
@@ -725,6 +730,9 @@ export interface ResolvedConfig {
|
|||||||
collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>;
|
collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
yomitan: {
|
||||||
|
externalProfilePath: string;
|
||||||
|
};
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user