Compare commits

...
Author SHA1 Message Date
sudacode e1a0bf4db0 feat(overlay): discover unclaimed mpv key bindings
- Import non-conflicting mpv bindings for the overlay session
- Preserve SubMiner precedence and document discovery behavior
2026-09-11 03:46:15 -07:00
sudacode e6dc9dfec5 test: clone VM results across Electron boundaries
- Clone tokenizer VM results into the host realm
- Expect null-prototype profile maps in IPC validation tests
2026-09-11 01:55:32 -07:00
sudacode 6d69a56574 fix(overlay): keep Hyprland recovery dialogs above overlays (#245) 2026-09-11 01:52:01 -07:00
sudacode 0c37c665a2 build(release): reduce package size and report release sizes (#244) 2026-09-11 01:15:36 -07:00
sudacode 8ae5bde6a4 test(stats): increase occurrence merge test timeout 2026-09-11 01:09:59 -07:00
49 changed files with 1916 additions and 527 deletions
+266
View File
@@ -0,0 +1,266 @@
name: Package release
on:
workflow_call:
secrets:
CSC_LINK:
required: true
CSC_KEY_PASSWORD:
required: true
APPLE_ID:
required: true
APPLE_APP_SPECIFIC_PASSWORD:
required: true
APPLE_TEAM_ID:
required: true
permissions:
contents: read
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Build AppImage
run: bun run build:appimage
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
- name: Smoke packaged runtime assets
shell: bash
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-macos:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Validate macOS signing/notarization secrets
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "Set all required macOS signing/notarization secrets and rerun."
exit 1
fi
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
shell: powershell
run: |
Set-Location vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/win-unpacked/resources"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
+13 -198
View File
@@ -16,207 +16,20 @@ jobs:
contents: read
uses: ./.github/workflows/quality-gate.yml
build-linux:
package:
needs: [quality-gate]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build AppImage
run: bun run build:appimage
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
if-no-files-found: error
build-macos:
needs: [quality-gate]
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Validate macOS signing/notarization secrets
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "Set all required macOS signing/notarization secrets and rerun."
exit 1
fi
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
if-no-files-found: error
build-windows:
needs: [quality-gate]
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
shell: powershell
run: |
Set-Location vendor/texthooker-ui
bun install
bun run build
- name: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
if-no-files-found: error
permissions:
contents: read
uses: ./.github/workflows/package-release.yml
secrets:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
release:
needs: [build-linux, build-macos, build-windows]
needs: [package]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -291,6 +104,7 @@ jobs:
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -337,6 +151,7 @@ jobs:
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer
dist/launcher/subminer.cmd
)
+13 -196
View File
@@ -17,205 +17,20 @@ jobs:
contents: read
uses: ./.github/workflows/quality-gate.yml
build-linux:
package:
needs: [quality-gate]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build AppImage
run: bun run build:appimage
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
build-macos:
needs: [quality-gate]
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Validate macOS signing/notarization secrets
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "Set all required macOS signing/notarization secrets and rerun."
exit 1
fi
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
build-windows:
needs: [quality-gate]
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
shell: powershell
run: |
Set-Location vendor/texthooker-ui
bun install
bun run build
- name: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
if-no-files-found: error
permissions:
contents: read
uses: ./.github/workflows/package-release.yml
secrets:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
release:
needs: [build-linux, build-macos, build-windows]
needs: [package]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -290,6 +105,7 @@ jobs:
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -354,6 +170,7 @@ jobs:
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer
dist/launcher/subminer.cmd
)
+1
View File
@@ -18,6 +18,7 @@
"ws": "^8.21.0",
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: release
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows.
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
+5
View File
@@ -635,6 +635,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
Supported, unclaimed single-key keyboard bindings from the connected mpv session are also available
in the overlay automatically. Configured SubMiner bindings, including `null` entries,
take precedence. See [mpv binding discovery](/shortcuts#automatic-mpv-bindings) for session refresh
behavior and limitations.
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
**See `config.example.jsonc`** for more keybinding examples and configuration options.
+17
View File
@@ -169,3 +169,20 @@ The `keybindings` array overrides or extends the overlay's built-in key handling
Mouse keybinding names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) - changes take effect without restarting SubMiner.
### Automatic mpv bindings
The overlay also discovers supported single-key keyboard bindings from the connected mpv session,
including `input.conf`, mpv defaults, and loaded scripts. When SubMiner does not handle a
key, it forwards the key to mpv to run the current binding. SubMiner shortcuts and
configured bindings take precedence, including entries explicitly disabled with
`"command": null`. Text entry, overlay menus, and Yomitan popups do not forward these
fallback keys.
Discovery runs in the background at startup, again after a short delay for scripts,
when the overlay regains focus, and when SubMiner's binding configuration reloads.
Bindings added later may require refocusing the overlay. Imported bindings stay in
memory for the session and do not appear in SubMiner's help menu or modify its config.
Supported keys include characters, common navigation keys, and F1 through F24, with
modifiers. Mouse bindings, keypad-specific and media keys, key sequences, and full
navigation of interactive mpv script menus are not imported. If discovery is unavailable, SubMiner's configured controls keep working.
+4
View File
@@ -378,6 +378,10 @@ windowrule = no_blur on, match:class SubMiner
If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay - the `opaque`/`opacity` and `no_blur` fields above override it.
**Application Not Responding dialog covered by the overlay**
SubMiner keeps visible Hyprland system dialogs above its windows on the same workspace when updating overlay placement. This lets you click the recovery dialog even while the overlay accepts mouse input. If the whole SubMiner process is frozen, use Hyprland's window-focus bindings to reach the dialog; SubMiner cannot update window order until it resumes.
**Global shortcuts not working**
On Hyprland, Electron cannot register global shortcuts on its own. You must explicitly pass keybindings to SubMiner using `pass` rules:
+42
View File
@@ -11,6 +11,48 @@
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it.
## Package contents and size checks
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
TypeScript files, and nested test or fixture directories
fail the build. Size measurements are informational and do not block releases.
Current targets are Linux x64, macOS arm64, and Windows x64.
The runtime allowlist includes `dist/`, `stats/dist/`, and
`vendor/texthooker-ui/docs/` plus metadata, config example, and license. The
texthooker `docs/` directory is its built UI. Keep the positive `package.json`
pattern in platform `files` lists: electron-builder otherwise treats an
exclusion-only platform list as a separate include-all matcher. Windows keeps
only its target Koffi binary; other platforms omit Koffi. Desktop UIs share the
original M PLUS 1 TTF in `dist/fonts/`.
`release/package-size-<platform>-<arch>.json` reports unpacked bytes, largest
files inside and outside ASAR, native binaries, and compressed artifact sizes.
Framework symlinks are not counted twice. Reports are checksummed and published.
CI downloads the preceding release's reports for comparison; older releases
without reports skip comparison. Review the inventory and reason for growth
when comparing releases. An AppImage normally
runs compressed; its extracted size is a separate measurement.
The shared workflow runs `bun run test:package <resources-directory>` with the
pinned Electron runtime and temporary user data. On headless Linux, prefix it
with `xvfb-run -a`. This checks packaged SQLite, Windows FFI loading/polling,
texthooker serving, Yomitan loading, UI assets, and Japanese font loading.
Standalone pages lack app IPC handlers and can log related errors; this check
does not replace an installed app session.
Before shipping packaging changes, check each platform's installed app:
startup and mpv tracking, dictionary lookup and stroke orders, settings/sync UI,
stats persistence, sentence mining with AnkiConnect, and updating from the prior
release. Preserve Electron locales, graphics fallbacks, codecs, dictionaries,
license notices, updater metadata, blockmaps, and the macOS updater ZIP. Trim
files before signing and generating updater hashes, never from a signed app.
## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
+11
View File
@@ -39,6 +39,17 @@ Read when: you need to find the owner module for a behavior or test surface
## Shared Contract Entry Points
Automatic mpv keyboard discovery uses the `get-mpv-input-bindings` IPC request and
`MpvInputBindingsSnapshot` in `src/types/session-bindings.ts`.
`src/main/runtime/mpv-input-bindings.ts` queries the connected player and preserves
configured keys, including disabled entries. `src/shared/mpv-input-bindings.ts`
validates discovered keys and translates browser input. The renderer's
`handlers/mpv-input-forwarding.ts` keeps the session lookup, coalesces asynchronous
refreshes, and releases held keys on blur or disposal. `handlers/keyboard.ts` runs
this fallback after SubMiner controls and refreshes on startup, a delayed startup
pass, focus, and binding reload. Discovery does not enter compiled session bindings,
the plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes,
including changes whose cue text and timings are identical. Native selection and clean clipboard
+5
View File
@@ -52,6 +52,11 @@ bun run docs:build
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
Content checks and informational size reporting run inside electron-builder hooks. See the
[release guide](../RELEASING.md#package-contents-and-size-checks) for size reports
and the installed-app verification checklist.
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
+40 -43
View File
@@ -80,7 +80,8 @@
"build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip --publish never",
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
"test:package": "bun scripts/run-package-smoke.mjs"
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
@@ -124,15 +125,16 @@
"ws": "^8.21.0"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"undici": "7.29.0",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"undici": "7.29.0"
},
"build": {
"appId": "com.sudacode.SubMiner",
@@ -159,6 +161,10 @@
"category": "AudioVideo",
"executableArgs": [
"--background"
],
"files": [
"package.json",
"!node_modules/koffi{,/**/*}"
]
},
"mac": {
@@ -177,6 +183,10 @@
"from": "dist/scripts/get-mpv-window-macos",
"to": "scripts/get-mpv-window-macos"
}
],
"files": [
"package.json",
"!node_modules/koffi{,/**/*}"
]
},
"dmg": {
@@ -188,7 +198,11 @@
"nsis",
"zip"
],
"icon": "assets/SubMiner.ico"
"icon": "assets/SubMiner.ico",
"files": [
"package.json",
"!node_modules/koffi/build/koffi/!(win32_${arch}){,/**/*}"
]
},
"nsis": {
"artifactName": "SubMiner-${version}.${ext}",
@@ -198,43 +212,19 @@
"include": "build/installer.nsh"
},
"files": [
"**/*",
"!assets{,/**/*}",
"!src{,/**/*}",
"!launcher{,/**/*}",
"!docs{,/**/*}",
"!tests{,/**/*}",
"!packaging{,/**/*}",
"!README.md",
"!CHANGELOG.md",
"!AGENTS.md",
"!CLAUDE.md",
"!stats/src{,/**/*}",
"!stats/index.html",
"!stats/public{,/**/*}",
"!stats/package.json",
"!stats/tsconfig.json",
"!stats/vite.config.ts",
"!docs-site{,/**/*}",
"!changes{,/**/*}",
"!.tmp{,/**/*}",
"!release-*{,/**/*}",
"!dist/**/*.map",
"!dist/**/*.test.*",
"!dist/**/__tests__{,/**/*}",
"!scripts/**/*.test.*",
"!plugin{,/**/*}",
"!vendor/subminer-yomitan{,/**/*}",
"!vendor/yomitan-jlpt-vocab{,/**/*}",
"!vendor/texthooker-ui/src{,/**/*}",
"!vendor/texthooker-ui/node_modules{,/**/*}",
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
"!vendor/texthooker-ui/.vscode{,/**/*}",
"!vendor/texthooker-ui/public{,/**/*}",
"!vendor/texthooker-ui/README.md",
"!vendor/texthooker-ui/package.json",
"!vendor/texthooker-ui/package-lock.json",
"!vendor/texthooker-ui/tsconfig*.json",
"dist/**/*",
"stats/dist/**/*",
"vendor/texthooker-ui/docs/**/*",
"config.example.jsonc",
"LICENSE",
"!**/*.map",
"!**/*.{ts,tsx,mts,cts}",
"!**/*.{test,spec}.*",
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
"!dist/launcher{,/**/*}",
"!dist/scripts{,/**/*}",
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
],
"extraResources": [
@@ -248,7 +238,13 @@
},
{
"from": "assets",
"to": "assets"
"to": "assets",
"filter": [
"SubMiner*.png",
"SubMiner.ico",
"themes/**/*",
"thumbnailers/**/*"
]
},
{
"from": "plugin/subminer",
@@ -273,7 +269,8 @@
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
}
]
],
"afterAllArtifactBuild": "scripts/package-audit.cjs"
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
+1
View File
@@ -105,6 +105,7 @@ async function afterPack(context, deps = {}) {
await stageLinuxAppImageSharedLibrary(context);
await verifyMacOSWindowHelper(context);
await stageBundledBunRuntime(context, deps);
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
}
module.exports = {
+10 -1
View File
@@ -21,6 +21,7 @@ const {
packager?: { appInfo?: { productFilename?: string } };
},
deps?: {
auditPackage?: (context: { appOutDir: string }) => Promise<void>;
stageBunRuntime?: (options: {
appOutDir: string;
platform: string;
@@ -172,11 +173,12 @@ test('afterPack propagates Linux staging failures', async () => {
}
});
test('afterPack preserves Linux staging and forwards the electron-builder target to Bun staging', async () => {
test('afterPack stages Linux and Bun runtime assets before auditing the package', async () => {
const workspace = createWorkspace('subminer-after-pack-target');
const appOutDir = path.join(workspace, 'SubMiner-linux-arm64');
const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY);
const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY);
const operations: string[] = [];
let stagedOptions:
| {
appOutDir: string;
@@ -200,10 +202,17 @@ test('afterPack preserves Linux staging and forwards the electron-builder target
{
stageBunRuntime: async (options) => {
stagedOptions = options;
operations.push('stage-bun');
},
auditPackage: async (context) => {
assert.equal(context.appOutDir, appOutDir);
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
operations.push('audit');
},
},
);
assert.deepEqual(operations, ['stage-bun', 'audit']);
assert.deepEqual(stagedOptions, {
appOutDir,
platform: 'linux',
+243
View File
@@ -0,0 +1,243 @@
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const asar = require('@electron/asar');
const { Arch } = require('builder-util');
const MIB = 1024 * 1024;
const currentReports = new Set();
const REQUIRED_APP_FILES = [
'package.json',
'LICENSE',
'config.example.jsonc',
'dist/main-entry.js',
'dist/main.js',
'dist/preload.js',
'dist/preload-settings.js',
'dist/preload-syncui.js',
'dist/preload-stats.js',
'dist/preload-jellyfin-setup.js',
'dist/fonts/MPLUS1[wght].ttf',
'stats/dist/index.html',
'vendor/texthooker-ui/docs/index.html',
...['renderer', 'settings', 'syncui'].flatMap((ui) => [
`dist/${ui}/index.html`,
`dist/${ui}/style.css`,
`dist/${ui}/${ui}.js`,
]),
];
const REQUIRED_RESOURCES = [
'yomitan/manifest.json',
'yomitan/data/fonts/kanji-stroke-orders.ttf',
'yomitan/fonts/NotoSansJP-Regular.ttf',
'yomitan/lib/resvg.wasm',
'launcher/subminer',
'plugin/subminer/main.lua',
'plugin/subminer.conf',
'assets/SubMiner.png',
'assets/SubMiner-square.png',
'assets/themes/subminer.rasi',
'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'CHANGELOG.md',
];
// Do not follow framework symlinks or count ASAR unpacked entries twice.
function listFiles(root, prefix = '') {
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) return [];
if (entry.isDirectory()) return listFiles(root, name);
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
});
}
function listAppFiles(archive) {
return asar.listPackage(archive).flatMap((entry) => {
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
const stat = asar.statFile(archive, name);
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
});
}
function verifyAppPath(name, platform, arch) {
const allowedRoots = new Set([
'dist',
'node_modules',
'stats',
'vendor',
'package.json',
'LICENSE',
'config.example.jsonc',
]);
assert(allowedRoots.has(name.split('/')[0]), `Unexpected app file: ${name}`);
assert(!name.endsWith('.map'), `Packaged source map: ${name}`);
assert(!/\.(?:[cm]?ts|tsx)$/.test(name), `Packaged TypeScript: ${name}`);
assert(!/\.(?:test|spec)\./.test(name), `Packaged test: ${name}`);
assert(
!/(?:^|\/)(?:tests?|__tests__|fixtures?|__fixtures__)\//.test(name),
`Packaged test or fixture directory: ${name}`,
);
assert(!/^dist\/.*\.test\./.test(name), `Packaged test: ${name}`);
assert(!/^dist\/(launcher|scripts)\//.test(name), `Duplicate helper: ${name}`);
assert(!/^dist\/(renderer|settings|syncui)\/fonts\//.test(name), `Duplicate font: ${name}`);
assert(!name.startsWith('stats/') || name.startsWith('stats/dist/'), `Stats source: ${name}`);
assert(
!name.startsWith('vendor/') || name.startsWith('vendor/texthooker-ui/docs/'),
`Vendor source: ${name}`,
);
if (name.startsWith('node_modules/koffi/')) {
assert.equal(platform, 'win32', `Koffi shipped on ${platform}`);
assert(!/^node_modules\/koffi\/(src|vendor|doc)\//.test(name), `Koffi build files: ${name}`);
if (name.endsWith('.node')) {
assert.equal(name, `node_modules/koffi/build/koffi/win32_${arch}/koffi.node`);
}
}
}
function verifyContents(archive, resources, platform, arch) {
const entries = listAppFiles(archive);
const names = new Set(entries.map((entry) => entry.path));
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
for (const name of REQUIRED_RESOURCES) {
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
}
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
const libsqlPlatform = {
linux: `linux-${arch}-gnu`,
darwin: `darwin-${arch}`,
win32: `win32-${arch}-msvc`,
}[platform];
const libsqlBinary = `node_modules/@libsql/${libsqlPlatform}/index.node`;
assert(names.has(libsqlBinary), `Missing SQLite native binary: ${libsqlBinary}`);
for (const name of names) {
if (name.startsWith('node_modules/@libsql/') && name.endsWith('.node')) {
assert.equal(name, libsqlBinary, `Foreign SQLite binary: ${name}`);
}
}
if (platform === 'win32') {
for (const name of [
'index.js',
'package.json',
'LICENSE.txt',
`build/koffi/win32_${arch}/koffi.node`,
]) {
assert(names.has(`node_modules/koffi/${name}`), `Missing Windows FFI file: ${name}`);
}
}
for (const name of listFiles(path.join(resources, 'assets'))) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
}
for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
}
return entries;
}
async function auditPackage(context) {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
const key = `${platform}-${arch}`;
const appRoot =
platform === 'darwin'
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
: context.appOutDir;
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
const files = listFiles(appRoot);
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
const report = {
version: context.packager.appInfo.version,
platform,
arch,
unpackedBytes,
appDirectory: path.relative(context.outDir, appRoot),
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
artifacts: [],
};
const output = path.join(context.outDir, `package-size-${key}.json`);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
currentReports.add(output);
console.log(
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
);
}
function artifactKind(name) {
if (name.endsWith('-mac.zip')) return 'mac.zip';
if (name.endsWith('-win.zip')) return 'win.zip';
const extension = path.extname(name).slice(1);
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
}
function compareSizes(report, previous) {
assert.equal(previous.platform, report.platform);
assert.equal(previous.arch, report.arch);
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
return {
version: previous.version,
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
artifacts: report.artifacts.flatMap((artifact) => {
const old = previousArtifacts.find(
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
);
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
}),
};
}
// Runs after signing and installer creation, before release upload.
async function afterAllArtifactBuild(result) {
const reports = [];
for (const reportPath of currentReports) {
const filename = path.basename(reportPath);
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const key = `${report.platform}-${report.arch}`;
const files = listFiles(path.join(result.outDir, report.appDirectory));
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
report.artifacts = result.artifactPaths.flatMap((file) => {
const kind = artifactKind(file);
if (!kind) return [];
const bytes = fs.statSync(file).size;
return [{ name: path.basename(file), kind, bytes }];
});
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
if (fs.existsSync(previousPath)) {
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
report.comparison = compareSizes(report, previous);
}
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const summary = [
`### Package size: ${key}`,
'',
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
report.comparison
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
: 'No previous size report available.',
'',
].join('\n');
console.log(summary);
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
reports.push(reportPath);
}
assert(reports.length > 0, 'No package size reports generated by afterPack');
return reports;
}
module.exports = {
auditPackage,
verifyContents,
verifyAppPath,
listFiles,
listAppFiles,
compareSizes,
default: afterAllArtifactBuild,
};
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync, createReadStream } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createPackageFromStreams } from '@electron/asar';
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
import config from '../package.json';
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
const root = process.cwd();
const fileStat = statSync('package.json');
for (const platform of ['linux', 'mac', 'win'] as const) {
const matchers = getFileMatchers(
{ files: [{ filter: config.build.files }] },
'files',
'/tmp/subminer-filter-output',
{
defaultSrc: root,
globalOutDir: path.join(root, 'release'),
customBuildOptions: { files: config.build[platform].files },
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
},
);
assert(matchers);
// This is builder's default for an exclusion-only platform matcher.
for (const matcher of matchers) {
if (matcher.containsOnlyIgnore()) matcher.prependPattern('**/*');
}
const included = (name: string) =>
matchers.some((matcher) => matcher.createFilter()(path.join(root, name), fileStat));
for (const name of [
'dist/main-entry.js',
'dist/fonts/MPLUS1[wght].ttf',
'stats/dist/index.html',
'vendor/texthooker-ui/docs/index.html',
'package.json',
]) {
assert(included(name), `${platform} must ship ${name}`);
}
for (const name of [
'.agents/skills/test.md',
'src/main.ts',
'scripts/build-yomitan.mjs',
'docs-site/index.md',
'dist/main.js.map',
'dist/main.test.js',
'dist/nested/source.ts',
'dist/nested/__tests__/helper.js',
'stats/dist/nested/fixtures/data.json',
'vendor/texthooker-ui/docs/nested/component.tsx',
'dist/launcher/subminer',
'dist/settings/fonts/MPLUS1[wght].ttf',
'vendor/subminer-yomitan/ext/manifest.json',
]) {
assert(!included(name), `${platform} must exclude ${name}`);
}
}
});
test('dependency filters keep only the target Windows Koffi binary', () => {
const root = process.cwd();
for (const arch of ['x64', 'arm64']) {
for (const platform of ['linux', 'mac', 'win'] as const) {
const patterns = [
'**/*',
...config.build.files.filter((name) => name.startsWith('!')),
...config.build[platform].files.filter((name) => name.startsWith('!')),
];
const filter = new FileMatcher(
root,
'/tmp/subminer-filter-output',
(value) => value.replaceAll('${arch}', arch),
patterns,
).createFilter();
const included = (name: string) =>
filter(path.join(root, 'node_modules', name), statSync('package.json'));
assert(included('@libsql/win32-x64-msvc/index.node'));
assert(!included('axios/dist/axios.js.map'));
assert(!included('koffi/src/koffi/src/ffi.c'));
assert(!included('agent-base/src/index.ts'));
assert(!included('@discordjs/rest/dist/index.d.mts'));
assert(!included('example/lib/tests/helper.js'));
for (const target of [
'win32_x64',
'win32_arm64',
'linux_x64',
'darwin_arm64',
'openbsd_x64',
]) {
assert.equal(
included(`koffi/build/koffi/${target}/koffi.node`),
platform === 'win' && target === `win32_${arch}`,
`${platform}/${arch}: ${target}`,
);
}
assert.equal(included('koffi/index.js'), platform === 'win');
assert.equal(included('koffi/LICENSE.txt'), platform === 'win');
}
}
});
test('content audit rejects development files beneath approved roots', () => {
for (const root of ['dist', 'stats/dist', 'vendor/texthooker-ui/docs', 'node_modules/example']) {
for (const suffix of [
'nested/source.ts',
'nested/component.tsx',
'nested/types.d.mts',
'nested/source.cts',
'nested/__tests__/helper.js',
'nested/tests/helper.js',
'nested/test/helper.js',
'nested/__fixtures__/data.json',
'nested/fixtures/data.json',
'nested/fixture/data.json',
'nested/component.spec.js',
'nested/component.test.cjs',
]) {
assert.throws(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'), /Packaged/);
}
for (const suffix of ['nested/runtime.js', 'nested/style.css', 'nested/data.json']) {
assert.doesNotThrow(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'));
}
}
});
test('archive inventory handles native files without counting them twice on disk', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
try {
const input = path.join(root, 'input');
const output = path.join(root, 'output');
mkdirSync(input);
mkdirSync(output);
writeFileSync(path.join(input, 'main.js'), 'hello');
writeFileSync(path.join(input, 'native.node'), 'native');
const archive = path.join(output, 'app.asar');
await createPackageFromStreams(
archive,
['main.js', 'native.node'].map((name) => ({
path: name,
type: 'file',
unpacked: name.endsWith('.node'),
stat: statSync(path.join(input, name)),
streamGenerator: () => createReadStream(path.join(input, name)),
})),
);
assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 },
]);
assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
statSync(archive).size + 6,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('size comparison tolerates older reports without artifact measurements', () => {
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
assert.deepEqual(compareSizes(current, previous), {
version: '0.19.6',
unpackedDeltaBytes: -20,
artifacts: [],
});
assert.deepEqual(
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
.artifacts,
[{ kind: 'AppImage', deltaBytes: -10 }],
);
assert.throws(
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
/Invalid previous size report/,
);
});
+4 -4
View File
@@ -29,10 +29,6 @@ function copyFile(sourcePath, outputPath) {
function copyAssets(sourceDir, outputDir, label) {
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true,
force: true,
});
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
}
@@ -102,6 +98,10 @@ function buildMacosHelper() {
}
function main() {
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
recursive: true,
force: true,
});
copyRendererAssets();
copySettingsAssets();
copySyncUiAssets();
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const resources = process.argv[2];
if (!resources) throw new Error('Usage: bun run test:package <resources-directory>');
const require = createRequire(import.meta.url);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-package-smoke-'));
const env = { ...process.env, SUBMINER_PACKAGE_SMOKE_DATA: profile };
delete env.ELECTRON_RUN_AS_NODE;
try {
const result = spawnSync(
require('electron'),
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
{ env, stdio: 'inherit', timeout: 75_000 },
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
}
+94
View File
@@ -0,0 +1,94 @@
// Run with the pinned Electron runtime against a finished app's resources folder.
const { app, BrowserWindow, session } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const resources = path.resolve(process.argv[2]);
const archive = path.join(resources, 'app.asar');
const isolatedData = process.env.SUBMINER_PACKAGE_SMOKE_DATA;
assert(
isolatedData && fs.existsSync(isolatedData),
'Use bun run test:package to create an isolated profile',
);
app.setPath('userData', isolatedData);
app.disableHardwareAcceleration();
app.on('window-all-closed', () => {});
const timeout = setTimeout(() => {
console.error('Package smoke timed out');
app.exit(1);
}, 60_000);
async function smoke() {
await app.whenReady();
const packagedRequire = createRequire(path.join(archive, 'package.json'));
const Database = packagedRequire('libsql');
const database = new Database(':memory:');
assert.equal(database.prepare('select 42 as answer').get().answer, 42);
database.close();
if (process.platform === 'win32') {
const win32 = packagedRequire('./dist/window-trackers/win32.js');
assert(Array.isArray(win32.findMpvWindows().matches));
}
const { Texthooker } = packagedRequire('./dist/core/services/texthooker.js');
const texthooker = new Texthooker();
const server = texthooker.start(0);
assert(server, 'Packaged texthooker assets could not be found');
try {
await once(server, 'listening');
const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
assert.equal(response.status, 200);
assert((await response.text()).includes('<html'));
} finally {
texthooker.stop();
}
const extension = await session.defaultSession.extensions.loadExtension(
path.join(resources, 'yomitan'),
{ allowFileAccess: true },
);
assert(extension.id, 'Yomitan extension failed to load');
const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
if (details.error !== 'net::ERR_ABORTED')
failedRequests.push(`${details.url}: ${details.error}`);
});
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
const win = new BrowserWindow({
show: false,
webPreferences: {
sandbox: false,
preload: path.join(archive, 'dist', ui === 'renderer' ? 'preload.js' : `preload-${ui}.js`),
},
});
try {
await win.loadFile(
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
);
if (ui !== 'stats') {
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
assert(loaded, `${ui}: shared Japanese font failed to load`);
}
} finally {
win.destroy();
}
}
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
);
}
smoke()
.then(() => {
clearTimeout(timeout);
app.exit(0);
})
.catch((error) => {
console.error(error);
clearTimeout(timeout);
app.exit(1);
});
@@ -156,6 +156,107 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
);
});
test('Hyprland placement keeps a recovery dialog above the input-catching overlay', () => {
for (const configProvider of ['hyprlang', 'lua']) {
for (const retryBounds of [false, true]) {
// Bottom to top, as when Hyprland opens its recovery dialog over playback.
const stack = ['0xmpv', '0xoverlay', '0xdialog'];
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
at: [10, 20],
size: [100, 100],
},
{
address: '0xdialog',
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
},
];
let clientReads = 0;
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
bounds: retryBounds ? { x: 0, y: 0, width: 1280, height: 720 } : undefined,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') {
clientReads += 1;
return JSON.stringify(clients);
}
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider });
if (args.join(' ').match(/alterzorder|alter_zorder/)) {
const address = args.join(' ').match(/address:(0x\w+)/)?.[1];
assert.ok(address);
stack.splice(stack.indexOf(address), 1);
stack.push(address);
}
return '';
},
});
assert.equal(status.dispatched, true);
assert.equal(clientReads, retryBounds ? 2 : 1);
assert.deepEqual(stack, ['0xmpv', '0xoverlay', '0xdialog'], configProvider);
}
}
});
test('Hyprland placement only promotes mapped dialogs on the placed window workspace', () => {
const calls: string[] = [];
const dialog = {
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
};
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
},
{ ...dialog, address: '0xhidden', hidden: true },
{ ...dialog, address: '0xunmapped', mapped: false },
{ ...dialog, address: '0xother', workspace: { id: 2 } },
{ ...dialog, address: '0xordinary', class: 'terminal' },
{ ...dialog, address: '0xinitial', class: '', initialClass: 'hyprland-dialog' },
];
for (const promote of [true, false]) {
calls.length = 0;
ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
promote,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') return JSON.stringify(clients);
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider: 'hyprlang' });
calls.push(args.join(' '));
return '';
},
});
assert.deepEqual(
calls,
promote
? [
'dispatch alterzorder top,address:0xoverlay',
'dispatch alterzorder top,address:0xinitial',
]
: [],
);
}
});
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
const buildDispatches = buildHyprlandPlacementDispatches as (
client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
+52 -4
View File
@@ -3,14 +3,17 @@ import { execFileSync } from 'node:child_process';
export interface HyprlandPlacementClient {
address?: string;
at?: [number, number];
class?: string;
floating?: boolean;
hidden?: boolean;
initialClass?: string;
initialTitle?: string;
mapped?: boolean;
pid?: number;
pinned?: boolean;
size?: [number, number];
title?: string;
workspace?: { id: number };
}
export interface HyprlandPlacementBounds {
@@ -25,7 +28,11 @@ export interface HyprlandPlacementDispatchOptions {
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
type ExecFileSync = (
file: string,
args: string[],
options: NonNullable<Parameters<typeof execFileSync>[2]>,
) => ReturnType<typeof execFileSync>;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement(
@@ -154,6 +161,33 @@ function luaWindowDispatch(name: string, windowAddress: string, fields: string[]
];
}
// Compositor recovery dialogs must remain clickable even when an overlay still owns input.
function buildHyprlandDialogPromotionDispatches(
clients: HyprlandPlacementClient[],
placedClient: HyprlandPlacementClient,
configProvider: HyprlandConfigProvider,
): string[][] {
if (typeof placedClient.workspace?.id !== 'number') return [];
return clients.flatMap((client) => {
if (
!client.address ||
client.address === placedClient.address ||
client.mapped === false ||
client.hidden === true ||
client.workspace?.id !== placedClient.workspace?.id ||
(client.class !== 'hyprland-dialog' && client.initialClass !== 'hyprland-dialog')
) {
return [];
}
const windowAddress = `address:${client.address}`;
return [
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
];
});
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`,
@@ -331,12 +365,16 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
configProvider,
promote: options.promote,
});
if (options.promote !== false) {
dispatches.push(...buildHyprlandDialogPromotionDispatches(clients, client, configProvider));
}
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
if (shouldVerifyBounds) {
try {
const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), {
const refreshedClients = readHyprlandPlacementClients(run);
const refreshedClient = findHyprlandWindowForPlacement(refreshedClients, {
pid: options.pid ?? process.pid,
title: options.title,
});
@@ -345,10 +383,20 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
targetBounds &&
clientMatchesPlacementBounds(refreshedClient, targetBounds) === false
) {
for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
const retryDispatches = buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
configProvider,
promote: options.promote,
})) {
});
if (options.promote !== false) {
retryDispatches.push(
...buildHyprlandDialogPromotionDispatches(
refreshedClients,
refreshedClient,
configProvider,
),
);
}
for (const args of retryDispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
}
+22 -1
View File
@@ -1037,7 +1037,13 @@ test('registerIpcHandlers accepts per-controller profile config updates', async
},
};
await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]);
assert.deepEqual(controllerSaves, [
{
...update,
// Validation uses a null prototype to safely store arbitrary profile IDs.
profiles: { __proto__: null, ...update.profiles },
},
]);
await assert.rejects(async () => {
await saveHandler(
@@ -1317,3 +1323,18 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
assert.deepEqual(calls, [21355]);
assert.deepEqual(searches, ['Re:ZERO']);
});
test('mpv discovery has its own request and does not change session bindings', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const snapshot = { keys: ['r'], blockedKeys: [] };
registerIpcHandlers(
createRegisterIpcDeps({ getMpvInputBindings: async () => snapshot }),
registrar,
);
const discovery = handlers.handle.get(IPC_CHANNELS.request.getMpvInputBindings);
const session = handlers.handle.get(IPC_CHANNELS.request.getSessionBindings);
assert.ok(discovery);
assert.ok(session);
assert.deepEqual(await discovery({}), snapshot);
assert.deepEqual(await session({}), []);
});
+8
View File
@@ -1,4 +1,5 @@
import electron from 'electron';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type {
ChangelogSnapshot,
@@ -89,6 +90,7 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -344,6 +346,7 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -438,6 +441,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
},
handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings,
getMpvInputBindings: options.getMpvInputBindings,
getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
@@ -769,6 +773,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getKeybindings();
});
ipc.handle(IPC_CHANNELS.request.getMpvInputBindings, () => {
return deps.getMpvInputBindings?.() ?? { keys: [], blockedKeys: [] };
});
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? [];
});
+4 -4
View File
@@ -211,7 +211,7 @@ function parseAccelerator(
};
}
function parseDomKeyString(
export function parseSessionBindingKey(
key: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
@@ -435,7 +435,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform);
const parsed = parseSessionBindingKey(statsToggleKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -462,7 +462,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
if (statsMarkWatchedKey) {
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform);
const parsed = parseSessionBindingKey(statsMarkWatchedKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -490,7 +490,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
const parsed = parseSessionBindingKey(binding.key, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -66,7 +66,7 @@ function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number
for (const legacyOccurrences of [false, true]) {
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
test(`sync merge carries occurrence timestamps in from ${label}`, () => {
test(`sync merge carries occurrence timestamps in from ${label}`, { timeout: 15_000 }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
try {
const local = buildDb(dir, 'local.sqlite', {
@@ -396,7 +396,8 @@ function createInjectedScriptVm(store: ReplayMessageStore): (script: string) =>
Set,
String,
});
return async (script: string) => await vm.runInContext(script, context);
// Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
}
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
@@ -64,7 +64,8 @@ export async function runInjectedYomitanScript(
script: string,
handler: (action: string, params: unknown) => unknown,
): Promise<unknown> {
return await vm.runInNewContext(script, createYomitanScriptSandbox(handler));
// Clone results into the host realm, matching Electron's process boundary.
return structuredClone(await vm.runInNewContext(script, createYomitanScriptSandbox(handler)));
}
// Persistent page context shared across executeJavaScript calls, matching the
@@ -75,7 +76,8 @@ function createPersistentYomitanScriptRunner(
handler: (action: string, params: unknown) => unknown,
): (script: string) => Promise<unknown> {
const context = vm.createContext(createYomitanScriptSandbox(handler));
return async (script: string) => await vm.runInContext(script, context);
// Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
}
// Deps whose parser window executes every injected script (profile metadata,
+12
View File
@@ -33,6 +33,7 @@ import {
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
@@ -5859,6 +5860,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getMpvInputBindings: () =>
readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform:
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'win32'
: 'linux',
}),
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
+2
View File
@@ -83,6 +83,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
@@ -280,6 +281,7 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings,
getMpvInputBindings: params.getMpvInputBindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readMpvInputBindings } from './mpv-input-bindings';
test('discovery reads the connected player and preserves configured keys including disabled bindings', async () => {
const client = {
connected: true,
requestProperty: async (name: string) => {
assert.equal(name, 'input-bindings');
return [{ key: 'r', cmd: 'script-binding replay/run', priority: 1 }];
},
};
assert.deepEqual(
await readMpvInputBindings({
getMpvClient: () => client,
getConfiguredKeybindings: () => [{ key: 'Ctrl+KeyR', command: null }],
platform: 'linux',
}),
{ keys: ['r'], blockedKeys: [{ code: 'KeyR', modifiers: ['ctrl'] }] },
);
});
test('discovery safely handles unsupported properties and disconnects during a request', async () => {
const client = {
connected: true,
requestProperty: async (): Promise<unknown> => {
throw new Error('property unavailable');
},
};
const deps = {
getMpvClient: () => client,
getConfiguredKeybindings: () => [],
platform: 'linux',
} satisfies Parameters<typeof readMpvInputBindings>[0];
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
client.requestProperty = async () => {
client.connected = false;
return [{ key: 'r', cmd: 'seek 5', priority: 1 }];
};
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
});
+31
View File
@@ -0,0 +1,31 @@
import type { Keybinding } from '../../types';
import { parseSessionBindingKey } from '../../core/services/session-bindings';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
export async function readMpvInputBindings(deps: {
getMpvClient: () => {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
} | null;
getConfiguredKeybindings: () => Keybinding[];
platform: 'darwin' | 'win32' | 'linux';
}): Promise<MpvInputBindingsSnapshot> {
const blockedKeys = deps.getConfiguredKeybindings().flatMap((binding) => {
const { key } = parseSessionBindingKey(binding.key, deps.platform);
return key ? [key] : [];
});
const client = deps.getMpvClient();
if (!client?.connected) return { keys: [], blockedKeys };
try {
const value = await client.requestProperty('input-bindings');
return {
keys:
client === deps.getMpvClient() && client.connected ? parseMpvInputBindingKeys(value) : [],
blockedKeys,
};
} catch {
// Older mpv versions and disconnected sessions retain SubMiner's controls.
return { keys: [], blockedKeys };
}
}
+1
View File
@@ -350,6 +350,7 @@ const electronAPI: ElectronAPI = {
getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getMpvInputBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getMpvInputBindings),
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
+87 -17
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
executableRunLines,
jobSteps,
readWorkflow,
stepRunsCommand,
@@ -12,7 +13,14 @@ import {
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n');
const packageWorkflow = readFileSync(
resolve(__dirname, '../.github/workflows/package-release.yml'),
'utf8',
);
const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath);
const parsedPackageWorkflow = readWorkflow(
resolve(__dirname, '../.github/workflows/package-release.yml'),
);
const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
@@ -42,15 +50,10 @@ test('prerelease workflow uses committed prerelease notes and never calls claude
});
test('prerelease delegates its quality gate instead of duplicating quality steps', () => {
assert.match(
prereleaseWorkflow,
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
);
const qualityGateJob = prereleaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0];
assert.ok(qualityGateJob);
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
assert.doesNotMatch(qualityGateJob, /bun run test:env/);
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.['quality-gate'], {
permissions: { contents: 'read' },
uses: './.github/workflows/quality-gate.yml',
});
});
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
@@ -60,10 +63,10 @@ test('prerelease workflow publishes GitHub prereleases and keeps them off latest
});
test('prerelease packaging workflows scope dependency caches by runner architecture', () => {
const archScopedCacheKeyMatches = prereleaseWorkflow.match(
const archScopedCacheKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
/key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
);
const archScopedRestoreKeyMatches = prereleaseWorkflow.match(
const archScopedRestoreKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
/\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
);
assert.equal(archScopedCacheKeyMatches?.length, 4);
@@ -71,12 +74,79 @@ test('prerelease packaging workflows scope dependency caches by runner architect
});
test('prerelease workflow builds and uploads all release platforms', () => {
assert.match(prereleaseWorkflow, /build-linux:/);
assert.match(prereleaseWorkflow, /build-macos:/);
assert.match(prereleaseWorkflow, /build-windows:/);
assert.match(prereleaseWorkflow, /name: appimage/);
assert.match(prereleaseWorkflow, /name: macos/);
assert.match(prereleaseWorkflow, /name: windows/);
assert.deepEqual(Object.keys(parsedPrereleaseWorkflow.jobs ?? {}).sort(), [
'package',
'quality-gate',
'release',
]);
assert.equal(
parsedPrereleaseWorkflow.jobs?.package?.uses,
'./.github/workflows/package-release.yml',
);
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.package?.needs, ['quality-gate']);
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.release?.needs, ['package']);
assert.deepEqual(Object.keys(parsedPackageWorkflow.jobs ?? {}).sort(), [
'build-linux',
'build-macos',
'build-windows',
]);
for (const [job, name, paths] of [
['build-linux', 'appimage', ['release/*.AppImage']],
['build-macos', 'macos', ['release/*.dmg', 'release/*.zip']],
['build-windows', 'windows', ['release/*.exe', 'release/*.zip']],
] as const) {
const uploads = jobSteps(parsedPackageWorkflow, job).filter(
(step) => step.uses === 'actions/upload-artifact@v4',
);
assert.equal(uploads.length, 1);
const upload = uploads[0];
assert.ok(upload);
assert.equal(upload.with?.name, name);
assert.equal(upload.with?.['if-no-files-found'], 'error');
const uploadPath = upload.with?.path;
assert.ok(typeof uploadPath === 'string');
assert.deepEqual(uploadPath.trim().split('\n'), [
...paths,
'release/latest*.yml',
'release/*.blockmap',
'release/package-size-*.json',
]);
const download = jobSteps(parsedPrereleaseWorkflow, 'release').find(
(step) => step.uses === 'actions/download-artifact@v4' && step.with?.name === name,
);
assert.equal(download?.with?.path, 'release');
}
const steps = jobSteps(parsedPrereleaseWorkflow, 'release');
const checksum = steps.find((step) => step.name === 'Generate checksums');
const publish = steps.find((step) => step.name === 'Publish Prerelease');
assert.ok(checksum);
assert.ok(publish);
assert.ok(executableRunLines(checksum).includes('files+=(release/package-size-*.json)'));
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
});
test('release callers pass only the declared macOS signing secrets to packaging', () => {
const secrets = [
'CSC_LINK',
'CSC_KEY_PASSWORD',
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
];
assert.deepEqual(
parsedPackageWorkflow.on?.workflow_call?.secrets,
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
);
for (const workflow of [
parsedPrereleaseWorkflow,
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
]) {
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
assert.deepEqual(
workflow.jobs?.package?.secrets,
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
);
}
});
test('prerelease workflow publishes both launcher wrappers with the platform packages', () => {
+42 -50
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
jobSteps,
readWorkflow,
stepsMissingEnvDeclaration,
templateExpressionsInRunBodies,
@@ -10,6 +11,10 @@ import {
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
const packageWorkflow = readFileSync(
resolve(__dirname, '../.github/workflows/package-release.yml'),
'utf8',
);
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath);
@@ -96,20 +101,25 @@ test('release delegates its quality gate instead of duplicating quality steps',
releaseWorkflow,
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
);
const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0];
const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n package:)/)?.[0];
assert.ok(qualityGateJob);
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
assert.doesNotMatch(qualityGateJob, /bun run test:env/);
});
test('release build jobs install and cache stats dependencies before packaging', () => {
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/);
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/);
assert.match(releaseWorkflow, /build-windows:[\s\S]*stats\/node_modules/);
assert.match(releaseWorkflow, /build-linux:[\s\S]*cd stats && bun install --frozen-lockfile/);
assert.match(releaseWorkflow, /build-macos:[\s\S]*cd stats && bun install --frozen-lockfile/);
assert.match(releaseWorkflow, /build-windows:[\s\S]*cd stats && bun install --frozen-lockfile/);
test('each release build job installs stats dependencies before packaging', () => {
const workflow = readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml'));
for (const job of ['build-linux', 'build-macos', 'build-windows']) {
const steps = jobSteps(workflow, job);
const install = steps.findIndex((step) =>
step.run?.includes('cd stats && bun install --frozen-lockfile'),
);
const build = steps.findIndex((step) =>
/bun run build:(appimage|mac|win)/.test(step.run ?? ''),
);
assert(install >= 0 && build > install, `${job} must install stats before packaging`);
}
});
test('release workflow generates release notes from committed changelog output', () => {
@@ -163,42 +173,6 @@ test('top-level package metadata keeps Linux Electron runtime app identity canon
assert.equal(packageJson.desktopName, 'SubMiner.desktop');
});
test('release packaging keeps default file inclusion and excludes large source-only trees explicitly', () => {
const files = packageJson.build?.files ?? [];
assert.ok(files.includes('**/*'));
assert.ok(files.includes('!src{,/**/*}'));
assert.ok(files.includes('!launcher{,/**/*}'));
assert.ok(files.includes('!stats/src{,/**/*}'));
assert.ok(files.includes('!.tmp{,/**/*}'));
assert.ok(files.includes('!release-*{,/**/*}'));
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
assert.ok(files.includes('!assets{,/**/*}'));
assert.ok(files.includes('!plugin{,/**/*}'));
assert.ok(files.includes('!vendor/yomitan-jlpt-vocab{,/**/*}'));
assert.ok(files.includes('!docs{,/**/*}'));
assert.ok(files.includes('!tests{,/**/*}'));
assert.ok(files.includes('!packaging{,/**/*}'));
assert.ok(files.includes('!README.md'));
assert.ok(files.includes('!CHANGELOG.md'));
assert.ok(files.includes('!AGENTS.md'));
assert.ok(files.includes('!CLAUDE.md'));
assert.ok(files.includes('!stats/public{,/**/*}'));
assert.ok(files.includes('!stats/package.json'));
assert.ok(files.includes('!stats/tsconfig.json'));
assert.ok(files.includes('!stats/vite.config.ts'));
assert.ok(files.includes('!dist/**/*.map'));
assert.ok(files.includes('!dist/**/*.test.*'));
assert.ok(files.includes('!dist/**/__tests__{,/**/*}'));
assert.ok(files.includes('!scripts/**/*.test.*'));
assert.ok(files.includes('!vendor/texthooker-ui/public{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/.vscode{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/README.md'));
assert.ok(files.includes('!vendor/texthooker-ui/package.json'));
assert.ok(files.includes('!vendor/texthooker-ui/tsconfig*.json'));
assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}'));
});
test('release packaging stages only the generated launcher runtime artifacts', () => {
const launcherResource = packageJson.build?.extraResources?.find(
(resource) => resource.from === 'dist/launcher' && resource.to === 'launcher',
@@ -239,12 +213,12 @@ test('config example generation runs directly from source without unrelated bund
});
test('windows release workflow publishes unsigned artifacts directly without SignPath', () => {
assert.match(releaseWorkflow, /Build unsigned Windows artifacts/);
assert.match(releaseWorkflow, /run: bun run build:win:unsigned/);
assert.match(releaseWorkflow, /name: windows/);
assert.match(releaseWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/);
assert.ok(!releaseWorkflow.includes('signpath/github-action-submit-signing-request'));
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
assert.match(packageWorkflow, /Build unsigned Windows artifacts/);
assert.match(packageWorkflow, /run: bun run build:win:unsigned/);
assert.match(packageWorkflow, /name: windows/);
assert.match(packageWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/);
assert.ok(!packageWorkflow.includes('signpath/github-action-submit-signing-request'));
assert.ok(!packageWorkflow.includes('SIGNPATH_'));
});
test('release artifact names are distinct before upload', () => {
@@ -306,3 +280,21 @@ test('release and docs workflows keep tag-derived values out of shell bodies', (
// that would be substituted into the condition before the shell reads it.
assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/);
});
test('stable and prerelease builds use the same packaging gate', () => {
const prerelease = readFileSync(
resolve(__dirname, '../.github/workflows/prerelease.yml'),
'utf8',
);
for (const workflow of [releaseWorkflow, prerelease]) {
assert.match(workflow, /uses: \.\/\.github\/workflows\/package-release\.yml/);
assert.match(workflow, /needs: \[package\]/);
assert.match(workflow, /release\/package-size-\*\.json/);
}
assert.deepEqual(
templateExpressionsInRunBodies(
readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml')),
),
[],
);
});
+75 -1
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import type { CompiledSessionBinding } from '../../types';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
@@ -115,6 +116,10 @@ function installKeyboardTestGlobals() {
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot> = async () => ({
keys: [],
blockedKeys: [],
});
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
@@ -238,6 +243,7 @@ function installKeyboardTestGlobals() {
},
electronAPI: {
getKeybindings: async () => [],
getMpvInputBindings: () => getMpvInputBindings(),
getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => {
@@ -308,6 +314,7 @@ function installKeyboardTestGlobals() {
altKey?: boolean;
shiftKey?: boolean;
repeat?: boolean;
target?: unknown;
}): void {
const listeners = documentListeners.get('keydown') ?? [];
const keyboardEvent = {
@@ -319,7 +326,7 @@ function installKeyboardTestGlobals() {
shiftKey: event.shiftKey ?? false,
repeat: event.repeat ?? false,
preventDefault: () => {},
target: null,
target: event.target ?? null,
};
for (const listener of listeners) {
listener(keyboardEvent);
@@ -369,6 +376,7 @@ function installKeyboardTestGlobals() {
}
function restore() {
dispatchWindowEvent('beforeunload');
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
@@ -421,6 +429,9 @@ function installKeyboardTestGlobals() {
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value;
},
setGetMpvInputBindings: (value: typeof getMpvInputBindings) => {
getMpvInputBindings = value;
},
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
@@ -2307,3 +2318,66 @@ test('mark-watched keybinding does not send mpv commands when no active session'
testGlobals.restore();
}
});
test('discovered mpv keys only run after SubMiner controls and stay out of session help', async () => {
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['r', 'SPACE', 'y', 'v'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'r'],
['cycle', 'pause'],
]);
assert.equal(ctx.state.sessionBindings.length, 1);
testGlobals.dispatchWindowEvent('blur');
const before = testGlobals.mpvCommands.length;
ctx.state.playlistBrowserModalOpen = true;
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.playlistBrowserModalOpen = false;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR', target: { closest: () => ({}) } });
assert.equal(testGlobals.mpvCommands.length, before);
} finally {
testGlobals.restore();
}
});
test('stalled mpv discovery does not delay configured overlay controls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(() => new Promise(() => {}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
+31
View File
@@ -1,5 +1,6 @@
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
@@ -55,6 +56,11 @@ export function createKeyboardHandlers(
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
let mpvInputForwardingListenersInstalled = false;
let keyboardConfigLoaded = false;
const importedMpvBindings = createMpvInputForwarding({
load: () => window.electronAPI.getMpvInputBindings(),
send: (command) => window.electronAPI.sendMpvCommand(command),
});
const CHORD_MAP = new Map<
string,
@@ -131,6 +137,7 @@ export function createKeyboardHandlers(
ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
);
void importedMpvBindings.refresh();
}
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
@@ -984,6 +991,7 @@ export function createKeyboardHandlers(
]);
updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
keyboardConfigLoaded = true;
syncKeyboardTokenSelection();
}
@@ -1034,6 +1042,18 @@ export function createKeyboardHandlers(
return;
}
mpvInputForwardingListenersInstalled = true;
const lateScriptRefresh = setTimeout(() => {
void importedMpvBindings.refresh();
}, 1500);
window.addEventListener('focus', () => {
void importedMpvBindings.refresh();
});
window.addEventListener('blur', importedMpvBindings.releaseAll);
window.addEventListener('beforeunload', () => {
clearTimeout(lateScriptRefresh);
importedMpvBindings.dispose();
});
document.addEventListener('keyup', importedMpvBindings.keyup, true);
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();
@@ -1248,7 +1268,18 @@ export function createKeyboardHandlers(
if (binding) {
e.preventDefault();
dispatchSessionBinding(binding);
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(e.target)
)
importedMpvBindings.keydown(e);
});
document.addEventListener('mousedown', (e: MouseEvent) => {
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
function keyEvent(
overrides: Partial<Parameters<ReturnType<typeof createMpvInputForwarding>['keydown']>[0]> = {},
) {
return {
key: 'r',
code: 'KeyR',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
repeat: false,
defaultPrevented: false,
isComposing: false,
getModifierState: () => false,
preventDefault: () => {},
...overrides,
};
}
test('forwarded keys use mpv repeat and retain the pressed key through modifier changes', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({ keys: ['r', 'ctrl+A'], blockedKeys: [] }),
send: (command) => commands.push(command),
});
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), true);
assert.equal(forwarding.keydown(keyEvent({ repeat: true })), true);
forwarding.keyup(keyEvent());
forwarding.keydown(keyEvent({ key: 'A', code: 'KeyA', ctrlKey: true, shiftKey: true }));
forwarding.keyup(keyEvent({ key: 'a', code: 'KeyA' }));
assert.deepEqual(commands, [
['keydown', 'r'],
['keyup', 'r'],
['keydown', 'ctrl+A'],
['keyup', 'ctrl+A'],
]);
});
test('configured and disabled keys, handled input, and unknown keys are not forwarded', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({ keys: ['r', 't', '1'], blockedKeys: [{ code: 'KeyR', modifiers: [] }] }),
send: (command) => commands.push(command),
});
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
assert.equal(
forwarding.keydown(keyEvent({ key: 't', code: 'KeyT', defaultPrevented: true })),
false,
);
assert.equal(forwarding.keydown(keyEvent({ key: 'z', code: 'KeyZ' })), false);
assert.equal(forwarding.keydown(keyEvent({ key: '1', code: 'Numpad1' })), false);
assert.deepEqual(commands, []);
});
test('refresh discards stale responses and coalesces concurrent requests', async () => {
let resolveFirst: (snapshot: MpvInputBindingsSnapshot) => void = () => {};
let requests = 0;
const forwarding = createMpvInputForwarding({
load: () => {
requests += 1;
if (requests === 1)
return new Promise((resolve) => {
resolveFirst = resolve;
});
return Promise.resolve({ keys: ['t'], blockedKeys: [] });
},
send: () => {},
});
const first = forwarding.refresh();
const second = forwarding.refresh();
forwarding.refresh();
assert.equal(requests, 1);
resolveFirst({ keys: ['r'], blockedKeys: [] });
await Promise.all([first, second]);
assert.equal(requests, 2);
assert.equal(forwarding.keydown(keyEvent()), false);
assert.equal(forwarding.keydown(keyEvent({ key: 't', code: 'KeyT' })), true);
});
test('focus loss releases held keys and failed refresh clears stale bindings', async () => {
let fail = false;
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => {
if (fail) throw new Error('disconnected');
return { keys: ['r'], blockedKeys: [] };
},
send: (command) => commands.push(command),
});
await forwarding.refresh();
forwarding.keydown(keyEvent());
forwarding.releaseAll();
forwarding.keyup(keyEvent());
assert.deepEqual(commands, [
['keydown', 'r'],
['keyup', 'r'],
]);
fail = true;
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
forwarding.dispose();
fail = false;
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
});
@@ -0,0 +1,91 @@
import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
type ForwardedKeyEvent = Parameters<typeof keyboardEventToMpvKey>[0] &
Pick<KeyboardEvent, 'code' | 'repeat' | 'defaultPrevented' | 'preventDefault'>;
export function createMpvInputForwarding(deps: {
load: () => Promise<MpvInputBindingsSnapshot>;
send: (command: (string | number)[]) => void;
}) {
let keys = new Set<string>();
let blockedKeys: MpvInputBindingsSnapshot['blockedKeys'] = [];
const heldKeys = new Map<string, string>();
let generation = 0;
let disposed = false;
let pending: Promise<void> | null = null;
function releaseAll(): void {
for (const key of heldKeys.values()) deps.send(['keyup', key]);
heldKeys.clear();
}
function refresh(): Promise<void> {
if (disposed) return Promise.resolve();
generation += 1;
keys.clear();
blockedKeys = [];
releaseAll();
if (pending) return pending;
pending = (async () => {
let requestedGeneration: number;
do {
requestedGeneration = generation;
try {
const snapshot = await deps.load();
if (!disposed && requestedGeneration === generation) {
keys = new Set(snapshot.keys);
blockedKeys = snapshot.blockedKeys;
}
} catch {
// Discovery is optional. Keep the existing overlay controls available.
}
} while (!disposed && requestedGeneration !== generation);
})().finally(() => {
pending = null;
});
return pending;
}
function keydown(event: ForwardedKeyEvent): boolean {
if (disposed || event.defaultPrevented) return false;
if (heldKeys.has(event.code)) {
event.preventDefault();
return true;
}
if (event.repeat || event.code.startsWith('Numpad')) return false;
if (
blockedKeys.some(
({ code, modifiers }) =>
code === event.code &&
modifiers.includes('ctrl') === event.ctrlKey &&
modifiers.includes('alt') === event.altKey &&
modifiers.includes('shift') === event.shiftKey &&
modifiers.includes('meta') === event.metaKey,
)
)
return false;
const key = keyboardEventToMpvKey(event);
if (!key || !keys.has(key)) return false;
heldKeys.set(event.code, key);
deps.send(['keydown', key]);
event.preventDefault();
return true;
}
function keyup(event: Pick<KeyboardEvent, 'code' | 'preventDefault'>): void {
const key = heldKeys.get(event.code);
if (!key) return;
heldKeys.delete(event.code);
deps.send(['keyup', key]);
event.preventDefault();
}
function dispose(): void {
disposed = true;
keys.clear();
releaseAll();
}
return { refresh, keydown, keyup, releaseAll, dispose };
}
+1 -1
View File
@@ -18,7 +18,7 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
+1 -1
View File
@@ -1,6 +1,6 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
+1
View File
@@ -61,6 +61,7 @@ export const IPC_CHANNELS = {
getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status',
getKeybindings: 'get-keybindings',
getMpvInputBindings: 'get-mpv-input-bindings',
getSessionBindings: 'get-session-bindings',
getConfigShortcuts: 'get-config-shortcuts',
getStatsToggleKey: 'get-stats-toggle-key',
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
keyboardEventToMpvKey,
normalizeMpvInputKey,
parseMpvInputBindingKeys,
} from './mpv-input-bindings';
test('mpv discovery validates entries and excludes inactive, mouse, sequence, and SubMiner keys', () => {
assert.deepEqual(
parseMpvInputBindingKeys([
{ key: 'r', cmd: 'script-binding replay/run', priority: 1, owner: 'replay' },
{ key: 'r', cmd: 'show-text duplicate', priority: 0 },
{ key: 'Ctrl+A', cmd: 'show-text shifted', priority: 1 },
{ key: 'g-g', cmd: 'seek 0', priority: 1 },
{ key: 'MBTN_LEFT', cmd: 'cycle pause', priority: 1 },
{ key: 'q', cmd: 'quit', priority: -1 },
{ key: 's', cmd: 'screenshot', priority: 1 },
{ key: 's', cmd: 'script-binding subminer/session', priority: 5, owner: 'subminer' },
{ key: 't', cmd: 'script-message subminer-toggle', priority: 1 },
{ key: 'x', cmd: 'ignore', priority: NaN },
{ key: 'z', cmd: 5, priority: 1 },
null,
]),
['r', 'ctrl+A'],
);
assert.deepEqual(parseMpvInputBindingKeys({ key: 'r' }), []);
});
test('mpv keys retain printable characters and normalize modifiers', () => {
assert.equal(normalizeMpvInputKey('Alt+Ctrl+Shift+a'), 'ctrl+alt+A');
assert.equal(normalizeMpvInputKey('Ctrl++'), 'ctrl++');
assert.equal(normalizeMpvInputKey('Shift+LEFT'), 'shift+LEFT');
assert.equal(normalizeMpvInputKey('F12'), 'F12');
assert.equal(normalizeMpvInputKey('UNMAPPED'), null);
});
test('keyboard conversion respects layout characters and skips composition and AltGr', () => {
const event = {
key: 'A',
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
isComposing: false,
getModifierState: () => false,
};
assert.equal(keyboardEventToMpvKey(event), 'ctrl+A');
assert.equal(keyboardEventToMpvKey({ ...event, key: '#', ctrlKey: false }), 'SHARP');
assert.equal(keyboardEventToMpvKey({ ...event, key: 'ArrowLeft', ctrlKey: false }), 'shift+LEFT');
assert.equal(keyboardEventToMpvKey({ ...event, key: 'Dead' }), null);
assert.equal(keyboardEventToMpvKey({ ...event, isComposing: true }), null);
assert.equal(
keyboardEventToMpvKey({ ...event, getModifierState: (key) => key === 'AltGraph' }),
null,
);
});
test('only the highest-priority active binding determines SubMiner ownership', () => {
const user = { key: 'r', cmd: 'script-binding replay/run', is_weak: false, priority: 12 };
const plugin = {
key: 'r',
cmd: 'script-binding subminer/run',
owner: 'subminer',
is_weak: true,
priority: 2,
};
assert.deepEqual(parseMpvInputBindingKeys([plugin, user]), ['r']);
assert.deepEqual(parseMpvInputBindingKeys([user, plugin]), ['r']);
assert.deepEqual(parseMpvInputBindingKeys([user, { ...plugin, priority: -1 }]), ['r']);
assert.deepEqual(
parseMpvInputBindingKeys([user, { ...plugin, is_weak: false, priority: 15 }]),
[],
);
});
test('SubMiner ownership excludes only its script commands and respects explicit owners', () => {
assert.deepEqual(
parseMpvInputBindingKeys([
{ key: 'a', cmd: 'show-text "subminer/readme"', priority: 1 },
{ key: 'b', cmd: 'run subminer-helper', priority: 1 },
{ key: 'c', cmd: 'script-message subminer-toggle', owner: 'other-script', priority: 1 },
{ key: 'd', cmd: 'script-binding subminer/action', owner: 'other-script', priority: 1 },
{ key: 'e', cmd: ' script-binding "subminer/action"', priority: 1 },
{ key: 'f', cmd: 'script-message subminer-toggle', priority: 1 },
{ key: 'g', cmd: 'ignore', owner: 'subminer', priority: 1 },
]),
['a', 'b', 'c', 'd'],
);
});
+98
View File
@@ -0,0 +1,98 @@
const SPECIAL_KEYS: Record<string, string> = {
' ': 'SPACE',
'#': 'SHARP',
Enter: 'ENTER',
Escape: 'ESC',
Backspace: 'BS',
Tab: 'TAB',
Delete: 'DEL',
Insert: 'INS',
Home: 'HOME',
End: 'END',
PageUp: 'PGUP',
PageDown: 'PGDWN',
ArrowLeft: 'LEFT',
ArrowRight: 'RIGHT',
ArrowUp: 'UP',
ArrowDown: 'DOWN',
};
const MPV_SPECIAL_KEYS = new Set(Object.values(SPECIAL_KEYS));
// Only single keyboard strokes are imported. Mouse input and sequences need
// their own focus and conflict rules before they can be forwarded safely.
export function normalizeMpvInputKey(value: string): string | null {
const modifiers = new Set<string>();
let key = value;
let modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key);
while (modifier?.[1]) {
modifiers.add(modifier[1].toLowerCase());
key = key.slice(modifier[0].length);
modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key);
}
if (key === 'SHARP') modifiers.delete('shift');
if (!MPV_SPECIAL_KEYS.has(key) && !/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(key)) {
if ([...key].length !== 1) return null;
if (modifiers.has('shift') && /^[a-z]$/i.test(key)) key = key.toUpperCase();
modifiers.delete('shift');
}
return [...['ctrl', 'alt', 'shift', 'meta'].filter((item) => modifiers.has(item)), key].join('+');
}
export function keyboardEventToMpvKey(
event: Pick<
KeyboardEvent,
'key' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' | 'getModifierState'
>,
): string | null {
if (event.isComposing || event.key === 'Dead' || event.getModifierState?.('AltGraph'))
return null;
const key = SPECIAL_KEYS[event.key] ?? event.key;
const modifiers = [
...(event.ctrlKey ? ['ctrl'] : []),
...(event.altKey ? ['alt'] : []),
...(event.shiftKey ? ['shift'] : []),
...(event.metaKey ? ['meta'] : []),
];
return normalizeMpvInputKey([...modifiers, key].join('+'));
}
export function parseMpvInputBindingKeys(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const bindings = new Map<string, { priority: number; owned: boolean }>();
for (const candidate of value) {
const entry: unknown = candidate;
if (
!entry ||
typeof entry !== 'object' ||
!('key' in entry) ||
typeof entry.key !== 'string' ||
!('cmd' in entry) ||
typeof entry.cmd !== 'string' ||
!('priority' in entry) ||
typeof entry.priority !== 'number' ||
!Number.isFinite(entry.priority) ||
entry.priority < 0
)
continue;
const key = normalizeMpvInputKey(entry.key);
if (!key) continue;
const owner = 'owner' in entry ? entry.owner : undefined;
const owned =
owner === 'subminer' ||
(owner === undefined &&
/^(?:script-binding\s+["']?subminer\/|script-message\s+["']?subminer-)/.test(
entry.cmd.trimStart(),
));
const previous = bindings.get(key);
// mpv's reported priority already ranks active non-weak bindings above weak
// bindings. Only the winning binding determines whether the key is imported.
if (
!previous ||
entry.priority > previous.priority ||
(entry.priority === previous.priority && owned)
) {
bindings.set(key, { priority: entry.priority, owned });
}
}
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
}
+1 -1
View File
@@ -1,6 +1,6 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
+2
View File
@@ -1,3 +1,4 @@
import type { MpvInputBindingsSnapshot } from './session-bindings';
import type {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
@@ -462,6 +463,7 @@ export interface ElectronAPI {
setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>;
getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings: () => Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
dispatchSessionAction: (
+5
View File
@@ -34,6 +34,11 @@ export interface SessionKeySpec {
modifiers: SessionKeyModifier[];
}
export interface MpvInputBindingsSnapshot {
keys: string[];
blockedKeys: SessionKeySpec[];
}
export interface SessionBindingWarning {
kind: 'unsupported' | 'conflict' | 'deprecated-config';
path: string;
+14 -1
View File
@@ -4,10 +4,23 @@ export type WorkflowStep = {
name?: string;
run?: string;
env?: Record<string, unknown>;
uses?: string;
with?: Record<string, unknown>;
};
export type ParsedWorkflow = {
jobs?: Record<string, { steps?: WorkflowStep[] } | undefined>;
on?: { workflow_call?: { secrets?: Record<string, { required?: boolean }> } };
jobs?: Record<
string,
| {
steps?: WorkflowStep[];
uses?: string;
needs?: string | string[];
permissions?: Record<string, string>;
secrets?: string | Record<string, string>;
}
| undefined
>;
};
// Workflow tests only ever run under `bun test`, which parses YAML natively.