mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a0bf4db0
|
||
|
|
e6dc9dfec5
|
||
|
|
6d69a56574 | ||
|
|
0c37c665a2 | ||
|
|
8ae5bde6a4
|
@@ -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
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({}), []);
|
||||
});
|
||||
|
||||
@@ -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?.() ?? [];
|
||||
});
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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,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,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: (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user