Automate AUR publish in tagged release workflow (#22)

This commit is contained in:
2026-03-14 19:49:46 -07:00
committed by GitHub
parent 99f4d2baaf
commit 9eed37420e
36 changed files with 641 additions and 722 deletions

View File

@@ -317,7 +317,7 @@ jobs:
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
@@ -362,3 +362,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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 assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
await saveHandler!({}, {
}, /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 assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
}, /Invalid controller preference payload/);
});

View File

@@ -265,13 +265,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.saveSubtitlePosition(parsedPosition);
});
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
ipc.handle(
IPC_CHANNELS.command.saveControllerPreference,
async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller preference payload');
}
await deps.saveControllerPreference(parsedUpdate);
});
},
);
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,9 +25,7 @@ test('controller status indicator shows once when a controller is first detected
classList,
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
durationMs: 1500,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
@@ -37,8 +35,7 @@ test('controller status indicator shows once when a controller is first detected
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,
{
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
setTimeout: () => 1 as never,
clearTimeout: () => {},
},
);
});
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,10 @@ test('controller debug modal renders active controller axes, buttons, and config
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
assert.match(
ctx.dom.controllerDebugButtons.textContent,
/button\[0\] value=1\.000 pressed=true/,
);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
@@ -224,8 +227,14 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
assert.match(
ctx.dom.controllerDebugStatus.textContent,
/Copied controller buttonIndices config/,
);
assert.match(
ctx.dom.controllerDebugToast.textContent,
/Copied controller buttonIndices config/,
);
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });

View File

@@ -18,8 +18,7 @@ function formatButtons(
}
function formatButtonIndices(
value:
| {
value: {
select: number;
buttonSouth: number;
buttonEast: number;
@@ -31,8 +30,7 @@ function formatButtonIndices(
rightStickPress: number;
leftTrigger: number;
rightTrigger: number;
}
| null,
} | null,
): string {
if (!value) {
return 'No controller config loaded.';
@@ -97,7 +95,9 @@ export function createControllerDebugModal(
);
setStatus(
activeDevice?.id ??
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
(ctx.state.connectedGamepads.length > 0
? 'Controller connected.'
: 'No controller detected.'),
);
ctx.dom.controllerDebugSummary.textContent =
ctx.state.connectedGamepads.length > 0

View File

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

View File

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

View File

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

View File

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