mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Automate AUR publish in tagged release workflow (#22)
This commit is contained in:
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -317,7 +317,7 @@ jobs:
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify changelog is ready for tagged release
|
||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||
@@ -362,3 +362,89 @@ jobs:
|
||||
for asset in "${artifacts[@]}"; do
|
||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||
done
|
||||
|
||||
aur-publish:
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate AUR SSH secret
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y makepkg
|
||||
|
||||
- name: Configure SSH for AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -dm700 ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Clone AUR repo
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
|
||||
|
||||
- name: Download release assets for AUR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ steps.version.outputs.VERSION }}"
|
||||
install -dm755 .tmp/aur-release-assets
|
||||
gh release download "$version" \
|
||||
--dir .tmp/aur-release-assets \
|
||||
--pattern "SubMiner-${version#v}.AppImage" \
|
||||
--pattern "subminer" \
|
||||
--pattern "subminer-assets.tar.gz"
|
||||
|
||||
- name: Update AUR packaging metadata
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_no_v="${{ steps.version.outputs.VERSION }}"
|
||||
version_no_v="${version_no_v#v}"
|
||||
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
|
||||
bash scripts/update-aur-package.sh \
|
||||
--pkg-dir aur-subminer-bin \
|
||||
--version "${{ steps.version.outputs.VERSION }}" \
|
||||
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
|
||||
--wrapper ".tmp/aur-release-assets/subminer" \
|
||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
||||
|
||||
- name: Commit and push AUR update
|
||||
working-directory: aur-subminer-bin
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- PKGBUILD .SRCINFO; then
|
||||
echo "AUR packaging already up to date."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
|
||||
git push origin HEAD:master
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: TASK-165
|
||||
title: Automate AUR publish on tagged releases
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 15:55'
|
||||
updated_date: '2026-03-14 18:40'
|
||||
labels:
|
||||
- release
|
||||
- packaging
|
||||
- linux
|
||||
dependencies:
|
||||
- TASK-161
|
||||
references:
|
||||
- .github/workflows/release.yml
|
||||
- src/release-workflow.test.ts
|
||||
- docs/RELEASING.md
|
||||
- packaging/aur/subminer-bin/PKGBUILD
|
||||
documentation:
|
||||
- docs/plans/2026-03-14-aur-release-sync-design.md
|
||||
- docs/plans/2026-03-14-aur-release-sync.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the tagged release workflow so a successful GitHub release automatically updates the `subminer-bin` AUR package over SSH. Keep the PKGBUILD source-of-truth in this repo so release automation is reviewable and testable instead of depending on an external maintainer checkout.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Repo-tracked AUR packaging source exists for `subminer-bin` and matches the current release artifact layout.
|
||||
- [x] #2 The release workflow clones `ssh://aur@aur.archlinux.org/subminer-bin.git` with a dedicated secret-backed SSH key only after release artifacts are ready.
|
||||
- [x] #3 The workflow updates `pkgver`, regenerates `sha256sums` from the built release artifacts, regenerates `.SRCINFO`, and pushes only when packaging files changed.
|
||||
- [x] #4 Regression coverage fails if the AUR publish job, secret contract, or update steps are removed from the release workflow.
|
||||
- [x] #5 Release docs mention the required `AUR_SSH_PRIVATE_KEY` setup and the new tagged-release side effect.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Record the approved design and implementation plan for direct AUR publishing from the release workflow.
|
||||
2. Add failing release workflow regression tests covering the new AUR publish job, SSH secret, and PKGBUILD/.SRCINFO regeneration steps.
|
||||
3. Reintroduce repo-tracked `packaging/aur/subminer-bin` source files as the maintained AUR template.
|
||||
4. Add a small helper script that updates `pkgver`, computes checksums from release artifacts, and regenerates `.SRCINFO` deterministically.
|
||||
5. Extend `.github/workflows/release.yml` with an AUR publish job that clones the AUR repo over SSH, runs the helper, commits only when needed, and pushes to `aur`.
|
||||
6. Update release docs for the new secret/setup requirements and tagged-release behavior.
|
||||
7. Run targeted workflow tests plus the SubMiner verification lane needed for workflow/docs changes, then update this task with results.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added repo-tracked AUR packaging source under `packaging/aur/subminer-bin/` plus `scripts/update-aur-package.sh` to stamp `pkgver`, compute SHA-256 sums from release assets, and regenerate `.SRCINFO` with `makepkg --printsrcinfo`.
|
||||
|
||||
Extended `.github/workflows/release.yml` with a terminal `aur-publish` job that runs after `release`, validates `AUR_SSH_PRIVATE_KEY`, installs `makepkg`, configures SSH/known_hosts, clones `ssh://aur@aur.archlinux.org/subminer-bin.git`, downloads the just-published `SubMiner-<version>.AppImage`, `subminer`, and `subminer-assets.tar.gz` assets, updates packaging metadata, and pushes only when `PKGBUILD` or `.SRCINFO` changed.
|
||||
|
||||
Updated `src/release-workflow.test.ts` with regression assertions for the AUR publish contract and updated `docs/RELEASING.md` with the new secret/setup requirement.
|
||||
|
||||
Verification run:
|
||||
- `bun test src/release-workflow.test.ts src/ci-workflow.test.ts`
|
||||
- `bash -n scripts/update-aur-package.sh && bash -n packaging/aur/subminer-bin/PKGBUILD`
|
||||
- `cd packaging/aur/subminer-bin && makepkg --printsrcinfo > .SRCINFO`
|
||||
- updater smoke via temp package dir with fake assets and `v9.9.9`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `git submodule update --init --recursive` (required because the worktree lacked release submodules)
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
|
||||
Docs update required: yes, completed in `docs/RELEASING.md`.
|
||||
Changelog fragment required: no; internal release automation only.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Tagged releases now attempt a direct AUR sync for `subminer-bin` using a dedicated SSH private key stored in `AUR_SSH_PRIVATE_KEY`. The release workflow clones the AUR repo after GitHub Release publication, rewrites `PKGBUILD` and `.SRCINFO` from the published release assets, and skips empty pushes. Repo-owned packaging source and workflow regression coverage were added so the automation remains reviewable and testable.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
4
changes/2026-03-14-aur-release-sync.md
Normal file
4
changes/2026-03-14-aur-release-sync.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: release
|
||||
|
||||
- Automate `subminer-bin` AUR package updates from the tagged release workflow.
|
||||
@@ -20,3 +20,5 @@ Notes:
|
||||
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# SubMiner Change Verification Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a SubMiner-specific skill that agents can use to verify code changes with automated checks. The skill must support both targeted regression testing during debugging and pre-handoff verification before final response.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-change-verification`
|
||||
- **Trigger:** Use when working in the SubMiner repo and you need to verify code changes actually work, especially for launcher, mpv, plugin, overlay, runtime, Electron, or env-sensitive behavior.
|
||||
- **Default posture:** cheap-first; prefer repo-native tests and narrow lanes before broader or GUI-dependent verification.
|
||||
- **Outputs:**
|
||||
- verification summary
|
||||
- exact commands run
|
||||
- artifact paths for logs, captured summaries, and preserved temp state on failures
|
||||
- skipped lanes and blockers
|
||||
- **Non-goals:**
|
||||
- replacing the repo's native tests
|
||||
- launching real GUI apps for every change
|
||||
- default visual regression or pixel-diff workflows
|
||||
|
||||
## Lane Selection
|
||||
|
||||
The skill chooses lanes from the diff or explicit file list.
|
||||
|
||||
- **`docs`**
|
||||
- For `docs-site/`, `docs/`, and similar documentation-only changes.
|
||||
- Prefer `bun run docs:test` and `bun run docs:build`.
|
||||
- **`config`**
|
||||
- For `src/config/`, config example generation/verification paths, and config-template-sensitive changes.
|
||||
- Prefer `bun run test:config`.
|
||||
- **`core`**
|
||||
- For general source-level changes where type safety and the fast maintained lane are the best cheap signal.
|
||||
- Prefer `bun run typecheck` and `bun run test:fast`.
|
||||
- **`launcher-plugin`**
|
||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||
- Prefer `bun run test:launcher:smoke:src` and `bun run test:plugin:src`.
|
||||
- **`runtime-compat`**
|
||||
- For runtime/composition/bundled behavior where dist-sensitive validation matters.
|
||||
- Prefer `bun run build`, `bun run test:runtime:compat`, and `bun run test:smoke:dist`.
|
||||
- **`real-gui`**
|
||||
- Reserved for cases where actual Electron/mpv/window behavior must be validated.
|
||||
- Not part of the default lane set; the classifier marks these changes as candidates so the agent can escalate deliberately.
|
||||
|
||||
## Escalation Rules
|
||||
|
||||
1. Start with the narrowest lane that credibly exercises the changed behavior.
|
||||
2. If a narrow lane fails in a way that suggests broader fallout, expand once.
|
||||
3. If a change touches launcher/mpv/plugin/runtime/overlay/window tracking paths, include the relevant specialized lanes before falling back to broad suites.
|
||||
4. Treat real GUI/mpv verification as opt-in escalation:
|
||||
- use only when cheaper evidence is insufficient
|
||||
- allow for platform/display/permission blockers
|
||||
- report skipped/blocker states explicitly
|
||||
|
||||
## Helper Script Design
|
||||
|
||||
The skill uses two small shell helpers:
|
||||
|
||||
- **`scripts/classify_subminer_diff.sh`**
|
||||
- Accepts explicit paths or discovers local changes from git.
|
||||
- Emits lane suggestions and flags in a simple line-oriented format.
|
||||
- Marks real GUI-sensitive paths as `flag:real-gui-candidate` instead of forcing GUI execution.
|
||||
- **`scripts/verify_subminer_change.sh`**
|
||||
- Creates an artifact directory under `.tmp/skill-verification/<timestamp>/`.
|
||||
- Selects lanes from the classifier unless lanes are supplied explicitly.
|
||||
- Runs repo-native commands in a stable order and captures stdout/stderr per step.
|
||||
- Writes a compact `summary.json` and a human-readable `summary.txt`.
|
||||
- Skips real GUI verification unless explicitly enabled.
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
Each invocation should create:
|
||||
|
||||
- `summary.json`
|
||||
- `summary.txt`
|
||||
- `classification.txt`
|
||||
- `env.txt`
|
||||
- `lanes.txt`
|
||||
- `steps.tsv`
|
||||
- `steps/<step>.stdout.log`
|
||||
- `steps/<step>.stderr.log`
|
||||
|
||||
Failures should preserve the artifact directory and identify the exact failing command and log paths.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
1. Inspect changed files or requested area.
|
||||
2. Classify the change into verification lanes.
|
||||
3. Run the cheapest sufficient lane set.
|
||||
4. Escalate only if evidence is insufficient.
|
||||
5. Escalate to real GUI/mpv only for actual Electron/mpv/window behavior claims.
|
||||
6. Return a short report with:
|
||||
- pass/fail/skipped per lane
|
||||
- exact commands run
|
||||
- artifact paths
|
||||
- blockers/gaps
|
||||
|
||||
## Initial Implementation Scope
|
||||
|
||||
- Ship the skill entrypoint plus the classifier/verifier helpers.
|
||||
- Make real GUI verification an explicit future hook rather than a default workflow.
|
||||
- Verify the new skill locally with representative classifier output and artifact generation.
|
||||
@@ -1,111 +0,0 @@
|
||||
# SubMiner Scrum Master Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a repo-local skill that can take incoming requests, bugs, or issues in the SubMiner repo, decide whether backlog tracking is warranted, create or update backlog work when appropriate, plan the implementation, dispatch one or more subagents, and ensure verification happens before handoff.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-scrum-master`
|
||||
- **Location:** `.agents/skills/subminer-scrum-master/`
|
||||
- **Use when:** the user gives a feature request, bug report, issue, refactor, or implementation ask in the SubMiner repo and the agent should own intake, planning, backlog hygiene, dispatch, and completion flow.
|
||||
- **Responsibilities:**
|
||||
- assess whether backlog tracking is warranted
|
||||
- if needed, search/update/create proper backlog structure
|
||||
- write a plan before dispatching coding work
|
||||
- choose sequential vs parallel execution
|
||||
- assign explicit ownership to workers
|
||||
- require verification before final handoff
|
||||
- **Limits:**
|
||||
- not the default code implementer unless delegation would be wasteful
|
||||
- no overlapping parallel write scopes
|
||||
- no skipping planning before dispatch
|
||||
- no skipping verification
|
||||
- must pause for ambiguous, risky, or external side-effect work
|
||||
|
||||
## Backlog Decision Rules
|
||||
|
||||
Backlog use is conditional, not mandatory.
|
||||
|
||||
- **Skip backlog when:**
|
||||
- question only
|
||||
- obvious mechanical edit
|
||||
- tiny isolated change with no real planning
|
||||
- **Use backlog when:**
|
||||
- implementation requires planning
|
||||
- scope/approach needs decisions
|
||||
- multiple phases or subsystems
|
||||
- likely subagent dispatch
|
||||
- work should remain traceable
|
||||
|
||||
When backlog is used:
|
||||
- search first
|
||||
- update existing matching work when appropriate
|
||||
- otherwise create standalone task or parent task
|
||||
- use parent + subtasks for multi-part work
|
||||
- record the implementation plan before coding starts
|
||||
|
||||
## Orchestration Policy
|
||||
|
||||
The skill orchestrates; workers implement.
|
||||
|
||||
- **No dispatch** for trivial/mechanical work
|
||||
- **Single worker** for focused single-scope work
|
||||
- **Parallel workers** only for clearly disjoint scopes
|
||||
- **Sequential flow** for shared files, runtime coupling, or unclear boundaries
|
||||
|
||||
Every worker should receive:
|
||||
- owned files/modules
|
||||
- explicit reminder not to revert unrelated edits
|
||||
- requirement to report changed files, tests run, and blockers
|
||||
|
||||
## Verification Policy
|
||||
|
||||
Every nontrivial code task gets verification.
|
||||
|
||||
- prefer `subminer-change-verification`
|
||||
- use cheap-first lanes
|
||||
- escalate only when needed
|
||||
- accept worker-run verification only if it is clearly relevant and sufficient
|
||||
- run a consolidating final verification pass when the scrum master needs stronger evidence
|
||||
|
||||
## Representative Flows
|
||||
|
||||
### Trivial fix, no backlog
|
||||
|
||||
1. assess request as mechanical or narrowly reversible
|
||||
2. skip backlog
|
||||
3. keep a short internal plan
|
||||
4. implement directly or use one worker if helpful
|
||||
5. run targeted verification
|
||||
6. report concise summary
|
||||
|
||||
### Single-task implementation
|
||||
|
||||
1. search backlog
|
||||
2. create/update one task
|
||||
3. record plan
|
||||
4. dispatch one worker
|
||||
5. integrate result
|
||||
6. run verification
|
||||
7. update task and report outcome
|
||||
|
||||
### Multi-part feature
|
||||
|
||||
1. search backlog
|
||||
2. create/update parent task
|
||||
3. create subtasks for distinct deliverables/phases
|
||||
4. record sequencing in the plan
|
||||
5. dispatch workers only where write scopes do not overlap
|
||||
6. integrate
|
||||
7. run consolidated verification
|
||||
8. update task state and report outcome
|
||||
|
||||
## V1 Scope
|
||||
|
||||
- instruction-heavy `SKILL.md`
|
||||
- no helper scripts unless orchestration becomes too repetitive
|
||||
- strong coordination with existing Backlog workflow and `subminer-change-verification`
|
||||
@@ -1,110 +0,0 @@
|
||||
# Overlay Controller Support Design
|
||||
|
||||
**Date:** 2026-03-11
|
||||
**Backlog:** `TASK-159`
|
||||
|
||||
## Goal
|
||||
|
||||
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
|
||||
|
||||
## Scope
|
||||
|
||||
- Poll connected gamepads from the visible overlay renderer.
|
||||
- Default to the first connected controller unless config specifies a preferred controller.
|
||||
- Add logical controller bindings and tuning knobs to config.
|
||||
- Add `Alt+C` controller selection modal.
|
||||
- Add `Alt+Shift+C` controller debug modal.
|
||||
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
|
||||
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
|
||||
|
||||
Out of scope for this pass:
|
||||
|
||||
- Raw arbitrary axis/button index remapping in config.
|
||||
- Controller support outside the visible overlay renderer.
|
||||
- Haptics or vibration.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
|
||||
|
||||
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
|
||||
|
||||
## Behavior
|
||||
|
||||
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
|
||||
|
||||
Default logical mappings:
|
||||
|
||||
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
|
||||
- left stick horizontal: move token selection left/right
|
||||
- right stick vertical: smooth Yomitan popup/window scroll
|
||||
- right stick horizontal: jump horizontally inside Yomitan popup/window
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `L1` / `R1`: previous / next Yomitan audio
|
||||
- `R2`: activate current Yomitan audio button
|
||||
- `L2`: toggle mpv play/pause
|
||||
|
||||
Selection-highlight cleanup:
|
||||
|
||||
- disabling keyboard-only mode clears the selected token class immediately
|
||||
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
|
||||
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
|
||||
|
||||
## Config
|
||||
|
||||
Add a top-level `controller` block in resolved config with:
|
||||
|
||||
- `enabled`
|
||||
- `preferredGamepadId`
|
||||
- `preferredGamepadLabel`
|
||||
- `smoothScroll`
|
||||
- `scrollPixelsPerSecond`
|
||||
- `horizontalJumpPixels`
|
||||
- `stickDeadzone`
|
||||
- `triggerDeadzone`
|
||||
- `repeatDelayMs`
|
||||
- `repeatIntervalMs`
|
||||
- `bindings` logical fields for the named actions/sticks
|
||||
|
||||
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
|
||||
|
||||
## UI
|
||||
|
||||
Controller selection modal:
|
||||
|
||||
- overlay-hosted modal in the visible renderer
|
||||
- lists currently connected controllers
|
||||
- highlights current active choice
|
||||
- selecting one persists config and makes it the active controller immediately if connected
|
||||
|
||||
Controller debug modal:
|
||||
|
||||
- overlay-hosted modal
|
||||
- shows selected controller and all connected controllers
|
||||
- live raw axis array values
|
||||
- live raw button values, pressed flags, and touched flags if available
|
||||
|
||||
## Testing
|
||||
|
||||
Test first:
|
||||
|
||||
- controller gating outside keyboard-only mode
|
||||
- logical mapping to existing helpers
|
||||
- continuous stick scroll and repeat behavior
|
||||
- modal open shortcuts
|
||||
- preferred-controller selection persistence
|
||||
- highlight cleanup on keyboard-only disable and popup close
|
||||
- config defaults/parse/template generation coverage
|
||||
|
||||
## Risks
|
||||
|
||||
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
|
||||
Mitigation: match by exact preferred id first; fall back to first connected controller.
|
||||
- Continuous stick input can spam actions.
|
||||
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
|
||||
- Popup DOM/audio controls may vary.
|
||||
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# Overlay Controller Support Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
|
||||
|
||||
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Track work and lock the design
|
||||
|
||||
**Files:**
|
||||
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
|
||||
|
||||
**Step 1: Record the approved scope**
|
||||
|
||||
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
|
||||
|
||||
**Step 2: Verify the written scope matches the approved design**
|
||||
|
||||
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
|
||||
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
|
||||
|
||||
### Task 2: Add failing config tests and defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/config.test.ts`
|
||||
- Modify: `src/config/definitions/defaults-core.ts`
|
||||
- Modify: `src/config/definitions/options-core.ts`
|
||||
- Modify: `src/config/definitions/template-sections.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `config.example.jsonc`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: FAIL because `controller` config is not defined yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Add failing keyboard-selection cleanup tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/handlers/keyboard.test.ts`
|
||||
- Modify: `src/renderer/handlers/keyboard.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- turning keyboard-only mode off clears `.keyboard-selected`
|
||||
- closing the popup clears stale selection highlight when keyboard-only mode is off
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: FAIL because selection cleanup is incomplete today.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Add failing controller runtime tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- Create: `src/renderer/handlers/gamepad-controller.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- first connected controller is selected by default
|
||||
- preferred controller wins when connected
|
||||
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
|
||||
- stick/button mappings invoke the expected logical helpers
|
||||
- smooth scroll and repeat throttling behavior
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: FAIL because controller runtime does not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: Add failing controller modal tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/index.html`
|
||||
- Modify: `src/renderer/style.css`
|
||||
- Create: `src/renderer/modals/controller-select.ts`
|
||||
- Create: `src/renderer/modals/controller-select.test.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.test.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `Alt+C` opens controller selection modal
|
||||
- `Alt+Shift+C` opens controller debug modal
|
||||
- selection modal renders connected controllers and persists the chosen device
|
||||
- debug modal shows live axes/buttons state
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: FAIL because modals and shortcuts do not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: Persist controller preference through preload/main wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/preload.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/shared/ipc/contracts.ts`
|
||||
- Modify: `src/core/services/ipc.ts`
|
||||
- Modify: `src/main.ts`
|
||||
- Modify: related main/runtime tests as needed
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: FAIL because no controller preference IPC exists yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 7: Update docs and config example
|
||||
|
||||
**Files:**
|
||||
- Modify: `config.example.jsonc`
|
||||
- Modify: `README.md`
|
||||
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
|
||||
|
||||
**Step 1: Write the failing doc/config check if needed**
|
||||
|
||||
If config example generation is covered by tests, add/refresh the failing assertion first.
|
||||
|
||||
**Step 2: Implement the docs**
|
||||
|
||||
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
|
||||
|
||||
**Step 3: Run doc/config verification**
|
||||
|
||||
Run: `bun run test:config`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 8: Run the handoff gate and update the backlog task
|
||||
|
||||
**Files:**
|
||||
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
|
||||
**Step 1: Run targeted verification**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun test src/config/config.test.ts`
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- `bun test src/renderer/modals/controller-select.test.ts`
|
||||
- `bun test src/renderer/modals/controller-debug.test.ts`
|
||||
- `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run broader gate**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
|
||||
Expected: PASS, or document exact blockers/failures.
|
||||
|
||||
**Step 3: Update backlog notes**
|
||||
|
||||
Fill in implementation notes, verification commands, and final summary in `TASK-159`.
|
||||
@@ -17,7 +17,9 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
|
||||
export function readExternalYomitanProfilePath(root: Record<string, unknown> | null): string | null {
|
||||
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>)
|
||||
|
||||
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
@@ -0,0 +1,36 @@
|
||||
pkgbase = subminer-bin
|
||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
||||
pkgver = 0.6.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/ksyasuda/SubMiner
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
depends = bun
|
||||
depends = fuse2
|
||||
depends = glibc
|
||||
depends = mpv
|
||||
depends = zlib-ng-compat
|
||||
optdepends = ffmpeg: media extraction and screenshot generation
|
||||
optdepends = ffmpegthumbnailer: faster thumbnail previews in the launcher
|
||||
optdepends = fzf: terminal media picker in the subminer wrapper
|
||||
optdepends = rofi: GUI media picker in the subminer wrapper
|
||||
optdepends = chafa: image previews in the fzf picker
|
||||
optdepends = yt-dlp: YouTube playback and subtitle extraction
|
||||
optdepends = mecab: optional Japanese metadata enrichment
|
||||
optdepends = mecab-ipadic: dictionary for MeCab metadata enrichment
|
||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
||||
optdepends = alass-git: preferred subtitle synchronization engine
|
||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
||||
provides = subminer=0.6.2
|
||||
conflicts = subminer
|
||||
noextract = SubMiner-0.6.2.AppImage
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
||||
source = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
||||
source = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
||||
|
||||
pkgname = subminer-bin
|
||||
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
@@ -0,0 +1,64 @@
|
||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
||||
|
||||
pkgname=subminer-bin
|
||||
pkgver=0.6.2
|
||||
pkgrel=1
|
||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/ksyasuda/SubMiner'
|
||||
license=('GPL-3.0-or-later')
|
||||
options=('!strip' '!debug')
|
||||
depends=(
|
||||
'bun'
|
||||
'fuse2'
|
||||
'glibc'
|
||||
'mpv'
|
||||
'zlib-ng-compat'
|
||||
)
|
||||
optdepends=(
|
||||
'ffmpeg: media extraction and screenshot generation'
|
||||
'ffmpegthumbnailer: faster thumbnail previews in the launcher'
|
||||
'fzf: terminal media picker in the subminer wrapper'
|
||||
'rofi: GUI media picker in the subminer wrapper'
|
||||
'chafa: image previews in the fzf picker'
|
||||
'yt-dlp: YouTube playback and subtitle extraction'
|
||||
'mecab: optional Japanese metadata enrichment'
|
||||
'mecab-ipadic: dictionary for MeCab metadata enrichment'
|
||||
'python-guessit: improved AniSkip title and episode inference'
|
||||
'alass-git: preferred subtitle synchronization engine'
|
||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
||||
)
|
||||
provides=("subminer=${pkgver}")
|
||||
conflicts=('subminer')
|
||||
source=(
|
||||
"SubMiner-${pkgver}.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/SubMiner-${pkgver}.AppImage"
|
||||
"subminer::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer"
|
||||
"subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
||||
)
|
||||
noextract=("SubMiner-${pkgver}.AppImage")
|
||||
|
||||
package() {
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
|
||||
install -Dm755 "${srcdir}/SubMiner-${pkgver}.AppImage" \
|
||||
"${pkgdir}/opt/SubMiner/SubMiner.AppImage"
|
||||
install -dm755 "${pkgdir}/opt/SubMiner"
|
||||
ln -s '/opt/SubMiner/SubMiner.AppImage' "${pkgdir}/usr/bin/SubMiner.AppImage"
|
||||
|
||||
install -Dm755 "${srcdir}/subminer" "${pkgdir}/usr/bin/subminer"
|
||||
|
||||
install -Dm644 "${srcdir}/config.example.jsonc" \
|
||||
"${pkgdir}/usr/share/SubMiner/config.example.jsonc"
|
||||
install -Dm644 "${srcdir}/plugin/subminer.conf" \
|
||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||
}
|
||||
124
scripts/update-aur-package.sh
Executable file
124
scripts/update-aur-package.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
|
||||
EOF
|
||||
}
|
||||
|
||||
pkg_dir=
|
||||
version=
|
||||
appimage=
|
||||
wrapper=
|
||||
assets=
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pkg-dir)
|
||||
pkg_dir="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
version="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--appimage)
|
||||
appimage="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--wrapper)
|
||||
wrapper="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--assets)
|
||||
assets="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${version#v}"
|
||||
pkgbuild="${pkg_dir}/PKGBUILD"
|
||||
|
||||
if [[ ! -f "$pkgbuild" ]]; then
|
||||
echo "Missing PKGBUILD at $pkgbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for artifact in "$appimage" "$wrapper" "$assets"; do
|
||||
if [[ ! -f "$artifact" ]]; then
|
||||
echo "Missing artifact: $artifact" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
awk \
|
||||
-v version="$version" \
|
||||
-v sum_appimage="${sha256sums[0]}" \
|
||||
-v sum_wrapper="${sha256sums[1]}" \
|
||||
-v sum_assets="${sha256sums[2]}" \
|
||||
'
|
||||
BEGIN {
|
||||
in_sha_block = 0
|
||||
found_pkgver = 0
|
||||
found_sha_block = 0
|
||||
}
|
||||
/^pkgver=/ {
|
||||
print "pkgver=" version
|
||||
found_pkgver = 1
|
||||
next
|
||||
}
|
||||
/^sha256sums=\(/ {
|
||||
print "sha256sums=("
|
||||
print "\047" sum_appimage "\047"
|
||||
print "\047" sum_wrapper "\047"
|
||||
print "\047" sum_assets "\047"
|
||||
in_sha_block = 1
|
||||
next
|
||||
}
|
||||
in_sha_block {
|
||||
if ($0 ~ /^\)/) {
|
||||
print ")"
|
||||
in_sha_block = 0
|
||||
found_sha_block = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!found_pkgver) {
|
||||
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_sha_block) {
|
||||
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$pkgbuild" > "$tmpfile"
|
||||
mv "$tmpfile" "$pkgbuild"
|
||||
|
||||
(
|
||||
cd "$pkg_dir"
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
)
|
||||
@@ -1195,14 +1195,32 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
|
||||
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
|
||||
assert.equal(
|
||||
config.controller.scrollPixelsPerSecond,
|
||||
DEFAULT_CONFIG.controller.scrollPixelsPerSecond,
|
||||
);
|
||||
assert.equal(
|
||||
config.controller.horizontalJumpPixels,
|
||||
DEFAULT_CONFIG.controller.horizontalJumpPixels,
|
||||
);
|
||||
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
|
||||
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.repeatDelayMs'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('controller button index config rejects fractional values', () => {
|
||||
@@ -1224,12 +1242,18 @@ test('controller button index config rejects fractional values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||
assert.equal(
|
||||
config.controller.buttonIndices.select,
|
||||
DEFAULT_CONFIG.controller.buttonIndices.select,
|
||||
);
|
||||
assert.equal(
|
||||
config.controller.buttonIndices.leftStickPress,
|
||||
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
||||
);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.select'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||
true,
|
||||
|
||||
@@ -74,13 +74,15 @@ export function buildCoreConfigOptionRegistry(
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'digital', 'analog'],
|
||||
defaultValue: defaultConfig.controller.triggerInputMode,
|
||||
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||
description:
|
||||
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||
},
|
||||
{
|
||||
path: 'controller.triggerDeadzone',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.triggerDeadzone,
|
||||
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
||||
description:
|
||||
'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatDelayMs',
|
||||
|
||||
@@ -17,7 +17,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||
const controllerAxisBindings = [
|
||||
'leftStickX',
|
||||
'leftStickY',
|
||||
'rightStickX',
|
||||
'rightStickY',
|
||||
] as const;
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
@@ -178,7 +183,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
if (value !== undefined && Math.floor(value) > 0) {
|
||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +198,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
if (value !== undefined && value >= 0 && value <= 1) {
|
||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected number between 0 and 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -467,16 +467,16 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
}, /Invalid controller preference payload/);
|
||||
await saveHandler!(
|
||||
{},
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerSaves, [
|
||||
{
|
||||
@@ -570,10 +570,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
}, /Invalid controller preference payload/);
|
||||
});
|
||||
|
||||
@@ -265,13 +265,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.saveSubtitlePosition(parsedPosition);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
});
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.command.saveControllerPreference,
|
||||
async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
|
||||
@@ -55,8 +55,9 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
|
||||
|
||||
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'),
|
||||
const resolved = resolveExternalYomitanExtensionPath(
|
||||
profilePath,
|
||||
(candidate) => candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
||||
);
|
||||
|
||||
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
||||
|
||||
@@ -25,9 +25,7 @@ export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDe
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
}
|
||||
|
||||
export function clearYomitanExtensionRuntimeState(
|
||||
deps: YomitanExtensionRuntimeStateDeps,
|
||||
): void {
|
||||
export function clearYomitanExtensionRuntimeState(deps: YomitanExtensionRuntimeStateDeps): void {
|
||||
clearYomitanParserRuntimeState(deps);
|
||||
deps.setYomitanExtension(null);
|
||||
deps.setYomitanSession(null);
|
||||
|
||||
23
src/main.ts
23
src/main.ts
@@ -694,7 +694,8 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
isExternalYomitanConfigured: () =>
|
||||
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
detectPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
@@ -3117,8 +3118,7 @@ function initializeOverlayRuntime(): void {
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message =
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
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.',
|
||||
);
|
||||
@@ -3572,11 +3572,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
@@ -3704,12 +3704,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({
|
||||
yomitanExt,
|
||||
getExistingWindow,
|
||||
setWindow,
|
||||
yomitanSession,
|
||||
}) => {
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: yomitanExt as Extension,
|
||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||
|
||||
@@ -279,7 +279,11 @@ export function createFirstRunSetupService(deps: {
|
||||
});
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
|
||||
!(
|
||||
state.yomitanSetupMode === 'external' &&
|
||||
!externalYomitanConfigured &&
|
||||
!yomitanSetupSatisfied
|
||||
)
|
||||
) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as path from 'path';
|
||||
|
||||
function redactSkippedYomitanWriteValue(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
actionName:
|
||||
| 'importYomitanDictionary'
|
||||
| 'deleteYomitanDictionary'
|
||||
| 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
const trimmed = rawValue.trim();
|
||||
@@ -18,7 +21,10 @@ function redactSkippedYomitanWriteValue(
|
||||
}
|
||||
|
||||
export function formatSkippedYomitanWriteAction(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
actionName:
|
||||
| 'importYomitanDictionary'
|
||||
| 'deleteYomitanDictionary'
|
||||
| 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||
|
||||
@@ -9,7 +9,11 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||
openYomitanSettingsWindow: ({
|
||||
getExistingWindow,
|
||||
setWindow,
|
||||
yomitanSession: forwardedSession,
|
||||
}) => {
|
||||
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||
const current = getExistingWindow();
|
||||
if (!current) {
|
||||
@@ -54,5 +58,7 @@ test('yomitan settings runtime warns and does not open when no yomitan session i
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(existingWindow, null);
|
||||
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Unable to open Yomitan settings: Yomitan session is unavailable.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,25 @@ test('windows release workflow publishes unsigned artifacts directly without Sig
|
||||
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
||||
});
|
||||
|
||||
test('release workflow publishes subminer-bin to AUR from tagged release artifacts', () => {
|
||||
assert.match(releaseWorkflow, /aur-publish:/);
|
||||
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
|
||||
assert.match(releaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
||||
assert.match(releaseWorkflow, /ssh:\/\/aur@aur\.archlinux\.org\/subminer-bin\.git/);
|
||||
assert.match(releaseWorkflow, /Install makepkg/);
|
||||
assert.match(releaseWorkflow, /scripts\/update-aur-package\.sh/);
|
||||
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/);
|
||||
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
|
||||
assert.doesNotMatch(
|
||||
releaseWorkflow,
|
||||
/SubMiner-\$\{\{ steps\.version\.outputs\.VERSION \}\}\.AppImage/,
|
||||
);
|
||||
});
|
||||
|
||||
test('release workflow skips empty AUR sync commits', () => {
|
||||
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
|
||||
});
|
||||
|
||||
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
|
||||
assert.match(
|
||||
makefile,
|
||||
|
||||
@@ -25,20 +25,17 @@ test('controller status indicator shows once when a controller is first detected
|
||||
classList,
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
durationMs: 1500,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, callback);
|
||||
return id as never;
|
||||
},
|
||||
clearTimeout: (id) => {
|
||||
scheduled.delete(id as never as number);
|
||||
},
|
||||
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
|
||||
durationMs: 1500,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, callback);
|
||||
return id as never;
|
||||
},
|
||||
);
|
||||
clearTimeout: (id) => {
|
||||
scheduled.delete(id as never as number);
|
||||
},
|
||||
});
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [],
|
||||
@@ -78,13 +75,10 @@ test('controller status indicator announces newly detected controllers after sta
|
||||
classList: createClassList(['hidden']),
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
);
|
||||
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
|
||||
@@ -58,7 +58,9 @@ export function createControllerStatusIndicator(
|
||||
(device) => device.id === snapshot.activeGamepadId,
|
||||
);
|
||||
const announcedDevice =
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ??
|
||||
newDevices[0] ??
|
||||
activeDevice;
|
||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,11 @@ function createControllerConfig(
|
||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||
} = {},
|
||||
): ResolvedControllerConfig {
|
||||
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||
overrides;
|
||||
const {
|
||||
bindings: bindingOverrides,
|
||||
buttonIndices: buttonIndexOverrides,
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
@@ -90,7 +93,11 @@ function createControllerConfig(
|
||||
test('gamepad controller selects the first connected controller by default', () => {
|
||||
const updates: string[] = [];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
|
||||
getGamepads: () => [
|
||||
null,
|
||||
createGamepad('pad-2', { index: 1 }),
|
||||
createGamepad('pad-3', { index: 2 }),
|
||||
],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
@@ -310,13 +317,12 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () =>
|
||||
[
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getGamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
@@ -352,7 +358,10 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
|
||||
assert.equal(calls.includes('prev-audio'), false);
|
||||
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
||||
assert.equal(calls.includes('quit-mpv'), true);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
|
||||
assert.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-67],
|
||||
);
|
||||
assert.equal(calls.includes('jump:160'), true);
|
||||
});
|
||||
|
||||
@@ -492,7 +501,10 @@ test('gamepad controller maps d-pad left/right to selection and d-pad up/down to
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
assert.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-90],
|
||||
);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||
@@ -524,7 +536,10 @@ test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll',
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
assert.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-90],
|
||||
);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||
|
||||
@@ -159,10 +159,7 @@ function resolveDpadValue(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadAxisValue(
|
||||
gamepad: GamepadLike,
|
||||
axisIndex: number,
|
||||
): number {
|
||||
function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
|
||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||
if (Math.abs(value) < 0.5) {
|
||||
return 0;
|
||||
@@ -175,7 +172,12 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||
return resolveDpadValue(
|
||||
gamepad,
|
||||
DPAD_BUTTON_INDEX.left,
|
||||
DPAD_BUTTON_INDEX.right,
|
||||
triggerDeadzone,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
@@ -201,7 +203,12 @@ function createHoldState(): HoldState {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||
function shouldFireHeldAction(
|
||||
state: HoldState,
|
||||
now: number,
|
||||
repeatDelayMs: number,
|
||||
repeatIntervalMs: number,
|
||||
): boolean {
|
||||
if (!state.initialFired) {
|
||||
state.initialFired = true;
|
||||
state.lastFireAt = now;
|
||||
@@ -305,11 +312,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(selectionHold);
|
||||
@@ -327,11 +330,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -418,9 +417,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
|
||||
const interactionAllowed =
|
||||
config.enabled &&
|
||||
options.getKeyboardModeEnabled() &&
|
||||
!options.getInteractionBlocked();
|
||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
|
||||
@@ -3,10 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
type?: string;
|
||||
@@ -478,14 +475,11 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
|
||||
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
||||
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.slice(-3),
|
||||
[
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(testGlobals.commandEvents.slice(-3), [
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -531,7 +525,8 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
|
||||
});
|
||||
|
||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
@@ -187,7 +187,9 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||
function clearKeyboardSelectedWordClasses(
|
||||
wordNodes: HTMLElement[] = getSubtitleWordNodes(),
|
||||
): void {
|
||||
for (const wordNode of wordNodes) {
|
||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,10 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
||||
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
||||
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
||||
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
|
||||
assert.match(
|
||||
ctx.dom.controllerDebugButtons.textContent,
|
||||
/button\[0\] value=1\.000 pressed=true/,
|
||||
);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
||||
@@ -224,8 +227,14 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
|
||||
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
|
||||
assert.match(
|
||||
ctx.dom.controllerDebugStatus.textContent,
|
||||
/Copied controller buttonIndices config/,
|
||||
);
|
||||
assert.match(
|
||||
ctx.dom.controllerDebugToast.textContent,
|
||||
/Copied controller buttonIndices config/,
|
||||
);
|
||||
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
|
||||
@@ -18,21 +18,19 @@ function formatButtons(
|
||||
}
|
||||
|
||||
function formatButtonIndices(
|
||||
value:
|
||||
| {
|
||||
select: number;
|
||||
buttonSouth: number;
|
||||
buttonEast: number;
|
||||
buttonNorth: number;
|
||||
buttonWest: number;
|
||||
leftShoulder: number;
|
||||
rightShoulder: number;
|
||||
leftStickPress: number;
|
||||
rightStickPress: number;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
}
|
||||
| null,
|
||||
value: {
|
||||
select: number;
|
||||
buttonSouth: number;
|
||||
buttonEast: number;
|
||||
buttonNorth: number;
|
||||
buttonWest: number;
|
||||
leftShoulder: number;
|
||||
rightShoulder: number;
|
||||
leftStickPress: number;
|
||||
rightStickPress: number;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
} | null,
|
||||
): string {
|
||||
if (!value) {
|
||||
return 'No controller config loaded.';
|
||||
@@ -97,7 +95,9 @@ export function createControllerDebugModal(
|
||||
);
|
||||
setStatus(
|
||||
activeDevice?.id ??
|
||||
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||
(ctx.state.connectedGamepads.length > 0
|
||||
? 'Controller connected.'
|
||||
: 'No controller detected.'),
|
||||
);
|
||||
ctx.dom.controllerDebugSummary.textContent =
|
||||
ctx.state.connectedGamepads.length > 0
|
||||
|
||||
@@ -45,7 +45,9 @@ export function createControllerSelectModal(
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === preferredId,
|
||||
);
|
||||
if (preferredIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
||||
syncSelectedControllerId();
|
||||
|
||||
@@ -63,8 +63,7 @@ body {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(138, 213, 202, 0.45);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||
color: rgba(228, 255, 251, 0.98);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -166,7 +166,9 @@ export function resolveRendererDom(): RendererDom {
|
||||
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
||||
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
||||
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
||||
'controllerDebugButtonIndices',
|
||||
),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
|
||||
@@ -223,7 +223,10 @@ export function ensureDefaultConfigBootstrap(options: {
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
||||
|
||||
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
|
||||
if (
|
||||
existsSync(options.configFilePaths.jsoncPath) ||
|
||||
existsSync(options.configFilePaths.jsonPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user