mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Compare commits
8 Commits
main
...
v0.12.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 |
389
.github/workflows/prerelease.yml
vendored
Normal file
389
.github/workflows/prerelease.yml
vendored
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
name: Prerelease
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*-beta.*'
|
||||||
|
- 'v*-rc.*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: prerelease-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
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/subminer-yomitan/node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', '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: Lint stats (formatting)
|
||||||
|
run: bun run lint:stats
|
||||||
|
|
||||||
|
- name: Build (TypeScript check)
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test suite (source)
|
||||||
|
run: bun run test:fast
|
||||||
|
|
||||||
|
- name: Coverage suite (maintained source lane)
|
||||||
|
run: bun run test:coverage:src
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-test-src
|
||||||
|
path: coverage/test-src/lcov.info
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Launcher smoke suite (source)
|
||||||
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
- name: Upload launcher smoke artifacts (on failure)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: launcher-smoke
|
||||||
|
path: .tmp/launcher-smoke/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
- name: Build (bundle)
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Immersion SQLite verification
|
||||||
|
run: bun run test:immersion:sqlite:dist
|
||||||
|
|
||||||
|
- name: Dist smoke suite
|
||||||
|
run: bun run test:smoke:dist
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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: 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
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build-linux, build-macos, build-windows]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download AppImage
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: appimage
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Download macOS artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Download Windows artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- 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
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build Bun subminer wrapper
|
||||||
|
run: make build-launcher
|
||||||
|
|
||||||
|
- name: Verify Bun subminer wrapper
|
||||||
|
run: dist/launcher/subminer --help >/dev/null
|
||||||
|
|
||||||
|
- name: Enforce generated launcher workflow
|
||||||
|
run: bash scripts/verify-generated-launcher.sh
|
||||||
|
|
||||||
|
- name: Verify generated config examples
|
||||||
|
run: bun run verify:config-example
|
||||||
|
|
||||||
|
- name: Package optional assets bundle
|
||||||
|
run: |
|
||||||
|
tar -czf "release/subminer-assets.tar.gz" \
|
||||||
|
config.example.jsonc \
|
||||||
|
plugin/subminer \
|
||||||
|
plugin/subminer.conf \
|
||||||
|
assets/themes/subminer.rasi
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No release artifacts found for checksum generation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sha256sum "${files[@]}" > release/SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate prerelease notes from pending fragments
|
||||||
|
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
- name: Publish Prerelease
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--draft=false \
|
||||||
|
--prerelease \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release/prerelease-notes.md
|
||||||
|
else
|
||||||
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--latest=false \
|
||||||
|
--prerelease \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release/prerelease-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts=(
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.dmg
|
||||||
|
release/*.exe
|
||||||
|
release/*.zip
|
||||||
|
release/*.tar.gz
|
||||||
|
release/SHA256SUMS.txt
|
||||||
|
dist/launcher/subminer
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#artifacts[@]}" -eq 0 ]; then
|
||||||
|
echo "No release artifacts found for upload."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for asset in "${artifacts[@]}"; do
|
||||||
|
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||||
|
done
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-beta.*'
|
||||||
|
- '!v*-rc.*'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
|
|||||||
5
changes/2026-04-09-prerelease-workflow.md
Normal file
5
changes/2026-04-09-prerelease-workflow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
|
||||||
|
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
|
||||||
@@ -30,3 +30,9 @@ Rules:
|
|||||||
- each non-empty body line becomes a bullet
|
- each non-empty body line becomes a bullet
|
||||||
- `README.md` is ignored by the generator
|
- `README.md` is ignored by the generator
|
||||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||||
|
|
||||||
|
Prerelease notes:
|
||||||
|
|
||||||
|
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||||
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
|
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
||||||
|
|||||||
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
|
||||||
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
|
## Stable Release
|
||||||
|
|
||||||
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
||||||
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
||||||
3. Run `bun run changelog:lint`.
|
3. Run `bun run changelog:lint`.
|
||||||
@@ -24,15 +26,37 @@
|
|||||||
10. Tag the commit: `git tag v<version>`.
|
10. Tag the commit: `git tag v<version>`.
|
||||||
11. Push commit + tag.
|
11. Push commit + tag.
|
||||||
|
|
||||||
|
## Prerelease
|
||||||
|
|
||||||
|
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
|
||||||
|
2. Run `bun run changelog:lint`.
|
||||||
|
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
|
||||||
|
4. Run the prerelease gate locally:
|
||||||
|
`bun run changelog:prerelease-notes --version <version>`
|
||||||
|
`bun run verify:config-example`
|
||||||
|
`bun run typecheck`
|
||||||
|
`bun run test:fast`
|
||||||
|
`bun run test:env`
|
||||||
|
`bun run build`
|
||||||
|
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
|
||||||
|
6. Tag the commit: `git tag v<version>`.
|
||||||
|
7. Push commit + tag.
|
||||||
|
|
||||||
|
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
||||||
|
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||||
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
|
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
||||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
|
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.11.2",
|
"version": "0.12.0-beta.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
||||||
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
||||||
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
||||||
|
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:src": "bash scripts/prettier-scope.sh --write",
|
"format:src": "bash scripts/prettier-scope.sh --write",
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
@@ -310,3 +310,186 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-beta-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
|
||||||
|
const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
|
||||||
|
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
|
||||||
|
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
|
||||||
|
fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '002.md'),
|
||||||
|
['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(changelogPath, 'utf8'),
|
||||||
|
existingChangelog,
|
||||||
|
'stable CHANGELOG.md should remain unchanged',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(docsChangelogPath, 'utf8'),
|
||||||
|
existingDocsChangelog,
|
||||||
|
'docs-site changelog should remain unchanged',
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
|
||||||
|
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
||||||
|
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
|
||||||
|
assert.match(
|
||||||
|
prereleaseNotes,
|
||||||
|
/### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
|
||||||
|
);
|
||||||
|
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-rc-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-rc.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(
|
||||||
|
prereleaseNotes,
|
||||||
|
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-alpha-reject');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-alpha.1',
|
||||||
|
}),
|
||||||
|
/Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-version-mismatch');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.2',
|
||||||
|
}),
|
||||||
|
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-no-fragments');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.1',
|
||||||
|
}),
|
||||||
|
/No changelog fragments found in changes\//,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type PullRequestChangelogOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
||||||
|
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
||||||
const CHANGELOG_HEADER = '# Changelog';
|
const CHANGELOG_HEADER = '# Changelog';
|
||||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||||
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
||||||
@@ -75,6 +76,10 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
|
|||||||
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedPrereleaseVersion(version: string): boolean {
|
||||||
|
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
||||||
|
}
|
||||||
|
|
||||||
function verifyRequestedVersionMatchesPackageVersion(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
|
|||||||
return [path.join(cwd, 'CHANGELOG.md')];
|
return [path.join(cwd, 'CHANGELOG.md')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseNotes(changes: string): string {
|
function renderReleaseNotes(
|
||||||
|
changes: string,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||||
return [
|
return [
|
||||||
|
...prefix,
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
|
function writeReleaseNotesFile(
|
||||||
|
cwd: string,
|
||||||
|
changes: string,
|
||||||
|
deps?: ChangelogFsDeps,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
|
||||||
|
|
||||||
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
||||||
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
|
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
|
||||||
return releaseNotesPath;
|
return releaseNotesPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
|||||||
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||||
|
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
||||||
|
|
||||||
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
|
const version = resolveVersion(options ?? {});
|
||||||
|
if (!isSupportedPrereleaseVersion(version)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragments = readChangeFragments(cwd, options?.deps);
|
||||||
|
if (fragments.length === 0) {
|
||||||
|
throw new Error('No changelog fragments found in changes/.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = renderGroupedChanges(fragments);
|
||||||
|
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||||
|
disclaimer:
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
outputPath: PRERELEASE_NOTES_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): {
|
function parseCliArgs(argv: string[]): {
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -710,6 +754,11 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'prerelease-notes') {
|
||||||
|
writePrereleaseNotesForVersion(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === 'docs') {
|
if (command === 'docs') {
|
||||||
generateDocsChangelog(options);
|
generateDocsChangelog(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
parseClipboardVideoPath,
|
parseClipboardVideoPath,
|
||||||
type DropDataTransferLike,
|
type DropDataTransferLike,
|
||||||
@@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
|
|||||||
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
|
||||||
|
const transfer = makeTransfer({
|
||||||
|
files: [
|
||||||
|
{ path: '/subs/ep02.ass' },
|
||||||
|
{ path: '/subs/readme.txt' },
|
||||||
|
{ path: '/subs/ep03.SRT' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = collectDroppedSubtitlePaths(transfer);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
|
||||||
|
const transfer = makeTransfer({
|
||||||
|
getData: (format: string) =>
|
||||||
|
format === 'text/uri-list'
|
||||||
|
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = collectDroppedSubtitlePaths(transfer);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
||||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
||||||
|
|
||||||
@@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
|
||||||
|
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['sub-add', '/tmp/ep01.ass', 'select'],
|
||||||
|
['sub-add', '/tmp/ep02.srt'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
||||||
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
|
|||||||
'.wmv',
|
'.wmv',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
|
||||||
|
|
||||||
function getPathExtension(pathValue: string): string {
|
function getPathExtension(pathValue: string): string {
|
||||||
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
||||||
const dot = normalized.lastIndexOf('.');
|
const dot = normalized.lastIndexOf('.');
|
||||||
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
|
|||||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUriList(data: string): string[] {
|
function isSupportedSubtitlePath(pathValue: string): boolean {
|
||||||
|
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
|
||||||
if (!data.trim()) return [];
|
if (!data.trim()) return [];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
|
|||||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||||
filePath = filePath.slice(1);
|
filePath = filePath.slice(1);
|
||||||
}
|
}
|
||||||
if (filePath && isSupportedVideoPath(filePath)) {
|
if (filePath && isSupportedPath(filePath)) {
|
||||||
out.push(filePath);
|
out.push(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
|
|||||||
|
|
||||||
export function collectDroppedVideoPaths(
|
export function collectDroppedVideoPaths(
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDroppedSubtitlePaths(
|
||||||
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDroppedPaths(
|
||||||
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
isSupportedPath: (pathValue: string) => boolean,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!dataTransfer) return [];
|
if (!dataTransfer) return [];
|
||||||
|
|
||||||
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
|
|||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
const trimmed = candidate.trim();
|
const trimmed = candidate.trim();
|
||||||
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
};
|
};
|
||||||
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataTransfer.getData === 'function') {
|
if (typeof dataTransfer.getData === 'function') {
|
||||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
|
||||||
addPath(pathValue);
|
addPath(pathValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
|
|||||||
index === 0 ? 'replace' : 'append',
|
index === 0 ? 'replace' : 'append',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
|
||||||
|
return paths.map((pathValue, index) =>
|
||||||
|
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ type WindowTrackerStub = {
|
|||||||
|
|
||||||
function createMainWindowRecorder() {
|
function createMainWindowRecorder() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let visible = false;
|
||||||
const window = {
|
const window = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
isVisible: () => visible,
|
||||||
hide: () => {
|
hide: () => {
|
||||||
|
visible = false;
|
||||||
calls.push('hide');
|
calls.push('hide');
|
||||||
},
|
},
|
||||||
show: () => {
|
show: () => {
|
||||||
|
visible = true;
|
||||||
calls.push('show');
|
calls.push('show');
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
@@ -200,6 +204,134 @@ test('Windows visible overlay stays click-through and does not steal focus while
|
|||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tracked Windows overlay refresh preserves renderer-managed mouse interaction when already visible', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(!calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced passthrough still reapplies while visible on Windows', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
});
|
||||||
|
|
||||||
test('visible overlay stays hidden while a modal window is active', () => {
|
test('visible overlay stays hidden while a modal window is active', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
|
|||||||
@@ -37,13 +37,21 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
const showPassiveVisibleOverlay = (): void => {
|
||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
const shouldDefaultToPassthrough =
|
||||||
|
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
|
||||||
|
const wasVisible = mainWindow.isVisible();
|
||||||
|
|
||||||
|
if (!wasVisible || forceMousePassthrough) {
|
||||||
|
if (shouldDefaultToPassthrough) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
args.ensureOverlayWindowLevel(mainWindow);
|
||||||
|
if (!wasVisible) {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
|
}
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/prerelease-workflow.test.ts
Normal file
61
src/prerelease-workflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
|
||||||
|
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8');
|
||||||
|
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
|
scripts: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('prerelease workflow triggers on beta and rc tags only', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /name: Prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
|
||||||
|
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
|
||||||
|
assert.equal(
|
||||||
|
packageJson.scripts['changelog:prerelease-notes'],
|
||||||
|
'bun run scripts/build-changelog.ts prerelease-notes',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow generates prerelease notes from pending fragments', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
|
||||||
|
assert.match(
|
||||||
|
prereleaseWorkflow,
|
||||||
|
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
prereleaseWorkflow,
|
||||||
|
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow does not publish to AUR', () => {
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
|
||||||
|
});
|
||||||
@@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
|
|||||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stable release workflow excludes prerelease beta and rc tags', () => {
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
|
||||||
|
});
|
||||||
|
|
||||||
test('publish release forces an existing draft tag release to become public', () => {
|
test('publish release forces an existing draft tag release to become public', () => {
|
||||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createRendererRecoveryController } from './error-recovery.js';
|
import { createRendererRecoveryController } from './error-recovery.js';
|
||||||
import {
|
import {
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
@@ -284,9 +286,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
|
||||||
|
const selectors: string[] = [];
|
||||||
|
const root = {
|
||||||
|
querySelector: (value: string) => {
|
||||||
|
selectors.push(value);
|
||||||
|
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(hasYomitanPopupIframe(root), true);
|
||||||
|
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
|
||||||
|
});
|
||||||
|
|
||||||
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
let selector = '';
|
const selectors: string[] = [];
|
||||||
const visibleFrame = {
|
const visibleFrame = {
|
||||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
} as unknown as HTMLIFrameElement;
|
} as unknown as HTMLIFrameElement;
|
||||||
@@ -309,18 +327,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
|||||||
try {
|
try {
|
||||||
const root = {
|
const root = {
|
||||||
querySelectorAll: (value: string) => {
|
querySelectorAll: (value: string) => {
|
||||||
selector = value;
|
selectors.push(value);
|
||||||
|
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [hiddenFrame, visibleFrame];
|
return [hiddenFrame, visibleFrame];
|
||||||
},
|
},
|
||||||
} as unknown as ParentNode;
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
assert.equal(isYomitanPopupVisible(root), true);
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.deepEqual(selectors, [
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
|
||||||
|
let selector = '';
|
||||||
|
const root = {
|
||||||
|
querySelectorAll: (value: string) => {
|
||||||
|
selector = value;
|
||||||
|
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||||
|
return [{ getAttribute: () => 'true' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
|
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||||
|
});
|
||||||
|
|
||||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||||
const activeItem = {
|
const activeItem = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
|
|||||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import {
|
import {
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
YOMITAN_POPUP_COMMAND_EVENT,
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
@@ -61,6 +62,9 @@ export function createKeyboardHandlers(
|
|||||||
if (target.closest('.modal')) return true;
|
if (target.closest('.modal')) return true;
|
||||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||||
if (isYomitanPopupIframe(target)) return true;
|
if (isYomitanPopupIframe(target)) return true;
|
||||||
|
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import type { SubtitleSidebarConfig } from '../../types';
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -78,11 +83,13 @@ function createMouseTestContext() {
|
|||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
shouldToggleMouseIgnore: false,
|
shouldToggleMouseIgnore: false,
|
||||||
|
isLinuxPlatform: false,
|
||||||
isMacOSPlatform: false,
|
isMacOSPlatform: false,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
subtitleSidebarModalOpen: false,
|
subtitleSidebarModalOpen: false,
|
||||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -712,6 +719,257 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
let overlayFocusCalls = 0;
|
||||||
|
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||||
|
overlayFocusCalls += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePopupHost = {
|
||||||
|
tagName: 'DIV',
|
||||||
|
getAttribute: (name: string) =>
|
||||||
|
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: (selector: string) => {
|
||||||
|
if (
|
||||||
|
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||||
|
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||||
|
) {
|
||||||
|
return [visiblePopupHost];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(focusMainWindowCalls, 1);
|
||||||
|
assert.equal(windowFocusCalls, 1);
|
||||||
|
assert.equal(overlayFocusCalls, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
let overlayFocusCalls = 0;
|
||||||
|
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||||
|
overlayFocusCalls += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePopupHost = {
|
||||||
|
tagName: 'DIV',
|
||||||
|
getAttribute: (name: string) =>
|
||||||
|
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
visibilityState: 'visible',
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: (selector: string) => {
|
||||||
|
if (
|
||||||
|
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||||
|
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||||
|
) {
|
||||||
|
return [visiblePopupHost];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get('blur') ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(focusMainWindowCalls, 1);
|
||||||
|
assert.equal(windowFocusCalls, 1);
|
||||||
|
assert.equal(overlayFocusCalls, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -916,10 +1174,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
|||||||
|
|
||||||
assert.equal(ctx.state.isOverSubtitle, false);
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
assert.deepEqual(ignoreCalls, [
|
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||||
{ ignore: false, forward: undefined },
|
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||||
{ ignore: true, forward: true },
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
@@ -34,6 +36,60 @@ export function createMouseHandlers(
|
|||||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
let pendingPointerResync = false;
|
let pendingPointerResync = false;
|
||||||
|
|
||||||
|
function getPopupVisibilityFromDom(): boolean {
|
||||||
|
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
||||||
|
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
||||||
|
yomitanPopupVisible = popupVisible;
|
||||||
|
ctx.state.yomitanPopupVisible = popupVisible;
|
||||||
|
return popupVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reclaimOverlayWindowFocusForPopup(): void {
|
||||||
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.electronAPI.focusMainWindow === 'function') {
|
||||||
|
void window.electronAPI.focusMainWindow();
|
||||||
|
}
|
||||||
|
window.focus();
|
||||||
|
if (typeof ctx.dom.overlay.focus === 'function') {
|
||||||
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sustainPopupInteraction(): void {
|
||||||
|
syncPopupVisibilityState(true);
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcilePopupInteraction(args: {
|
||||||
|
assumeVisible?: boolean;
|
||||||
|
reclaimFocus?: boolean;
|
||||||
|
allowPause?: boolean;
|
||||||
|
} = {}): boolean {
|
||||||
|
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
||||||
|
if (!popupVisible) {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
if (args.reclaimFocus === true) {
|
||||||
|
reclaimOverlayWindowFocusForPopup();
|
||||||
|
}
|
||||||
|
if (args.allowPause === true) {
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -205,18 +261,14 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
sustainPopupInteraction();
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||||
yomitanPopupVisible = true;
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,19 +408,37 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
reconcilePopupInteraction({ allowPause: true });
|
||||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||||
|
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||||
|
reconcilePopupInteraction({ assumeVisible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
reconcilePopupInteraction();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconcilePopupInteraction({ reclaimFocus: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
|||||||
@@ -61,3 +61,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
|||||||
Object.assign(globalThis, { window: originalWindow });
|
Object.assign(globalThis, { window: originalWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
||||||
|
const classList = createClassList();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
querySelectorAll: (selector: string) =>
|
||||||
|
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||||
|
? [{ getAttribute: () => 'true' }]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncOverlayMouseIgnoreState({
|
||||||
|
dom: {
|
||||||
|
overlay: { classList },
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
import type { RendererState } from './state';
|
import type { RendererState } from './state';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
|
|
||||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||||
|
if (state.yomitanPopupVisible) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isYomitanPopupVisible(document);
|
||||||
|
}
|
||||||
|
|
||||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
ctx.state.yomitanPopupVisible ||
|
isYomitanPopupInteractionActive(ctx.state) ||
|
||||||
isBlockingOverlayModalOpen(ctx.state);
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
if (shouldStayInteractive) {
|
if (shouldStayInteractive) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js';
|
|||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
} from '../core/services/overlay-drop.js';
|
} from '../core/services/overlay-drop.js';
|
||||||
|
|
||||||
@@ -706,18 +708,28 @@ function setupDragDropToMpvQueue(): void {
|
|||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
||||||
|
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
||||||
|
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
||||||
for (const command of loadCommands) {
|
for (const command of loadCommands) {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
}
|
}
|
||||||
|
for (const command of subtitleCommands) {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
}
|
||||||
|
const osdParts: string[] = [];
|
||||||
if (loadCommands.length > 0) {
|
if (loadCommands.length > 0) {
|
||||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||||
window.electronAPI.sendMpvCommand([
|
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
||||||
'show-text',
|
}
|
||||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
if (subtitleCommands.length > 0) {
|
||||||
'1500',
|
osdParts.push(
|
||||||
]);
|
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (osdParts.length > 0) {
|
||||||
|
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDropInteractive();
|
clearDropInteractive();
|
||||||
|
|||||||
@@ -684,7 +684,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.settings-modal-open iframe.yomitan-popup,
|
body.settings-modal-open iframe.yomitan-popup,
|
||||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
body.settings-modal-open iframe[id^='yomitan-popup'],
|
||||||
|
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
@@ -1151,7 +1152,8 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iframe.yomitan-popup,
|
iframe.yomitan-popup,
|
||||||
iframe[id^='yomitan-popup'] {
|
iframe[id^='yomitan-popup'],
|
||||||
|
[data-subminer-yomitan-popup-host='true'] {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
z-index: 2147483647 !important;
|
z-index: 2147483647 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
||||||
|
export const YOMITAN_POPUP_HOST_SELECTOR = '[data-subminer-yomitan-popup-host="true"]';
|
||||||
|
export const YOMITAN_POPUP_VISIBLE_HOST_SELECTOR =
|
||||||
|
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]';
|
||||||
|
const YOMITAN_POPUP_VISIBLE_ATTRIBUTE = 'data-subminer-yomitan-popup-visible';
|
||||||
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||||
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||||
@@ -29,21 +33,56 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
return (
|
||||||
|
typeof root.querySelector === 'function' &&
|
||||||
|
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
||||||
|
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisiblePopupElement(element: Element): boolean {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = window.getComputedStyle(element);
|
||||||
|
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkedVisiblePopupHost(element: Element): boolean {
|
||||||
|
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] {
|
||||||
|
if (typeof root.querySelectorAll !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from(root.querySelectorAll<T>(selector));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||||
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||||
for (const iframe of popupIframes) {
|
if (visiblePopupHosts.length > 0) {
|
||||||
const rect = iframe.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const styles = window.getComputedStyle(iframe);
|
|
||||||
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
for (const iframe of popupIframes) {
|
||||||
|
if (isVisiblePopupElement(iframe)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
||||||
|
for (const host of popupHosts) {
|
||||||
|
if (isMarkedVisiblePopupHost(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 69620abcbc...ed31b7a3ee
Reference in New Issue
Block a user