mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a526531d7
|
||
|
|
04c91add22
|
||
|
|
d5fb43caa3
|
||
|
|
7b92511f23
|
||
|
|
0f53b8beca
|
||
|
|
ca534cd232
|
||
|
|
d3a94536a8
|
||
|
|
3068d02f89
|
||
|
|
9150a60630 | ||
|
|
3549e0ed93 |
No files matched your search
@@ -67,26 +67,26 @@ jobs:
|
|||||||
- name: Build unversioned AppImage
|
- name: Build unversioned AppImage
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
appimages=(release/SubMiner-*.AppImage)
|
appimages=(dist/release/SubMiner-*.AppImage)
|
||||||
if [ "${#appimages[@]}" -eq 0 ]; then
|
if [ "${#appimages[@]}" -eq 0 ]; then
|
||||||
echo "No versioned AppImage found to create unversioned artifact."
|
echo "No versioned AppImage found to create unversioned artifact."
|
||||||
ls -la release
|
ls -la dist/release
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
cp "${appimages[0]}" release/SubMiner.AppImage
|
cp "${appimages[0]}" dist/release/SubMiner.AppImage
|
||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
|
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
|
||||||
|
|
||||||
- name: Upload AppImage artifact
|
- name: Upload AppImage artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: |
|
path: |
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -159,17 +159,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
|
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
- name: Upload macOS artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: |
|
path: |
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -224,15 +224,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun run test:package "release/win-unpacked/resources"
|
run: bun run test:package "dist/release/win-unpacked/resources"
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
- name: Upload Windows artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: |
|
path: |
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -44,19 +44,19 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
- name: Download macOS artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -90,13 +90,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Package optional assets bundle
|
- name: Package optional assets bundle
|
||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||||
|
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||||
|
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi \
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||||
resources/bun/licenses
|
packaging/bun/licenses
|
||||||
|
|
||||||
- name: Package Bun corresponding source
|
- name: Package Bun corresponding source
|
||||||
run: bun scripts/package-bun-source.mjs
|
run: bun scripts/package-bun-source.mjs
|
||||||
@@ -104,16 +106,16 @@ jobs:
|
|||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||||
if [ "${#files[@]}" -eq 0 ]; then
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
echo "No release artifacts found for checksum generation."
|
echo "No release artifacts found for checksum generation."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
: > release/SHA256SUMS.txt
|
: > dist/release/SHA256SUMS.txt
|
||||||
for file in "${files[@]}"; do
|
for file in "${files[@]}"; do
|
||||||
printf '%s %s\n' \
|
printf '%s %s\n' \
|
||||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||||
"${file##*/}" >> release/SHA256SUMS.txt
|
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
@@ -124,12 +126,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
if [ ! -s release/prerelease-notes.md ]; then
|
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
|
||||||
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
||||||
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -142,15 +144,15 @@ jobs:
|
|||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
artifacts=(
|
artifacts=(
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/*.tar.gz
|
dist/release/*.tar.gz
|
||||||
release/*.tar.gz.sha256
|
dist/release/*.tar.gz.sha256
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
release/SHA256SUMS.txt
|
dist/release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
dist/launcher/subminer.cmd
|
dist/launcher/subminer.cmd
|
||||||
)
|
)
|
||||||
@@ -165,14 +167,14 @@ jobs:
|
|||||||
--draft \
|
--draft \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
else
|
else
|
||||||
gh release create "$RELEASE_VERSION" \
|
gh release create "$RELEASE_VERSION" \
|
||||||
--draft \
|
--draft \
|
||||||
--latest=false \
|
--latest=false \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for asset in "${artifacts[@]}"; do
|
for asset in "${artifacts[@]}"; do
|
||||||
@@ -183,4 +185,4 @@ jobs:
|
|||||||
--draft=false \
|
--draft=false \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
@@ -45,19 +45,19 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
- name: Download macOS artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -91,13 +91,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Package optional assets bundle
|
- name: Package optional assets bundle
|
||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||||
|
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||||
|
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi \
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||||
resources/bun/licenses
|
packaging/bun/licenses
|
||||||
|
|
||||||
- name: Package Bun corresponding source
|
- name: Package Bun corresponding source
|
||||||
run: bun scripts/package-bun-source.mjs
|
run: bun scripts/package-bun-source.mjs
|
||||||
@@ -105,16 +107,16 @@ jobs:
|
|||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||||
if [ "${#files[@]}" -eq 0 ]; then
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
echo "No release artifacts found for checksum generation."
|
echo "No release artifacts found for checksum generation."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
: > release/SHA256SUMS.txt
|
: > dist/release/SHA256SUMS.txt
|
||||||
for file in "${files[@]}"; do
|
for file in "${files[@]}"; do
|
||||||
printf '%s %s\n' \
|
printf '%s %s\n' \
|
||||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||||
"${file##*/}" >> release/SHA256SUMS.txt
|
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
@@ -152,24 +154,24 @@ jobs:
|
|||||||
gh release edit "$RELEASE_VERSION" \
|
gh release edit "$RELEASE_VERSION" \
|
||||||
--draft=false \
|
--draft=false \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/release-notes.md
|
--notes-file packaging/release-notes/release-notes.md
|
||||||
else
|
else
|
||||||
gh release create "$RELEASE_VERSION" \
|
gh release create "$RELEASE_VERSION" \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/release-notes.md
|
--notes-file packaging/release-notes/release-notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
artifacts=(
|
artifacts=(
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/*.tar.gz
|
dist/release/*.tar.gz
|
||||||
release/*.tar.gz.sha256
|
dist/release/*.tar.gz.sha256
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
release/SHA256SUMS.txt
|
dist/release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
dist/launcher/subminer.cmd
|
dist/launcher/subminer.cmd
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-7
@@ -8,11 +8,6 @@ node_modules/
|
|||||||
# Electron build output
|
# Electron build output
|
||||||
out/
|
out/
|
||||||
dist/
|
dist/
|
||||||
release/*
|
|
||||||
!release/
|
|
||||||
!release/release-notes.md
|
|
||||||
!release/prerelease-notes.md
|
|
||||||
build/yomitan/
|
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Launcher build artifact (produced by make build-launcher)
|
# Launcher build artifact (produced by make build-launcher)
|
||||||
@@ -20,8 +15,6 @@ coverage/
|
|||||||
/main-entry.js
|
/main-entry.js
|
||||||
/main-entry.js.map
|
/main-entry.js.map
|
||||||
|
|
||||||
/build/hachidori/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -43,6 +36,7 @@ environment.toml
|
|||||||
.vitepress/dist/
|
.vitepress/dist/
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
|
# Local test media and scripts
|
||||||
tests/*
|
tests/*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
release
|
|
||||||
coverage
|
coverage
|
||||||
vendor
|
vendor
|
||||||
*.log
|
*.log
|
||||||
src/core/services/tokenizer/__fixtures__/golden/*.json
|
src/core/services/tokenizer/__fixtures__/golden/*.json
|
||||||
|
# Reviewed release text and vendored license texts; never reformat
|
||||||
|
packaging/release-notes
|
||||||
|
packaging/bun/licenses
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
|||||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||||
|
|
||||||
# If building from source, the AppImage will typically land in release/.
|
# If building from source, the AppImage will typically land in dist/release/.
|
||||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
|
||||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
|
||||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
|
||||||
PRERELEASE_NOTES := release/prerelease-notes.md
|
|
||||||
|
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -57,7 +56,7 @@ help:
|
|||||||
" build-linux Build Linux AppImage" \
|
" build-linux Build Linux AppImage" \
|
||||||
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
||||||
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
||||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
" clean Remove build artifacts (dist/, AppImage, binary)" \
|
||||||
" dev-start Build and launch local Electron app" \
|
" dev-start Build and launch local Electron app" \
|
||||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||||
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||||
@@ -165,15 +164,7 @@ build-launcher:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||||
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
@rm -rf dist
|
||||||
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
|
||||||
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
|
||||||
rm -rf dist release && \
|
|
||||||
install -d release && \
|
|
||||||
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
|
||||||
else \
|
|
||||||
rm -rf dist release; \
|
|
||||||
fi
|
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
|
|
||||||
generate-config: ensure-bun
|
generate-config: ensure-bun
|
||||||
@@ -229,7 +220,7 @@ install-linux: build-launcher
|
|||||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||||
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
||||||
else \
|
else \
|
||||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||||
@@ -252,7 +243,7 @@ install-macos: build-launcher
|
|||||||
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
||||||
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
||||||
else \
|
else \
|
||||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
|
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Look up words with Yomitan or Hachidori, mine them to Anki, and track your immer
|
|||||||
|
|
||||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||||
|
|
||||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||||
@@ -38,7 +38,7 @@ Yomitan remains the default. Select the bundled Hachidori backend with `dictiona
|
|||||||
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
|
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -48,7 +48,7 @@ Create an Anki card from the exact playback moment with one key press, click, or
|
|||||||
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency colors, character portraits, and JLPT underlines">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
|||||||
<td><b>WebSocket</b></td>
|
<td><b>WebSocket</b></td>
|
||||||
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Plugins</b></td>
|
||||||
|
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
|
||||||
|
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
|
||||||
|
const { mkdtemp, readFile, rm } = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
codexPath: 'codex',
|
||||||
|
model: '',
|
||||||
|
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
before: 2,
|
||||||
|
after: 1,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
pause: true,
|
||||||
|
prompts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
explain: {
|
||||||
|
title: 'Grammar',
|
||||||
|
prompt:
|
||||||
|
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
|
||||||
|
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
|
||||||
|
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
title: 'Translation',
|
||||||
|
prompt:
|
||||||
|
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
|
||||||
|
'Reply with the translation, then one short note only if the line is ambiguous.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function activate(ctx) {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
|
for (const [name, action] of Object.entries(ACTIONS)) {
|
||||||
|
const prompt = settings.prompts?.[name] ?? action.prompt;
|
||||||
|
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(text) {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
|
||||||
|
function oneLine(text) {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(text, lineCount) {
|
||||||
|
return text.trim().split('\n').slice(-lineCount).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(ctx, settings, title, instructions) {
|
||||||
|
const { before, current, after } = ctx.subtitles.context({
|
||||||
|
before: settings.before,
|
||||||
|
after: settings.after,
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
ctx.notify('No subtitle line to send.', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
|
||||||
|
|
||||||
|
const header = quote(current.text);
|
||||||
|
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
|
||||||
|
const prompt = [
|
||||||
|
instructions,
|
||||||
|
'',
|
||||||
|
'Subtitles:',
|
||||||
|
...before.map((line) => oneLine(line.text)),
|
||||||
|
`>>> ${oneLine(current.text)}`,
|
||||||
|
...after.map((line) => oneLine(line.text)),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
|
||||||
|
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
|
||||||
|
const answerPath = path.join(workDir, 'answer.md');
|
||||||
|
const args = [
|
||||||
|
'exec',
|
||||||
|
'--ephemeral',
|
||||||
|
'--skip-git-repo-check',
|
||||||
|
'--sandbox',
|
||||||
|
'read-only',
|
||||||
|
'--color',
|
||||||
|
'never',
|
||||||
|
'-C',
|
||||||
|
workDir,
|
||||||
|
'-o',
|
||||||
|
answerPath,
|
||||||
|
];
|
||||||
|
if (settings.model) args.push('-m', settings.model);
|
||||||
|
if (settings.reasoningEffort) {
|
||||||
|
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
|
||||||
|
}
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ctx.exec(settings.codexPath, args, {
|
||||||
|
stdin: prompt,
|
||||||
|
timeoutMs: settings.timeoutMs,
|
||||||
|
signal: panel.signal,
|
||||||
|
});
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
|
||||||
|
}
|
||||||
|
const answer = (await readFile(answerPath, 'utf8')).trim();
|
||||||
|
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
|
||||||
|
} catch (error) {
|
||||||
|
// Closing the panel aborts the request; there is nobody left to show the error to.
|
||||||
|
if (panel.signal.aborted) return;
|
||||||
|
const message =
|
||||||
|
error?.code === 'ENOENT'
|
||||||
|
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
|
||||||
|
: String(error?.message ?? error);
|
||||||
|
ctx.log.warn(`Codex request failed: ${message}`);
|
||||||
|
panel.update({
|
||||||
|
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "AI Explain",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "main.js"
|
||||||
|
}
|
||||||
+2
-2
@@ -44,11 +44,11 @@ How fragments turn into a release:
|
|||||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||||
|
|
||||||
Prerelease notes:
|
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 tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
|
||||||
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
||||||
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: added
|
||||||
|
area: plugins
|
||||||
|
|
||||||
|
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||||
|
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: readme
|
||||||
|
|
||||||
|
- Refreshed the README's anime screenshots using the current SubMiner build.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: build
|
||||||
|
|
||||||
|
- Moved packaging inputs (electron-builder resources, Bun runtime manifests and licenses, committed release notes) under `packaging/`.
|
||||||
|
- Build output now lands in `dist/`: packaged apps in `dist/release/`, staged Yomitan and Hachidori in `dist/yomitan/` and `dist/hachidori/`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- mpv keys and mouse buttons (such as `9`/`0` volume and double-click fullscreen) pass through the overlay again while the subtitle sidebar is open; only input inside the sidebar stays with it.
|
||||||
@@ -218,6 +218,14 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Plugins
|
||||||
|
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||||
|
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||||
|
// Restart SubMiner after changing plugins.
|
||||||
|
// ==========================================
|
||||||
|
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'AniList', link: '/anilist-integration' },
|
{ text: 'AniList', link: '/anilist-integration' },
|
||||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||||
|
{ text: 'Plugins', link: '/plugins' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
|
|||||||
|
|
||||||
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
|
||||||
|
|
||||||
|
| Key | Default | What it does |
|
||||||
|
| ----------------------- | ------- | -------------------------------------- |
|
||||||
|
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
|
||||||
|
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
|
||||||
|
|
||||||
### Auto-start overlay
|
### Auto-start overlay
|
||||||
|
|
||||||
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
||||||
@@ -268,7 +277,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
|||||||
|
|
||||||
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
||||||
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
||||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
|
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
|
||||||
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||||
|
|
||||||
### Shortcuts configuration
|
### Shortcuts configuration
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ make deps
|
|||||||
make build-macos
|
make build-macos
|
||||||
```
|
```
|
||||||
|
|
||||||
The `.dmg` and `.zip` land in `release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
The `.dmg` and `.zip` land in `dist/release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
|
||||||
|
|
||||||
|
::: warning Only run plugins you trust
|
||||||
|
Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
|
||||||
|
:::
|
||||||
|
|
||||||
|
These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
|
||||||
|
|
||||||
|
## Install a plugin
|
||||||
|
|
||||||
|
1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
|
||||||
|
2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Bind its actions to keys with the `__plugin-action:<plugin id>/<action>` command:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"keybindings": [
|
||||||
|
{ "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
|
||||||
|
{ "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
|
||||||
|
|
||||||
|
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
||||||
|
|
||||||
|
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
||||||
|
|
||||||
|
## Example: explain the current line with Codex
|
||||||
|
|
||||||
|
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
|
||||||
|
|
||||||
|
| Action | What it does |
|
||||||
|
| ----------- | --------------------------------------------------------------- |
|
||||||
|
| `explain` | Translation plus a breakdown of the grammar and vocabulary |
|
||||||
|
| `translate` | Natural English translation, with a note only when it's unclear |
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
| ----------------- | --------- | ----------------------------------------------------------------- |
|
||||||
|
| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
|
||||||
|
| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
|
||||||
|
| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
|
||||||
|
| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
|
||||||
|
| `pause` | `true` | Pause playback when the panel opens |
|
||||||
|
| `timeoutMs` | `120000` | Give up after this long |
|
||||||
|
| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
|
||||||
|
|
||||||
|
Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
|
||||||
|
|
||||||
|
## Write a plugin
|
||||||
|
|
||||||
|
A plugin folder needs a `plugin.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "My Plugin", "version": "1.0.0", "main": "main.js" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner waits up to 3 seconds for it, including any promise it returns. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function activate(ctx) {
|
||||||
|
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
|
||||||
|
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
||||||
|
ctx.actions.register('hello', () => {
|
||||||
|
const line = ctx.subtitles.current();
|
||||||
|
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
|
```
|
||||||
|
|
||||||
|
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
|
||||||
|
|
||||||
|
### `ctx` reference
|
||||||
|
|
||||||
|
| Member | What it does |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||||
|
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
|
||||||
|
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||||
|
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||||
|
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||||
|
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||||
|
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||||
|
| `mpv.connected` | Whether mpv is connected |
|
||||||
|
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||||
|
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||||
|
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
|
||||||
|
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
|
||||||
|
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
|
||||||
|
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
|
||||||
|
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||||
|
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
|
||||||
|
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
|
||||||
|
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||||
|
|
||||||
|
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
||||||
|
|
||||||
|
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
||||||
|
|
||||||
|
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||||
|
|
||||||
|
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||||
@@ -218,6 +218,14 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Plugins
|
||||||
|
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||||
|
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||||
|
// Restart SubMiner after changing plugins.
|
||||||
|
// ==========================================
|
||||||
|
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -138,6 +138,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
|
||||||
|
|
||||||
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||||
|
|
||||||
## Automatic mpv bindings
|
## Automatic mpv bindings
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ SubMiner detected that a previously non-empty Yomitan profile suddenly appears e
|
|||||||
|
|
||||||
## "Yomitan extension not found in any search path"
|
## "Yomitan extension not found in any search path"
|
||||||
|
|
||||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `dist/yomitan`.
|
||||||
|
|
||||||
## "MeCab not found on system"
|
## "MeCab not found on system"
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
|
|||||||
|
|
||||||
### Hachidori setup
|
### Hachidori setup
|
||||||
|
|
||||||
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
[Hachidori](https://github.com/bee-san/hachidori) is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||||
|
|
||||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ subminer doctor # check dependencies, config, and the mpv soc
|
|||||||
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
||||||
subminer logs -e # export a log ZIP for bug reports
|
subminer logs -e # export a log ZIP for bug reports
|
||||||
subminer app --setup # reopen first-run setup
|
subminer app --setup # reopen first-run setup
|
||||||
|
subminer app --plugin-action <id>/<name> # run a plugin action
|
||||||
subminer -u # update SubMiner
|
subminer -u # update SubMiner
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -54,10 +54,10 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
4. Bump `package.json` to the release version.
|
4. Bump `package.json` to the release version.
|
||||||
5. Build release metadata before tagging (this calls `claude -p` locally):
|
5. Build release metadata before tagging (this calls `claude -p` locally):
|
||||||
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
|
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed
|
||||||
before tagging. Release CI no longer auto-builds the changelog; it fails
|
before tagging. Release CI no longer auto-builds the changelog; it fails
|
||||||
fast if `changes/*.md` fragments are still present on a tag-based run.
|
fast if `changes/*.md` fragments are still present on a tag-based run.
|
||||||
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
|
6. Review `CHANGELOG.md` and `packaging/release-notes/release-notes.md`. Edit by hand if Claude
|
||||||
missed something — the committed Markdown is what ships.
|
missed something — the committed Markdown is what ships.
|
||||||
7. Run release gate locally:
|
7. Run release gate locally:
|
||||||
`bun run changelog:check --version <version>`
|
`bun run changelog:check --version <version>`
|
||||||
@@ -73,9 +73,9 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
|
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
|
||||||
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
|
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
|
||||||
inputs, or collected license files differ from
|
inputs, or collected license files differ from
|
||||||
`build/bun-source-manifest.json`.
|
`packaging/bun/bun-source-manifest.json`.
|
||||||
When validating auto-update metadata, also run the relevant platform package
|
When validating auto-update metadata, also run the relevant platform package
|
||||||
build and confirm `release/` contains the generated updater metadata
|
build and confirm `dist/release/` contains the generated updater metadata
|
||||||
(`latest*.yml`) and blockmaps (`*.blockmap`).
|
(`latest*.yml`) and blockmaps (`*.blockmap`).
|
||||||
8. If `docs-site/` changed, also run:
|
8. If `docs-site/` changed, also run:
|
||||||
`bun run docs:test`
|
`bun run docs:test`
|
||||||
@@ -103,9 +103,9 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
source archive. A missing source repository or license file fails the
|
source archive. A missing source repository or license file fails the
|
||||||
release instead of publishing only the executable.
|
release instead of publishing only the executable.
|
||||||
When validating packaged updater output, confirm the platform build writes
|
When validating packaged updater output, confirm the platform build writes
|
||||||
`latest*.yml` and `*.blockmap` files under `release/`.
|
`latest*.yml` and `*.blockmap` files under `dist/release/`.
|
||||||
5. Commit the prerelease prep (package.json version bump + the generated
|
5. Commit the prerelease prep (package.json version bump + the generated
|
||||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
`packaging/release-notes/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||||
committed file — so review it before committing. Rerun
|
committed file — so review it before committing. Rerun
|
||||||
`bun run changelog:prerelease-notes --version <version>` for every later
|
`bun run changelog:prerelease-notes --version <version>` for every later
|
||||||
beta/RC, even if no fragments changed: the notes carry a hidden
|
beta/RC, even if no fragments changed: the notes carry a hidden
|
||||||
@@ -128,14 +128,14 @@ Notes:
|
|||||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
- 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. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `packaging/release-notes/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||||
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
|
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
|
||||||
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
|
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
- `changelog:build` generates `CHANGELOG.md` + `packaging/release-notes/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
- `packaging/release-notes/release-notes.md` (and `packaging/release-notes/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||||
- 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. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
- 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.
|
- 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.
|
||||||
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
|||||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||||
|
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
|
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ bun run docs:build
|
|||||||
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||||
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
On headless Linux: `xvfb-run -a bun run test:package dist/release/linux-unpacked/resources`.
|
||||||
Content checks run inside the electron-builder afterPack hook. See the
|
Content checks run inside the electron-builder afterPack hook. See the
|
||||||
[release guide](../RELEASING.md#package-contents-checks) for the
|
[release guide](../RELEASING.md#package-contents-checks) for the
|
||||||
installed-app verification checklist.
|
installed-app verification checklist.
|
||||||
@@ -123,7 +123,8 @@ whenever the behavior does not depend on a specific compositor.
|
|||||||
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||||
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
|
||||||
|
to write files such as plugins into the isolated config dir before launch.
|
||||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|||||||
export type E2eSessionOptions = {
|
export type E2eSessionOptions = {
|
||||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||||
config?: JsonObject;
|
config?: JsonObject;
|
||||||
|
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
|
||||||
|
files?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppTarget = {
|
export type AppTarget = {
|
||||||
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
|||||||
|
|
||||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||||
|
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
|
||||||
|
const filePath = path.resolve(userDataDir, relativePath);
|
||||||
|
const inside = path.relative(userDataDir, filePath);
|
||||||
|
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
|
||||||
|
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
const env: EnvDelta = {
|
const env: EnvDelta = {
|
||||||
set: {
|
set: {
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test, { after } from 'node:test';
|
||||||
|
import type { CdpPage } from './harness/cdp';
|
||||||
|
import { FIXTURE_CUES } from './harness/fixtures';
|
||||||
|
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||||
|
import { waitUntil } from './harness/wait';
|
||||||
|
|
||||||
|
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||||
|
// session boots inside whichever test runs first and every test budgets for it.
|
||||||
|
const TEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
// Opens a panel describing the subtitle context and a child process result, and
|
||||||
|
// logs when the panel's signal aborts so the test can see the close reach main.
|
||||||
|
const PROBE_PLUGIN = `
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
|
||||||
|
exports.activate = (ctx) => {
|
||||||
|
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||||
|
ctx.actions.register('window', () => {
|
||||||
|
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||||
|
});
|
||||||
|
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
|
||||||
|
ctx.actions.register('show', () => {
|
||||||
|
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
||||||
|
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
||||||
|
panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
|
||||||
|
ctx
|
||||||
|
.exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
signal: panel.signal,
|
||||||
|
})
|
||||||
|
.then((result) =>
|
||||||
|
panel.update({
|
||||||
|
markdown: [
|
||||||
|
'## ' + ctx.settings.greeting,
|
||||||
|
'',
|
||||||
|
'- before: ' + before.map((line) => line.text).join('|'),
|
||||||
|
'- current: **' + (current ? current.text : '') + '**',
|
||||||
|
'- after: ' + after.map((line) => line.text).join('|'),
|
||||||
|
'- exec: ' + result.stdout,
|
||||||
|
].join('\\n'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||||
|
});
|
||||||
|
return () => ipcMain.removeHandler('e2e-probe:info');
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROBE_PRELOAD = `
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROBE_WINDOW_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>E2E Probe Window</title></head>
|
||||||
|
<body>
|
||||||
|
<pre id="out">loading</pre>
|
||||||
|
<script>
|
||||||
|
window.probe.info().then((info) => {
|
||||||
|
document.getElementById('out').textContent = JSON.stringify(info);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Started = { session: E2eSession; overlay: CdpPage };
|
||||||
|
let started: Promise<Started> | undefined;
|
||||||
|
|
||||||
|
function useSession(): Promise<Started> {
|
||||||
|
started ??= (async () => {
|
||||||
|
const session = await startE2eSession({
|
||||||
|
config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
|
||||||
|
files: {
|
||||||
|
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
||||||
|
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
||||||
|
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
|
||||||
|
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||||
|
} catch (error) {
|
||||||
|
await session.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
const running = await started?.catch(() => undefined);
|
||||||
|
await running?.session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
type PanelState = {
|
||||||
|
title: string;
|
||||||
|
heading: string;
|
||||||
|
current: string;
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a plugin action opens the plugin panel with subtitle context and child process output',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const [previous, cue, next] = FIXTURE_CUES;
|
||||||
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||||
|
{ description: `overlay to show "${cue.text}"` },
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.runAppCommand(
|
||||||
|
'--session-action',
|
||||||
|
JSON.stringify({
|
||||||
|
actionId: 'runPluginAction',
|
||||||
|
payload: { pluginActionId: 'e2e-probe/show' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = await session.openPage('renderer/index.html?layer=modal');
|
||||||
|
const panel = await modal
|
||||||
|
.waitFor<PanelState | false>(
|
||||||
|
`(() => {
|
||||||
|
const root = document.getElementById('pluginPanelModal');
|
||||||
|
const body = document.getElementById('pluginPanelBody');
|
||||||
|
if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
|
||||||
|
return {
|
||||||
|
title: document.getElementById('pluginPanelTitle').textContent,
|
||||||
|
heading: body.querySelector('h4')?.textContent ?? '',
|
||||||
|
current: body.querySelector('strong')?.textContent ?? '',
|
||||||
|
items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
{ description: 'plugin panel to show the finished probe result' },
|
||||||
|
)
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
const body = await modal.evaluate<string>(
|
||||||
|
`document.getElementById('pluginPanelBody').textContent`,
|
||||||
|
);
|
||||||
|
throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(panel, {
|
||||||
|
title: 'Probe',
|
||||||
|
heading: 'hello',
|
||||||
|
current: cue.text,
|
||||||
|
items: [
|
||||||
|
`before: ${previous.text}`,
|
||||||
|
`current: ${cue.text}`,
|
||||||
|
`after: ${next.text}`,
|
||||||
|
'exec: child-ok',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing hides the modal window (Linux destroys it), often before Chromium
|
||||||
|
// acknowledges the key event, so the send is not awaited. The plugin's log line
|
||||||
|
// proves the close reached main and aborted the handle.
|
||||||
|
void modal
|
||||||
|
.send('Input.dispatchKeyEvent', {
|
||||||
|
type: 'rawKeyDown',
|
||||||
|
key: 'Escape',
|
||||||
|
code: 'Escape',
|
||||||
|
windowsVirtualKeyCode: 27,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
|
||||||
|
{ description: 'closing the panel to abort the plugin handle in main' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
|
||||||
|
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
|
||||||
|
|
||||||
|
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
|
||||||
|
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
|
||||||
|
`(() => {
|
||||||
|
const text = document.getElementById('out')?.textContent;
|
||||||
|
return text && text !== 'loading' ? JSON.parse(text) : false;
|
||||||
|
})()`,
|
||||||
|
{ description: 'plugin window to read plugin info over IPC' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(info.greeting, 'hello');
|
||||||
|
assert.equal(path.basename(info.dataDir), 'e2e-probe');
|
||||||
|
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
|
||||||
|
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
const mediaPath = await session.mpv.command('get_property', 'path');
|
||||||
|
|
||||||
|
// Replacing the playing file ends it with reason "stop".
|
||||||
|
await session.mpv.command('loadfile', String(mediaPath), 'replace');
|
||||||
|
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
|
||||||
|
{ description: 'the plugin to log the end-file reason' },
|
||||||
|
);
|
||||||
|
});
|
||||||
+13
-6
@@ -146,7 +146,8 @@
|
|||||||
"afterPack": "scripts/electron-builder-after-pack.cjs",
|
"afterPack": "scripts/electron-builder-after-pack.cjs",
|
||||||
"icon": "assets/SubMiner-square.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"buildResources": "packaging/electron-builder",
|
||||||
|
"output": "dist/release"
|
||||||
},
|
},
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
@@ -178,8 +179,8 @@
|
|||||||
"category": "public.app-category.video",
|
"category": "public.app-category.video",
|
||||||
"icon": "assets/SubMiner-square.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"entitlements": "build/entitlements.mac.plist",
|
"entitlements": "packaging/electron-builder/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
"entitlementsInherit": "packaging/electron-builder/entitlements.mac.plist",
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "dist/scripts/get-mpv-window-macos",
|
"from": "dist/scripts/get-mpv-window-macos",
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"include": "build/installer.nsh"
|
"include": "packaging/electron-builder/installer.nsh"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
@@ -225,17 +226,18 @@
|
|||||||
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
|
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
|
||||||
"!dist/launcher{,/**/*}",
|
"!dist/launcher{,/**/*}",
|
||||||
"!dist/scripts{,/**/*}",
|
"!dist/scripts{,/**/*}",
|
||||||
|
"!dist/{yomitan,hachidori,release}{,/**/*}",
|
||||||
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
|
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
|
||||||
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
|
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
|
||||||
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "build/yomitan",
|
"from": "dist/yomitan",
|
||||||
"to": "yomitan"
|
"to": "yomitan"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "build/hachidori",
|
"from": "dist/hachidori",
|
||||||
"to": "hachidori"
|
"to": "hachidori"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -277,6 +279,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
|
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
|
||||||
"sourceManifest": "build/bun-source-manifest.json",
|
"sourceManifest": "packaging/bun/bun-source-manifest.json",
|
||||||
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
|
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,7 @@ The SubMiner GitHub release containing this application also contains `bun-v1.3.
|
|||||||
- every external source repository registered by that Bun revision's CMake build, at its exact commit
|
- every external source repository registered by that Bun revision's CMake build, at its exact commit
|
||||||
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
|
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
|
||||||
|
|
||||||
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `build/bun-source-manifest.json` in SubMiner's source repository.
|
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `packaging/bun/bun-source-manifest.json` in SubMiner's source repository.
|
||||||
|
|
||||||
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
|
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Bun 1.3.5 third-party notices
|
# Bun 1.3.5 third-party notices
|
||||||
|
|
||||||
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in build/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in packaging/bun/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
||||||
|
|
||||||
## BoringSSL
|
## BoringSSL
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -203,7 +203,7 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
|
|||||||
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
|
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||||
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
@@ -240,7 +240,7 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
|
|||||||
assert.equal(project.exists('changes/001.md'), false);
|
assert.equal(project.exists('changes/001.md'), false);
|
||||||
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
|
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
|
||||||
assert.match(
|
assert.match(
|
||||||
project.read('release/release-notes.md'),
|
project.read('packaging/release-notes/release-notes.md'),
|
||||||
/## Highlights\n### Added\n- Existing release bullet\./,
|
/## Highlights\n### Added\n- Existing release bullet\./,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -268,7 +268,7 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
|
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
|
||||||
assert.equal(result.releaseNotesPath, project.at('release/release-notes.md'));
|
assert.equal(result.releaseNotesPath, project.at('packaging/release-notes/release-notes.md'));
|
||||||
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
|
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
|
||||||
|
|
||||||
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
|
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
|
||||||
@@ -471,7 +471,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
|||||||
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
|
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(outputPath, project.at('release/prerelease-notes.md'));
|
assert.equal(outputPath, project.at('packaging/release-notes/prerelease-notes.md'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
project.read('CHANGELOG.md'),
|
project.read('CHANGELOG.md'),
|
||||||
existingChangelog,
|
existingChangelog,
|
||||||
@@ -527,7 +527,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
'prerelease-reuse-existing-notes',
|
'prerelease-reuse-existing-notes',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.11.3-beta.2'),
|
'package.json': packageJson('0.11.3-beta.2'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
|
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
|
||||||
},
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
@@ -574,7 +574,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
|
|||||||
'prerelease-ignore-unmarked-old-notes',
|
'prerelease-ignore-unmarked-old-notes',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.17.0-beta.1'),
|
'package.json': packageJson('0.17.0-beta.1'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment(
|
'changes/001.md': fragment(
|
||||||
'changed',
|
'changed',
|
||||||
'overlay',
|
'overlay',
|
||||||
@@ -785,7 +785,7 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
|||||||
'release-notes mode drops internal fragments',
|
'release-notes mode drops internal fragments',
|
||||||
);
|
);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -841,7 +841,7 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
|||||||
project.at('changes/002.md'),
|
project.at('changes/002.md'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||||
@@ -904,7 +904,10 @@ test('writeChangelogArtifacts skips contributor attribution in GitHub Actions wi
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(warnings, []);
|
assert.deepEqual(warnings, []);
|
||||||
assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/);
|
assert.doesNotMatch(
|
||||||
|
project.read('packaging/release-notes/release-notes.md'),
|
||||||
|
/## What's Changed/,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = originalWarn;
|
console.warn = originalWarn;
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
@@ -990,7 +993,10 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
|
|||||||
|
|
||||||
await withProject(
|
await withProject(
|
||||||
'release-notes-preserve-attribution',
|
'release-notes-preserve-attribution',
|
||||||
{ 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes },
|
{
|
||||||
|
'CHANGELOG.md': existingChangelog,
|
||||||
|
'packaging/release-notes/release-notes.md': committedReleaseNotes,
|
||||||
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
const outputPath = writeReleaseNotesForVersion({
|
const outputPath = writeReleaseNotesForVersion({
|
||||||
cwd: project.projectRoot,
|
cwd: project.projectRoot,
|
||||||
@@ -1055,7 +1061,7 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
|
|||||||
'no Claude calls should fire when the section already exists',
|
'no Claude calls should fire when the section already exists',
|
||||||
);
|
);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
|
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
|
||||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||||
@@ -1237,7 +1243,7 @@ test('writePrereleaseNotesForVersion strips the stale delta section from the reu
|
|||||||
'prerelease-reuse-strips-delta',
|
'prerelease-reuse-strips-delta',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.12.0-beta.3'),
|
'package.json': packageJson('0.12.0-beta.3'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
|
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
|
||||||
},
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
@@ -1271,7 +1277,7 @@ test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale
|
|||||||
(project) => {
|
(project) => {
|
||||||
const verify = (version = '0.12.0-beta.2') =>
|
const verify = (version = '0.12.0-beta.2') =>
|
||||||
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
|
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
|
||||||
const notesPath = 'release/prerelease-notes.md';
|
const notesPath = 'packaging/release-notes/prerelease-notes.md';
|
||||||
|
|
||||||
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
|
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ type PullRequestChangelogOptions = {
|
|||||||
changedLabels?: string[];
|
changedLabels?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
const RELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'release-notes.md');
|
||||||
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
const PRERELEASE_NOTES_PATH = path.join('packaging', 'release-notes', '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 SKIP_CHANGELOG_LABEL = 'skip-changelog';
|
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
|
||||||
@@ -1144,7 +1144,7 @@ function isFragmentPath(candidate: string): boolean {
|
|||||||
function isIgnoredPullRequestPath(candidate: string): boolean {
|
function isIgnoredPullRequestPath(candidate: string): boolean {
|
||||||
return (
|
return (
|
||||||
candidate === 'CHANGELOG.md' ||
|
candidate === 'CHANGELOG.md' ||
|
||||||
candidate === 'release/release-notes.md' ||
|
candidate === 'packaging/release-notes/release-notes.md' ||
|
||||||
candidate === 'AGENTS.md' ||
|
candidate === 'AGENTS.md' ||
|
||||||
candidate === 'README.md' ||
|
candidate === 'README.md' ||
|
||||||
candidate.startsWith('changes/') ||
|
candidate.startsWith('changes/') ||
|
||||||
@@ -1405,7 +1405,7 @@ export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): v
|
|||||||
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
|
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
|
||||||
if (markerVersion !== version) {
|
if (markerVersion !== version) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
`packaging/release-notes/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
||||||
const output = path.join(repoRoot, 'build', 'hachidori');
|
const output = path.join(repoRoot, 'dist', 'hachidori');
|
||||||
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
||||||
|
|
||||||
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('Hachidori staging configures Electron without changing the fork source', a
|
|||||||
files.map((file) => readFileSync(source(file), 'utf8')),
|
files.map((file) => readFileSync(source(file), 'utf8')),
|
||||||
before,
|
before,
|
||||||
);
|
);
|
||||||
const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href);
|
const staged = await import(new URL('../dist/hachidori/overlay-mode.js', import.meta.url).href);
|
||||||
assert.equal(staged.OVERLAY_MODE, true);
|
assert.equal(staged.OVERLAY_MODE, true);
|
||||||
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
||||||
const original = await import(source('overlay-mode.js').href);
|
const original = await import(source('overlay-mode.js').href);
|
||||||
@@ -28,13 +28,13 @@ test('Hachidori staging configures Electron without changing the fork source', a
|
|||||||
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
|
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
|
||||||
// highlight stays on for a first install.
|
// highlight stays on for a first install.
|
||||||
const stagedSetup = await import(
|
const stagedSetup = await import(
|
||||||
new URL('../build/hachidori/setup-state.js', import.meta.url).href
|
new URL('../dist/hachidori/setup-state.js', import.meta.url).href
|
||||||
);
|
);
|
||||||
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
|
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
|
||||||
const originalSetup = await import(source('setup-state.js').href);
|
const originalSetup = await import(source('setup-state.js').href);
|
||||||
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
|
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
|
readFileSync(new URL('../dist/hachidori/manifest.json', import.meta.url), 'utf8'),
|
||||||
);
|
);
|
||||||
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
||||||
assert.deepEqual(manifest, {
|
assert.deepEqual(manifest, {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const repoRoot = path.resolve(dirname, '..');
|
|||||||
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
||||||
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
||||||
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
||||||
const buildOutputDir = path.join(repoRoot, 'build', 'yomitan');
|
const buildOutputDir = path.join(repoRoot, 'dist', 'yomitan');
|
||||||
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
||||||
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
||||||
const bunCommand = process.versions.bun ? process.execPath : 'bun';
|
const bunCommand = process.versions.bun ? process.execPath : 'bun';
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ app
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
const targetSession = session.fromPartition('persist:hachidori-check');
|
const targetSession = session.fromPartition('persist:hachidori-check');
|
||||||
const extension = await targetSession.extensions.loadExtension(
|
const extension = await targetSession.extensions.loadExtension(
|
||||||
path.join(root, 'build/hachidori'),
|
path.join(root, 'dist/hachidori'),
|
||||||
{ allowFileAccess: true },
|
{ allowFileAccess: true },
|
||||||
);
|
);
|
||||||
// Keep a host window alive while the dictionary importer opens/closes its
|
// Keep a host window alive while the dictionary importer opens/closes its
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
|||||||
'/tmp/subminer-filter-output',
|
'/tmp/subminer-filter-output',
|
||||||
{
|
{
|
||||||
defaultSrc: root,
|
defaultSrc: root,
|
||||||
globalOutDir: path.join(root, 'release'),
|
globalOutDir: path.join(root, 'dist', 'release'),
|
||||||
customBuildOptions: { files: config.build[platform].files },
|
customBuildOptions: { files: config.build[platform].files },
|
||||||
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
||||||
},
|
},
|
||||||
@@ -51,6 +51,9 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
|||||||
'stats/dist/nested/fixtures/data.json',
|
'stats/dist/nested/fixtures/data.json',
|
||||||
'vendor/texthooker-ui/docs/nested/component.tsx',
|
'vendor/texthooker-ui/docs/nested/component.tsx',
|
||||||
'dist/launcher/subminer',
|
'dist/launcher/subminer',
|
||||||
|
'dist/yomitan/manifest.json',
|
||||||
|
'dist/hachidori/manifest.json',
|
||||||
|
'dist/release/SubMiner-1.0.0.AppImage',
|
||||||
'dist/settings/fonts/MPLUS1[wght].ttf',
|
'dist/settings/fonts/MPLUS1[wght].ttf',
|
||||||
'vendor/subminer-yomitan/ext/manifest.json',
|
'vendor/subminer-yomitan/ext/manifest.json',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ import { Readable } from 'node:stream';
|
|||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
|
||||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json');
|
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'packaging',
|
||||||
|
'bun',
|
||||||
|
'bun-source-manifest.json',
|
||||||
|
);
|
||||||
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
'build',
|
'packaging',
|
||||||
|
'bun',
|
||||||
'bun-runtime-manifest.json',
|
'bun-runtime-manifest.json',
|
||||||
);
|
);
|
||||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release');
|
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'dist', 'release');
|
||||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
||||||
|
|
||||||
function isRecord(value) {
|
function isRecord(value) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_MANIFEST_PATH,
|
||||||
|
DEFAULT_PACKAGE_JSON_PATH,
|
||||||
|
DEFAULT_RUNTIME_MANIFEST_PATH,
|
||||||
downloadArchive,
|
downloadArchive,
|
||||||
parseRegisteredRepositories,
|
parseRegisteredRepositories,
|
||||||
parseSourceManifest,
|
parseSourceManifest,
|
||||||
@@ -67,10 +70,26 @@ describe('Bun corresponding-source manifest', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('default manifest paths resolve to aligned checked-in manifests', async () => {
|
||||||
|
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
|
||||||
|
fs.readFile(DEFAULT_MANIFEST_PATH, 'utf8'),
|
||||||
|
fs.readFile(DEFAULT_RUNTIME_MANIFEST_PATH, 'utf8'),
|
||||||
|
fs.readFile(DEFAULT_PACKAGE_JSON_PATH, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateRuntimeAlignment(
|
||||||
|
parseSourceManifest(JSON.parse(manifestText)),
|
||||||
|
JSON.parse(packageJsonText),
|
||||||
|
JSON.parse(runtimeManifestText),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects stale runtime or package-manager pins', async () => {
|
test('rejects stale runtime or package-manager pins', async () => {
|
||||||
const manifest = parseSourceManifest(
|
const manifest = parseSourceManifest(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const runtimeManifest = {
|
const runtimeManifest = {
|
||||||
@@ -100,7 +119,7 @@ describe('Bun corresponding-source manifest', () => {
|
|||||||
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
||||||
const manifest = parseSourceManifest(
|
const manifest = parseSourceManifest(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const registrations = manifest.sources
|
const registrations = manifest.sources
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ cat <<'EOF'
|
|||||||
patch-yomitan.sh is retired.
|
patch-yomitan.sh is retired.
|
||||||
|
|
||||||
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
|
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
|
||||||
Chromium extension artifact into build/yomitan.
|
Chromium extension artifact into dist/yomitan.
|
||||||
|
|
||||||
Use:
|
Use:
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ paths=(
|
|||||||
"tsconfig.json"
|
"tsconfig.json"
|
||||||
"tsconfig.renderer.json"
|
"tsconfig.renderer.json"
|
||||||
"tsconfig.typecheck.json"
|
"tsconfig.typecheck.json"
|
||||||
".prettierrc.json"
|
|
||||||
".github"
|
".github"
|
||||||
"build"
|
"packaging/bun/bun-runtime-manifest.json"
|
||||||
|
"packaging/bun/bun-source-manifest.json"
|
||||||
"e2e"
|
"e2e"
|
||||||
"launcher"
|
"launcher"
|
||||||
"scripts"
|
"scripts"
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
|
||||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json');
|
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'packaging',
|
||||||
|
'bun',
|
||||||
|
'bun-runtime-manifest.json',
|
||||||
|
);
|
||||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
||||||
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses');
|
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'packaging', 'bun', 'licenses');
|
||||||
export const STAGED_METADATA_FILE = 'metadata.json';
|
export const STAGED_METADATA_FILE = 'metadata.json';
|
||||||
export const REQUIRED_LICENSE_FILES = [
|
export const REQUIRED_LICENSE_FILES = [
|
||||||
'Bun-LICENSE.md',
|
'Bun-LICENSE.md',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function runtimeConfig() {
|
|||||||
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
||||||
},
|
},
|
||||||
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
||||||
sourceManifest: 'build/bun-source-manifest.json',
|
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ test('manifest version must match the exact packageManager Bun pin', () => {
|
|||||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||||
artifacts: {},
|
artifacts: {},
|
||||||
licenseInventoryStatus: 'partial',
|
licenseInventoryStatus: 'partial',
|
||||||
sourceManifest: 'build/bun-source-manifest.json',
|
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||||
},
|
},
|
||||||
'1.3.5',
|
'1.3.5',
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(sessionAction), true);
|
assert.equal(shouldStartApp(sessionAction), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
||||||
|
|
||||||
|
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
|
||||||
|
assert.equal(pluginAction.pluginAction, 'anime/open');
|
||||||
|
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
|
||||||
|
assert.equal(hasExplicitCommand(pluginAction), true);
|
||||||
|
assert.equal(shouldStartApp(pluginAction), true);
|
||||||
|
// The plugin decides what it needs; a window-only action must not force the overlay up.
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
|
||||||
|
|
||||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface CliArgs {
|
|||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
sessionAction?: SessionActionDispatchRequest;
|
sessionAction?: SessionActionDispatchRequest;
|
||||||
|
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
||||||
|
pluginAction?: string;
|
||||||
copySubtitleCount?: number;
|
copySubtitleCount?: number;
|
||||||
mineSentenceCount?: number;
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--session-action') {
|
} else if (arg === '--session-action') {
|
||||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||||
if (parsed) args.sessionAction = parsed;
|
if (parsed) args.sessionAction = parsed;
|
||||||
|
} else if (arg.startsWith('--plugin-action=')) {
|
||||||
|
const value = arg.slice('--plugin-action='.length).trim();
|
||||||
|
if (value) args.pluginAction = value;
|
||||||
|
} else if (arg === '--plugin-action') {
|
||||||
|
const value = readValue(argv[i + 1])?.trim();
|
||||||
|
if (value) args.pluginAction = value;
|
||||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||||
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ ${B}Mining${R}
|
|||||||
--open-session-help Open session help modal
|
--open-session-help Open session help modal
|
||||||
--open-controller-select Open controller select modal
|
--open-controller-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
|
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
--anilist-setup Open AniList authentication flow
|
--anilist-setup Open AniList authentication flow
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
|
plugins,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const {
|
const {
|
||||||
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
|
plugins,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
subtitleSidebar,
|
subtitleSidebar,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'startupWarmups'
|
| 'startupWarmups'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
|
| 'plugins'
|
||||||
| 'auto_start_overlay'
|
| 'auto_start_overlay'
|
||||||
> = {
|
> = {
|
||||||
dictionaryBackend: 'yomitan',
|
dictionaryBackend: 'yomitan',
|
||||||
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
notifications: {
|
notifications: {
|
||||||
overlayPosition: 'top-right',
|
overlayPosition: 'top-right',
|
||||||
},
|
},
|
||||||
|
plugins: {},
|
||||||
auto_start_overlay: true,
|
auto_start_overlay: true,
|
||||||
};
|
};
|
||||||
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||||
description: 'Position for in-overlay notification cards.',
|
description: 'Position for in-overlay notification cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'plugins',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.plugins,
|
||||||
|
description:
|
||||||
|
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.multiCopyTimeoutMs',
|
path: 'shortcuts.multiCopyTimeoutMs',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
|
|||||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
|
PLUGIN_ACTION_PREFIX: '__plugin-action:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||||
key: 'notifications',
|
key: 'notifications',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugins',
|
||||||
|
description: [
|
||||||
|
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
|
||||||
|
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
|
||||||
|
],
|
||||||
|
notes: ['Restart SubMiner after changing plugins.'],
|
||||||
|
key: 'plugins',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Keyboard Shortcuts',
|
title: 'Keyboard Shortcuts',
|
||||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
|
|||||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||||
import { applyIntegrationConfig } from './resolve/integrations';
|
import { applyIntegrationConfig } from './resolve/integrations';
|
||||||
|
import { applyPluginsConfig } from './resolve/plugins';
|
||||||
import { applyStatsConfig } from './resolve/stats';
|
import { applyStatsConfig } from './resolve/stats';
|
||||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||||
import { applyTopLevelConfig } from './resolve/top-level';
|
import { applyTopLevelConfig } from './resolve/top-level';
|
||||||
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
|
|||||||
applyIntegrationConfig,
|
applyIntegrationConfig,
|
||||||
applyImmersionTrackingConfig,
|
applyImmersionTrackingConfig,
|
||||||
applyStatsConfig,
|
applyStatsConfig,
|
||||||
|
applyPluginsConfig,
|
||||||
applyAnkiConnectResolution,
|
applyAnkiConnectResolution,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveConfig } from '../resolve';
|
||||||
|
|
||||||
|
test('resolveConfig keeps plugin entries and their settings', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
plugins: {
|
||||||
|
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||||
|
dormant: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolved.plugins, {
|
||||||
|
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||||
|
dormant: { enabled: false, settings: {} },
|
||||||
|
});
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
plugins: {
|
||||||
|
flag: { enabled: 'yes' as never, settings: [] as never },
|
||||||
|
scalar: true as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ResolveContext } from './context';
|
||||||
|
import { isObject } from './shared';
|
||||||
|
|
||||||
|
/** Keeps well-formed `plugins.<id>` entries; ids are validated again when the plugin loads. */
|
||||||
|
export function applyPluginsConfig(context: ResolveContext): void {
|
||||||
|
const { src, resolved, warn } = context;
|
||||||
|
if (src.plugins === undefined) return;
|
||||||
|
if (!isObject(src.plugins)) {
|
||||||
|
warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, entry] of Object.entries(src.plugins)) {
|
||||||
|
const path = `plugins.${id}`;
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
|
||||||
|
warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
|
||||||
|
}
|
||||||
|
if (entry.settings !== undefined && !isObject(entry.settings)) {
|
||||||
|
warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
|
||||||
|
}
|
||||||
|
resolved.plugins[id] = {
|
||||||
|
enabled: entry.enabled === true,
|
||||||
|
settings: isObject(entry.settings) ? { ...entry.settings } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
|
|||||||
'keybindings',
|
'keybindings',
|
||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
|
'plugins',
|
||||||
'ankiConnect.knownWords.decks',
|
'ankiConnect.knownWords.decks',
|
||||||
'subtitleStyle.css',
|
'subtitleStyle.css',
|
||||||
'subtitleStyle.secondary.css',
|
'subtitleStyle.secondary.css',
|
||||||
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('notifications.')) {
|
if (path.startsWith('notifications.')) {
|
||||||
return { category: 'behavior', section: 'Notifications' };
|
return { category: 'behavior', section: 'Notifications' };
|
||||||
}
|
}
|
||||||
|
if (path === 'plugins') {
|
||||||
|
return { category: 'advanced', section: 'Plugins' };
|
||||||
|
}
|
||||||
if (path === 'mpv.aniskipButtonKey') {
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
|||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
createMpvClient: () => calls.push('createMpvClient'),
|
||||||
|
startPlugins: () => calls.push('startPlugins'),
|
||||||
reloadConfig: () => calls.push('reloadConfig'),
|
reloadConfig: () => calls.push('reloadConfig'),
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
websocket: { enabled: 'auto' },
|
websocket: { enabled: 'auto' },
|
||||||
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
assert.equal(calls.includes('resolveKeybindings'), false);
|
||||||
assert.equal(calls.includes('createMpvClient'), false);
|
assert.equal(calls.includes('createMpvClient'), false);
|
||||||
|
assert.equal(calls.includes('startPlugins'), false);
|
||||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||||
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
|
|||||||
|
|
||||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||||
|
assert.ok(calls.includes('setLogLevel:info:config'));
|
||||||
|
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
|
||||||
|
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
|
||||||
|
let request: unknown = null;
|
||||||
|
const { deps } = createDeps({
|
||||||
|
dispatchSessionAction: async (nextRequest) => {
|
||||||
|
request = nextRequest;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
actionId: 'runPluginAction',
|
||||||
|
payload: { pluginActionId: 'anime/open' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand dispatches mark-watched session action', async () => {
|
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||||
let request: unknown = null;
|
let request: unknown = null;
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -422,6 +422,12 @@ export function handleCliCommand(
|
|||||||
`sessionAction:${args.sessionAction.actionId}`,
|
`sessionAction:${args.sessionAction.actionId}`,
|
||||||
'Session action failed',
|
'Session action failed',
|
||||||
);
|
);
|
||||||
|
} else if (args.pluginAction) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
|
||||||
|
`pluginAction:${args.pluginAction}`,
|
||||||
|
'Plugin action failed',
|
||||||
|
);
|
||||||
} else if (args.toggle || args.toggleVisibleOverlay) {
|
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ test('Hachidori resolves development and packaged artifacts without falling back
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
resolveHachidoriExtensionPath({
|
resolveHachidoriExtensionPath({
|
||||||
...options,
|
...options,
|
||||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
exists: (p) => p === '/app/dist/hachidori/manifest.json',
|
||||||
}),
|
}),
|
||||||
'/app/build/hachidori',
|
'/app/dist/hachidori',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveHachidoriExtensionPath({
|
resolveHachidoriExtensionPath({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function resolveHachidoriExtensionPath(options: {
|
|||||||
exists?: (candidate: string) => boolean;
|
exists?: (candidate: string) => boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
path.resolve(options.moduleDir, '../../../dist/hachidori'),
|
||||||
path.join(options.resourcesPath, 'hachidori'),
|
path.join(options.resourcesPath, 'hachidori'),
|
||||||
];
|
];
|
||||||
const found = candidates.find((candidate) =>
|
const found = candidates.find((candidate) =>
|
||||||
|
|||||||
@@ -575,8 +575,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
const openedModals: unknown[] = [];
|
const openedModals: unknown[] = [];
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
{
|
{
|
||||||
onOverlayModalClosed: (modal) => {
|
onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
|
||||||
closedModals.push(modal);
|
closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
|
||||||
},
|
},
|
||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
openedModals.push(modal);
|
openedModals.push(modal);
|
||||||
@@ -637,7 +637,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
||||||
assert.deepEqual(closedModals, ['subsync', 'kiku']);
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4);
|
||||||
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad');
|
||||||
|
assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']);
|
||||||
|
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
|
|||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
instanceId?: number,
|
||||||
) => void;
|
) => void;
|
||||||
onOverlayModalOpened?: (
|
onOverlayModalOpened?: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
@@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
instanceId?: number,
|
||||||
) => void;
|
) => void;
|
||||||
onOverlayModalOpened?: (
|
onOverlayModalOpened?: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
@@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
|
ipc.on(
|
||||||
|
IPC_CHANNELS.command.overlayModalClosed,
|
||||||
|
(event: unknown, modal: unknown, instanceId: unknown) => {
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
const senderWindow =
|
const senderWindow =
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
deps.onOverlayModalClosed(parsedModal, senderWindow);
|
deps.onOverlayModalClosed(
|
||||||
});
|
parsedModal,
|
||||||
|
senderWindow,
|
||||||
|
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
|
|||||||
@@ -445,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
|
|||||||
|
|
||||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
|
||||||
|
|
||||||
|
assert.deepEqual(received, [
|
||||||
|
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
|
||||||
|
{ reason: 'unknown', playlistEntryId: null, error: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -9,8 +9,30 @@ export type MpvMessage = {
|
|||||||
args?: unknown;
|
args?: unknown;
|
||||||
request_id?: number;
|
request_id?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
reason?: unknown;
|
||||||
|
playlist_entry_id?: unknown;
|
||||||
|
file_error?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
||||||
|
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
||||||
|
|
||||||
|
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
||||||
|
export interface MpvEndFileEvent {
|
||||||
|
reason: MpvEndFileReason;
|
||||||
|
playlistEntryId: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
||||||
|
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
||||||
|
return {
|
||||||
|
reason,
|
||||||
|
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
|
||||||
|
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||||
export const MPV_REQUEST_ID_PATH = 102;
|
export const MPV_REQUEST_ID_PATH = 102;
|
||||||
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||||
@@ -100,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
shouldQuitOnMpvShutdown: () => boolean;
|
shouldQuitOnMpvShutdown: () => boolean;
|
||||||
requestAppQuit: () => void;
|
requestAppQuit: () => void;
|
||||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||||
|
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleTrackCandidate = {
|
type SubtitleTrackCandidate = {
|
||||||
@@ -419,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
if (args.length > 0) {
|
if (args.length > 0) {
|
||||||
deps.emitClientMessage?.({ args });
|
deps.emitClientMessage?.({ args });
|
||||||
}
|
}
|
||||||
|
} else if (msg.event === 'end-file') {
|
||||||
|
deps.emitEndFile?.(parseEndFileEvent(msg));
|
||||||
} else if (msg.event === 'shutdown') {
|
} else if (msg.event === 'shutdown') {
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
if (deps.shouldQuitOnMpvShutdown()) {
|
if (deps.shouldQuitOnMpvShutdown()) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
dispatchMpvProtocolMessage,
|
dispatchMpvProtocolMessage,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
|
type MpvEndFileEvent,
|
||||||
MpvMessage,
|
MpvMessage,
|
||||||
MpvProtocolHandleMessageDeps,
|
MpvProtocolHandleMessageDeps,
|
||||||
splitMpvMessagesFromBuffer,
|
splitMpvMessagesFromBuffer,
|
||||||
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
|
|||||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||||
'secondary-subtitle-visibility': { visible: boolean };
|
'secondary-subtitle-visibility': { visible: boolean };
|
||||||
'client-message': { args: string[] };
|
'client-message': { args: string[] };
|
||||||
|
'end-file': MpvEndFileEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||||
@@ -519,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
emitClientMessage: (payload) => {
|
emitClientMessage: (payload) => {
|
||||||
this.emit('client-message', payload);
|
this.emit('client-message', payload);
|
||||||
},
|
},
|
||||||
|
emitEndFile: (payload) => {
|
||||||
|
this.emit('end-file', payload);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
|
||||||
|
|
||||||
|
// The test runtime (bun or node) doubles as a portable child process.
|
||||||
|
const runtime = process.execPath;
|
||||||
|
|
||||||
|
test('pipes stdin to the child and collects stdout', async () => {
|
||||||
|
const result = await execPluginCommand(
|
||||||
|
runtime,
|
||||||
|
['-e', 'process.stdin.on("data", (d) => process.stdout.write(String(d).toUpperCase()))'],
|
||||||
|
{ stdin: '字幕 line' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.equal(result.stdout, '字幕 LINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves with a non-zero exit code and stderr', async () => {
|
||||||
|
const result = await execPluginCommand(runtime, [
|
||||||
|
'-e',
|
||||||
|
'process.stderr.write("bad things"); process.exit(3)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 3);
|
||||||
|
assert.equal(result.stderr, 'bad things');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops the AppImage library path from the child environment', async () => {
|
||||||
|
const previous = process.env.LD_LIBRARY_PATH;
|
||||||
|
process.env.LD_LIBRARY_PATH = '/tmp/.mount_SubMinXXXXXX/usr/lib';
|
||||||
|
try {
|
||||||
|
const result = await execPluginCommand(
|
||||||
|
runtime,
|
||||||
|
['-e', 'process.stdout.write(String(process.env.LD_LIBRARY_PATH) + "|" + process.env.EXTRA)'],
|
||||||
|
{ env: { EXTRA: 'kept' } },
|
||||||
|
);
|
||||||
|
assert.equal(result.stdout, 'undefined|kept');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.LD_LIBRARY_PATH;
|
||||||
|
else process.env.LD_LIBRARY_PATH = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aborting kills the child and rejects with AbortError', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = execPluginCommand(runtime, ['-e', 'setTimeout(() => {}, 60_000)'], {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
setTimeout(() => controller.abort(), 50);
|
||||||
|
|
||||||
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force-kills a child that ignores SIGTERM after the grace period', async (t) => {
|
||||||
|
if (process.platform === 'win32') return t.skip('POSIX signals only');
|
||||||
|
const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = execPluginCommand(
|
||||||
|
runtime,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`,
|
||||||
|
],
|
||||||
|
{ signal: controller.signal },
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
let pid = 0;
|
||||||
|
for (let i = 0; i < 100 && !pid; i += 1) {
|
||||||
|
pid = Number(await readFile(pidFile, 'utf8').catch(() => '0'));
|
||||||
|
if (!pid) await sleep(20);
|
||||||
|
}
|
||||||
|
assert.ok(pid > 0, 'child never reported its pid');
|
||||||
|
try {
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
let alive = true;
|
||||||
|
for (let i = 0; i < 100 && alive; i += 1) {
|
||||||
|
await sleep(20);
|
||||||
|
alive = isRunning(pid);
|
||||||
|
}
|
||||||
|
assert.equal(alive, false, 'child survived SIGTERM');
|
||||||
|
} finally {
|
||||||
|
if (isRunning(pid)) process.kill(pid, 'SIGKILL');
|
||||||
|
await rm(path.dirname(pidFile), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects when the binary does not exist', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
execPluginCommand('subminer-definitely-missing-binary', []),
|
||||||
|
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-'));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(dir, 'codex.cmd'), '');
|
||||||
|
await writeFile(path.join(dir, 'native.exe'), '');
|
||||||
|
const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' };
|
||||||
|
|
||||||
|
const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32');
|
||||||
|
assert.equal(shim.viaShell, true);
|
||||||
|
assert.equal(shim.file, 'cmd.exe');
|
||||||
|
assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']);
|
||||||
|
assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd')));
|
||||||
|
|
||||||
|
assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), {
|
||||||
|
file: path.join(dir, 'native.exe'),
|
||||||
|
args: ['-v'],
|
||||||
|
viaShell: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), {
|
||||||
|
file: 'codex',
|
||||||
|
args: ['-'],
|
||||||
|
viaShell: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { type ChildProcess, spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
||||||
|
import {
|
||||||
|
buildWindowsShimInvocation,
|
||||||
|
isWindowsCommandShim,
|
||||||
|
resolveWindowsCommand,
|
||||||
|
} from '../../utils/windows-command-shim';
|
||||||
|
import type { PluginExecOptions, PluginExecResult } from './types';
|
||||||
|
|
||||||
|
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
||||||
|
const DEFAULT_KILL_GRACE_MS = 3_000;
|
||||||
|
|
||||||
|
function appendCapped(current: string, chunk: string): string {
|
||||||
|
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
|
||||||
|
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpawnTarget {
|
||||||
|
file: string;
|
||||||
|
args: string[];
|
||||||
|
viaShell: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
|
||||||
|
* binaries, and every command on Linux and macOS, are spawned directly.
|
||||||
|
*/
|
||||||
|
export function resolvePluginSpawnTarget(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): SpawnTarget {
|
||||||
|
if (platform !== 'win32') return { file: command, args, viaShell: false };
|
||||||
|
const resolved = resolveWindowsCommand(command, env);
|
||||||
|
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
|
||||||
|
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
|
||||||
|
function killWindowsProcessTree(child: ChildProcess): void {
|
||||||
|
if (child.pid === undefined) return;
|
||||||
|
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
}).on('error', () => child.kill());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
|
||||||
|
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
|
||||||
|
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
|
||||||
|
* process tree is ended with taskkill instead).
|
||||||
|
*/
|
||||||
|
export function execPluginCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: PluginExecOptions = {},
|
||||||
|
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
||||||
|
): Promise<PluginExecResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
reject(new DOMException('Command aborted.', 'AbortError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = { ...buildSystemBinaryEnv(), ...options.env };
|
||||||
|
const target = resolvePluginSpawnTarget(command, args, env);
|
||||||
|
const child = spawn(target.file, target.args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: target.viaShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const settle = (finish: () => void): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
options.signal?.removeEventListener('abort', onAbort);
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
||||||
|
const killWith = (error: Error): void => {
|
||||||
|
settle(() => reject(error));
|
||||||
|
if (target.viaShell) {
|
||||||
|
killWindowsProcessTree(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const forceKill = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||||
|
}, killGraceMs);
|
||||||
|
forceKill.unref();
|
||||||
|
child.once('exit', () => clearTimeout(forceKill));
|
||||||
|
};
|
||||||
|
|
||||||
|
function onAbort(): void {
|
||||||
|
killWith(new DOMException('Command aborted.', 'AbortError'));
|
||||||
|
}
|
||||||
|
|
||||||
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => killWith(new Error(`${command} timed out after ${options.timeoutMs} ms.`)),
|
||||||
|
options.timeoutMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', (chunk: string) => {
|
||||||
|
stdout = appendCapped(stdout, chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk: string) => {
|
||||||
|
stderr = appendCapped(stderr, chunk);
|
||||||
|
});
|
||||||
|
// A child that exits without reading stdin raises EPIPE here; the exit code tells the story.
|
||||||
|
child.stdin.on('error', () => {});
|
||||||
|
|
||||||
|
child.on('error', (error) => settle(() => reject(error)));
|
||||||
|
child.on('close', (exitCode) => settle(() => resolve({ exitCode, stdout, stderr })));
|
||||||
|
|
||||||
|
child.stdin.end(options.stdin ?? '');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { PluginManifest } from './types';
|
||||||
|
|
||||||
|
const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||||
|
|
||||||
|
/** Plugin ids double as directory names and action prefixes, and action names follow the same rule. */
|
||||||
|
export function isValidPluginName(id: string): boolean {
|
||||||
|
return PLUGIN_NAME_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates parsed `plugin.json` content. Returns an error message instead of throwing. */
|
||||||
|
export function parsePluginManifest(
|
||||||
|
raw: unknown,
|
||||||
|
fallbackName: string,
|
||||||
|
): { manifest: PluginManifest } | { error: string } {
|
||||||
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||||
|
return { error: 'plugin.json must contain a JSON object.' };
|
||||||
|
}
|
||||||
|
const value = raw as Record<string, unknown>;
|
||||||
|
if (typeof value.main !== 'string' || value.main.trim().length === 0) {
|
||||||
|
return { error: 'plugin.json needs a "main" entry file.' };
|
||||||
|
}
|
||||||
|
if (value.name !== undefined && typeof value.name !== 'string') {
|
||||||
|
return { error: 'plugin.json "name" must be a string.' };
|
||||||
|
}
|
||||||
|
if (value.version !== undefined && typeof value.version !== 'string') {
|
||||||
|
return { error: 'plugin.json "version" must be a string.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
|
||||||
|
version: typeof value.version === 'string' ? value.version : null,
|
||||||
|
main: value.main.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
|
||||||
|
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
|
||||||
|
const resolved = path.resolve(pluginDir, file);
|
||||||
|
const relative = path.relative(pluginDir, resolved);
|
||||||
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||||
|
import { createPluginHost, type PluginHostDeps } from './plugin-host';
|
||||||
|
import type {
|
||||||
|
PluginConfigEntry,
|
||||||
|
PluginLogger,
|
||||||
|
PluginMpvEventName,
|
||||||
|
PluginWindowRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function writePlugin(root: string, id: string, files: Record<string, string>): void {
|
||||||
|
const dir = path.join(root, id);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
writeFileSync(path.join(dir, name), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeWindow = ReturnType<typeof createFakeWindow>;
|
||||||
|
|
||||||
|
/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */
|
||||||
|
function createFakeWindow() {
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const fake = {
|
||||||
|
destroyed: false,
|
||||||
|
isDestroyed: () => fake.destroyed,
|
||||||
|
destroy: () => {
|
||||||
|
fake.destroyed = true;
|
||||||
|
events.emit('closed');
|
||||||
|
},
|
||||||
|
once: (event: string, listener: () => void) => events.once(event, listener),
|
||||||
|
asWindow: () => fake as unknown as BrowserWindow,
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(
|
||||||
|
config: Record<string, PluginConfigEntry>,
|
||||||
|
overrides: Partial<PluginHostDeps<{ marker: string }>> = {},
|
||||||
|
) {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
|
||||||
|
const errors: string[] = [];
|
||||||
|
const notifications: string[] = [];
|
||||||
|
const sessionActions: SessionActionDispatchRequest[] = [];
|
||||||
|
const windowRequests: PluginWindowRequest[] = [];
|
||||||
|
const windows = new Map<string, FakeWindow>();
|
||||||
|
const logger: PluginLogger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: (message) => errors.push(message),
|
||||||
|
};
|
||||||
|
const deps: PluginHostDeps<{ marker: string }> = {
|
||||||
|
pluginsDir: path.join(root, 'plugins'),
|
||||||
|
pluginDataDir: path.join(root, 'data'),
|
||||||
|
getPluginConfig: () => config,
|
||||||
|
createLogger: () => logger,
|
||||||
|
isMpvConnected: () => true,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
requestMpvProperty: async () => null,
|
||||||
|
ensureMpvRunning: async () => true,
|
||||||
|
dispatchSessionAction: async (request) => {
|
||||||
|
sessionActions.push(request);
|
||||||
|
},
|
||||||
|
getCurrentSubtitle: () => null,
|
||||||
|
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
|
||||||
|
notify: (message) => notifications.push(message),
|
||||||
|
openPanel: () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
openWindow: (request) => {
|
||||||
|
windowRequests.push(request);
|
||||||
|
const existing = windows.get(request.key);
|
||||||
|
if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false };
|
||||||
|
const window = createFakeWindow();
|
||||||
|
windows.set(request.key, window);
|
||||||
|
return { window: window.asWindow(), created: true };
|
||||||
|
},
|
||||||
|
unstable: { marker: 'internals' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
root: deps.pluginsDir,
|
||||||
|
dataRoot: deps.pluginDataDir,
|
||||||
|
errors,
|
||||||
|
notifications,
|
||||||
|
sessionActions,
|
||||||
|
windowRequests,
|
||||||
|
windows,
|
||||||
|
host: createPluginHost(deps),
|
||||||
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = (settings: Record<string, unknown> = {}): PluginConfigEntry => ({
|
||||||
|
enabled: true,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFakeMpvClient() {
|
||||||
|
const bus = new EventEmitter();
|
||||||
|
return {
|
||||||
|
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
|
||||||
|
bus.on(event, listener),
|
||||||
|
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
|
||||||
|
bus.off(event, listener),
|
||||||
|
emit: (event: PluginMpvEventName, payload: unknown) => bus.emit(event, payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads enabled plugins and runs their registered actions', async () => {
|
||||||
|
const h = createHarness({
|
||||||
|
greeter: enabled({ greeting: 'hi' }),
|
||||||
|
off: { enabled: false, settings: {} },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'greeter', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.actions.register('say', () => ctx.notify(ctx.settings.greeting + ' ' + ctx.unstable.marker));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
writePlugin(h.root, 'off', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `throw new Error('disabled plugins must not load');`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(await h.host.runAction('greeter/say'), true);
|
||||||
|
assert.deepEqual(h.notifications, ['hi internals']);
|
||||||
|
assert.equal(await h.host.runAction('greeter/missing'), false);
|
||||||
|
assert.deepEqual(h.errors, []);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads ES module plugins', async () => {
|
||||||
|
const h = createHarness({ esm: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'esm', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.mjs' }),
|
||||||
|
'main.mjs': `export function activate(ctx) { ctx.actions.register('go', () => ctx.notify('esm')); }`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(await h.host.runAction('esm/go'), true);
|
||||||
|
assert.deepEqual(h.notifications, ['esm']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed activation rolls back its registrations without affecting other plugins', async () => {
|
||||||
|
const h = createHarness({ broken: enabled(), healthy: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'broken', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.actions.register('half', () => {});
|
||||||
|
throw new Error('boom');
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
writePlugin(h.root, 'healthy', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('ok', () => {}); };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(await h.host.runAction('broken/half'), false);
|
||||||
|
assert.equal(await h.host.runAction('healthy/ok'), true);
|
||||||
|
assert.deepEqual(h.errors, ['Activation failed: boom']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects entry files outside the plugin directory', async () => {
|
||||||
|
const h = createHarness({ escape: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'escape', {
|
||||||
|
'plugin.json': JSON.stringify({ main: '../elsewhere.cjs' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(h.errors.length, 1);
|
||||||
|
assert.match(h.errors[0]!, /must stay inside/);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv listener errors are contained and unload detaches listeners and calls deactivate', async () => {
|
||||||
|
const h = createHarness({ watcher: enabled() });
|
||||||
|
const client = createFakeMpvClient();
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'watcher', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.mpv.on('pause-change', ({ paused }) => {
|
||||||
|
ctx.notify('paused=' + paused);
|
||||||
|
throw new Error('listener bug');
|
||||||
|
});
|
||||||
|
return () => ctx.notify('deactivated');
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
h.host.attachMpvClient(client);
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
client.emit('pause-change', { paused: true });
|
||||||
|
assert.deepEqual(h.notifications, ['paused=true']);
|
||||||
|
assert.deepEqual(h.errors, ['mpv pause-change listener failed: listener bug']);
|
||||||
|
|
||||||
|
await h.host.unloadAll();
|
||||||
|
client.emit('pause-change', { paused: false });
|
||||||
|
assert.deepEqual(h.notifications, ['paused=true', 'deactivated']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unload gives up on a deactivate that never settles and still disposes the plugin', async () => {
|
||||||
|
const h = createHarness({ stuck: enabled() }, { deactivateTimeoutMs: 20 });
|
||||||
|
const client = createFakeMpvClient();
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'stuck', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
|
||||||
|
return () => new Promise(() => {});
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
h.host.attachMpvClient(client);
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
await h.host.unloadAll();
|
||||||
|
client.emit('pause-change', { paused: true });
|
||||||
|
|
||||||
|
assert.deepEqual(h.notifications, []);
|
||||||
|
assert.deepEqual(h.errors, ['deactivate failed: timed out after 20 ms']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('events follow a replacement mpv client', async () => {
|
||||||
|
const h = createHarness({ watcher: enabled() });
|
||||||
|
const first = createFakeMpvClient();
|
||||||
|
const second = createFakeMpvClient();
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'watcher', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.mpv.on('media-path-change', ({ path }) => ctx.notify(path));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
h.host.attachMpvClient(first);
|
||||||
|
await h.host.loadAll();
|
||||||
|
h.host.attachMpvClient(second);
|
||||||
|
|
||||||
|
first.emit('media-path-change', { path: 'stale.mkv' });
|
||||||
|
second.emit('media-path-change', { path: 'current.mkv' });
|
||||||
|
|
||||||
|
assert.deepEqual(h.notifications, ['current.mkv']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runAction waits for a load that is still in progress', async () => {
|
||||||
|
const h = createHarness({ slow: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'slow', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = async (ctx) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
ctx.actions.register('go', () => ctx.notify('went'));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(await h.host.runAction('slow/go'), true);
|
||||||
|
assert.deepEqual(h.notifications, ['went']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each plugin gets its own data folder, created before activate', async () => {
|
||||||
|
const h = createHarness({ keeper: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'keeper', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `const fs = require('node:fs');
|
||||||
|
exports.activate = (ctx) => {
|
||||||
|
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
|
||||||
|
ctx.notify(ctx.dataDir);
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
const expected = path.join(h.dataRoot, 'keeper');
|
||||||
|
assert.deepEqual(h.notifications, [expected]);
|
||||||
|
assert.equal(existsSync(path.join(expected, 'state.json')), true);
|
||||||
|
assert.deepEqual(h.errors, []);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actions.run forwards built-in session actions', async () => {
|
||||||
|
const h = createHarness({ caller: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'caller', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = async (ctx) => {
|
||||||
|
await ctx.actions.run('openJimaku');
|
||||||
|
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.deepEqual(h.sessionActions, [
|
||||||
|
{ actionId: 'openJimaku' },
|
||||||
|
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
|
||||||
|
const h = createHarness({ viewer: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'viewer', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
|
||||||
|
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
await h.host.runAction('viewer/open');
|
||||||
|
await h.host.runAction('viewer/open');
|
||||||
|
const dir = path.join(h.root, 'viewer');
|
||||||
|
assert.deepEqual(h.windowRequests[0], {
|
||||||
|
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
|
||||||
|
htmlPath: path.join(dir, 'ui', 'index.html'),
|
||||||
|
preloadPath: path.join(dir, 'preload.js'),
|
||||||
|
title: 'viewer',
|
||||||
|
width: 400,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(h.windows.size, 1);
|
||||||
|
|
||||||
|
await h.host.runAction('viewer/escape');
|
||||||
|
assert.equal(h.windowRequests.length, 2);
|
||||||
|
assert.match(h.errors[0] ?? '', /must stay inside/);
|
||||||
|
|
||||||
|
const [window] = [...h.windows.values()];
|
||||||
|
await h.host.unloadAll();
|
||||||
|
assert.equal(window?.destroyed, true);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||||
|
import { execPluginCommand } from './exec';
|
||||||
|
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
|
||||||
|
import {
|
||||||
|
PLUGIN_MPV_EVENT_NAMES,
|
||||||
|
type PluginConfigEntry,
|
||||||
|
type PluginContext,
|
||||||
|
type PluginLogger,
|
||||||
|
type PluginModule,
|
||||||
|
type PluginMpvEventName,
|
||||||
|
type PluginNotifyVariant,
|
||||||
|
type PluginPanelContent,
|
||||||
|
type PluginPanelHandle,
|
||||||
|
type PluginSubtitleContext,
|
||||||
|
type PluginSubtitleLine,
|
||||||
|
type PluginWindowRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const MIN_TIMER_INTERVAL_MS = 100;
|
||||||
|
const DEFAULT_WINDOW_WIDTH = 960;
|
||||||
|
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||||
|
// Quit waits for unloadAll, so a deactivate that never settles must not hold the app open.
|
||||||
|
const DEFAULT_DEACTIVATE_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
interface MpvClientEvents {
|
||||||
|
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||||
|
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostDeps<TUnstable> {
|
||||||
|
pluginsDir: string;
|
||||||
|
/** Root of the per-plugin data folders; each plugin gets `<pluginDataDir>/<id>`. */
|
||||||
|
pluginDataDir: string;
|
||||||
|
getPluginConfig: () => Record<string, PluginConfigEntry>;
|
||||||
|
createLogger: (scope: string) => PluginLogger;
|
||||||
|
isMpvConnected: () => boolean;
|
||||||
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
|
ensureMpvRunning: () => Promise<boolean>;
|
||||||
|
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||||
|
getCurrentSubtitle: () => PluginSubtitleLine | null;
|
||||||
|
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||||
|
notify: (message: string, variant: PluginNotifyVariant) => void;
|
||||||
|
openPanel: (content: PluginPanelContent) => PluginPanelHandle;
|
||||||
|
/** Focuses the window already open under `request.key`, or creates it (`created: true`). */
|
||||||
|
openWindow: (request: PluginWindowRequest) => { window: BrowserWindow; created: boolean };
|
||||||
|
unstable: TUnstable;
|
||||||
|
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
|
||||||
|
loadModule?: (entryPath: string) => PluginModule;
|
||||||
|
exec?: typeof execPluginCommand;
|
||||||
|
/** How long unload waits for each plugin's deactivate before disposing it anyway. */
|
||||||
|
deactivateTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
id: string;
|
||||||
|
disposers: Set<() => void>;
|
||||||
|
deactivate: (() => unknown) | null;
|
||||||
|
/** Aborted on unload so in-flight `exec` children die with the plugin. */
|
||||||
|
lifetime: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as { then?: unknown }).then === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickExport<K extends 'activate' | 'deactivate'>(
|
||||||
|
mod: PluginModule,
|
||||||
|
key: K,
|
||||||
|
): NonNullable<PluginModule[K]> | null {
|
||||||
|
const direct = mod[key];
|
||||||
|
if (typeof direct === 'function') return direct as NonNullable<PluginModule[K]>;
|
||||||
|
const fromDefault = mod.default?.[key];
|
||||||
|
return typeof fromDefault === 'function' ? (fromDefault as NonNullable<PluginModule[K]>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads enabled plugins from `<pluginsDir>/<id>/plugin.json` and gives each one a scoped context.
|
||||||
|
* Every plugin callback runs behind a guard, so a throwing plugin logs instead of breaking the app.
|
||||||
|
* Plugins run in the main process: a synchronous busy loop still blocks it.
|
||||||
|
*/
|
||||||
|
export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||||
|
const mpvEvents = new EventEmitter();
|
||||||
|
mpvEvents.setMaxListeners(0);
|
||||||
|
const plugins = new Map<string, LoadedPlugin>();
|
||||||
|
const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
|
||||||
|
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
|
||||||
|
const exec = deps.exec ?? execPluginCommand;
|
||||||
|
let attachedClient: {
|
||||||
|
client: MpvClientEvents;
|
||||||
|
forwarders: Map<string, (p: unknown) => void>;
|
||||||
|
} | null = null;
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
|
||||||
|
try {
|
||||||
|
const result = callback();
|
||||||
|
if (isPromiseLike(result)) {
|
||||||
|
Promise.resolve(result).catch((error) =>
|
||||||
|
log.error(`${label} failed: ${errorMessage(error)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`${label} failed: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(
|
||||||
|
plugin: LoadedPlugin,
|
||||||
|
dir: string,
|
||||||
|
dataDir: string,
|
||||||
|
entry: PluginConfigEntry,
|
||||||
|
log: PluginLogger,
|
||||||
|
): PluginContext<TUnstable> {
|
||||||
|
const track = (dispose: () => void): (() => void) => {
|
||||||
|
let disposed = false;
|
||||||
|
const disposer = (): void => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
plugin.disposers.delete(disposer);
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
plugin.disposers.add(disposer);
|
||||||
|
return disposer;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: plugin.id,
|
||||||
|
dir,
|
||||||
|
settings: Object.freeze({ ...entry.settings }),
|
||||||
|
dataDir,
|
||||||
|
log,
|
||||||
|
every: (intervalMs, callback) => {
|
||||||
|
const handle = setInterval(
|
||||||
|
() => runGuarded(log, 'Timer', callback),
|
||||||
|
Math.max(MIN_TIMER_INTERVAL_MS, intervalMs),
|
||||||
|
);
|
||||||
|
return track(() => clearInterval(handle));
|
||||||
|
},
|
||||||
|
after: (delayMs, callback) => {
|
||||||
|
const handle = setTimeout(
|
||||||
|
() => {
|
||||||
|
dispose();
|
||||||
|
runGuarded(log, 'Timer', callback);
|
||||||
|
},
|
||||||
|
Math.max(0, delayMs),
|
||||||
|
);
|
||||||
|
const dispose = track(() => clearTimeout(handle));
|
||||||
|
return dispose;
|
||||||
|
},
|
||||||
|
mpv: {
|
||||||
|
get connected() {
|
||||||
|
return deps.isMpvConnected();
|
||||||
|
},
|
||||||
|
on: (event, listener) => {
|
||||||
|
const wrapped = (payload: unknown): void =>
|
||||||
|
runGuarded(log, `mpv ${event} listener`, () =>
|
||||||
|
listener(payload as Parameters<typeof listener>[0]),
|
||||||
|
);
|
||||||
|
mpvEvents.on(event, wrapped);
|
||||||
|
return track(() => mpvEvents.off(event, wrapped));
|
||||||
|
},
|
||||||
|
command: (command) => deps.sendMpvCommand(command),
|
||||||
|
getProperty: (name) => deps.requestMpvProperty(name),
|
||||||
|
ensureRunning: () => deps.ensureMpvRunning(),
|
||||||
|
},
|
||||||
|
subtitles: {
|
||||||
|
current: () => deps.getCurrentSubtitle(),
|
||||||
|
context: (options = {}) => deps.getSubtitleContext(options),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
register: (name, handler) => {
|
||||||
|
if (!isValidPluginName(name)) {
|
||||||
|
throw new Error(`Invalid action name "${name}"; use letters, digits, ".", "_" or "-".`);
|
||||||
|
}
|
||||||
|
const actionId = `${plugin.id}/${name}`;
|
||||||
|
if (actions.has(actionId)) {
|
||||||
|
throw new Error(`Action "${actionId}" is already registered.`);
|
||||||
|
}
|
||||||
|
actions.set(actionId, { pluginId: plugin.id, handler });
|
||||||
|
return track(() => actions.delete(actionId));
|
||||||
|
},
|
||||||
|
run: (actionId, payload) =>
|
||||||
|
deps.dispatchSessionAction(payload === undefined ? { actionId } : { actionId, payload }),
|
||||||
|
},
|
||||||
|
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
||||||
|
ui: {
|
||||||
|
panel: (content) => {
|
||||||
|
const handle = deps.openPanel({
|
||||||
|
title: content.title ?? plugin.id,
|
||||||
|
markdown: content.markdown,
|
||||||
|
});
|
||||||
|
const dispose = track(() => handle.close());
|
||||||
|
handle.signal.addEventListener('abort', dispose, { once: true });
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
openWindow: (options) => {
|
||||||
|
const resolveFile = (label: string, file: string): string => {
|
||||||
|
const resolved = resolvePluginFilePath(dir, file);
|
||||||
|
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
const htmlPath = resolveFile('html', options.html);
|
||||||
|
const { window, created } = deps.openWindow({
|
||||||
|
key: `${plugin.id}:${htmlPath}`,
|
||||||
|
htmlPath,
|
||||||
|
preloadPath:
|
||||||
|
options.preload === undefined ? null : resolveFile('preload', options.preload),
|
||||||
|
title: options.title ?? plugin.id,
|
||||||
|
width: options.width ?? DEFAULT_WINDOW_WIDTH,
|
||||||
|
height: options.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
let closed = false;
|
||||||
|
const dispose = track(() => {
|
||||||
|
if (!closed && !window.isDestroyed()) window.destroy();
|
||||||
|
});
|
||||||
|
window.once('closed', () => {
|
||||||
|
closed = true;
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exec: (command, args, options = {}) =>
|
||||||
|
exec(command, args, {
|
||||||
|
...options,
|
||||||
|
signal: options.signal
|
||||||
|
? AbortSignal.any([options.signal, plugin.lifetime.signal])
|
||||||
|
: plugin.lifetime.signal,
|
||||||
|
}),
|
||||||
|
unstable: deps.unstable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposePlugin(plugin: LoadedPlugin): void {
|
||||||
|
plugin.lifetime.abort();
|
||||||
|
for (const dispose of [...plugin.disposers]) {
|
||||||
|
try {
|
||||||
|
dispose();
|
||||||
|
} catch {
|
||||||
|
// A failing disposer must not keep the rest registered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins.delete(plugin.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlugin(id: string, entry: PluginConfigEntry): Promise<void> {
|
||||||
|
const log = deps.createLogger(`plugin:${id}`);
|
||||||
|
if (!isValidPluginName(id)) {
|
||||||
|
log.error(`Invalid plugin id "${id}"; use letters, digits, ".", "_" or "-".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.join(deps.pluginsDir, id);
|
||||||
|
let rawManifest: unknown;
|
||||||
|
try {
|
||||||
|
rawManifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Cannot read ${path.join(dir, 'plugin.json')}: ${errorMessage(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parsePluginManifest(rawManifest, id);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
log.error(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entryPath = resolvePluginFilePath(dir, parsed.manifest.main);
|
||||||
|
if (!entryPath) {
|
||||||
|
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = path.join(deps.pluginDataDir, id);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: LoadedPlugin = {
|
||||||
|
id,
|
||||||
|
disposers: new Set(),
|
||||||
|
deactivate: null,
|
||||||
|
lifetime: new AbortController(),
|
||||||
|
};
|
||||||
|
plugins.set(id, plugin);
|
||||||
|
try {
|
||||||
|
const mod = loadModule(entryPath);
|
||||||
|
const activate = pickExport(mod, 'activate');
|
||||||
|
if (!activate) {
|
||||||
|
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
||||||
|
}
|
||||||
|
const returned = await activate(
|
||||||
|
createContext(plugin, dir, dataDir, entry, log) as PluginContext,
|
||||||
|
);
|
||||||
|
plugin.deactivate =
|
||||||
|
typeof returned === 'function'
|
||||||
|
? (returned as () => unknown)
|
||||||
|
: pickExport(mod, 'deactivate');
|
||||||
|
const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
|
||||||
|
log.info(`Loaded ${parsed.manifest.name}${version}.`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Activation failed: ${errorMessage(error)}`);
|
||||||
|
disposePlugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Forwards the client's events to plugin listeners, replacing any previously attached client. */
|
||||||
|
attachMpvClient(client: MpvClientEvents): void {
|
||||||
|
if (attachedClient) {
|
||||||
|
for (const [event, forward] of attachedClient.forwarders) {
|
||||||
|
attachedClient.client.off(event as PluginMpvEventName, forward);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const forwarders = new Map<string, (payload: unknown) => void>();
|
||||||
|
for (const event of PLUGIN_MPV_EVENT_NAMES) {
|
||||||
|
const forward = (payload: unknown): void => {
|
||||||
|
mpvEvents.emit(event, payload);
|
||||||
|
};
|
||||||
|
client.on(event, forward);
|
||||||
|
forwarders.set(event, forward);
|
||||||
|
}
|
||||||
|
attachedClient = { client, forwarders };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Loads every enabled plugin once; later calls return the first load. */
|
||||||
|
loadAll(): Promise<void> {
|
||||||
|
loadPromise ??= Promise.all(
|
||||||
|
Object.entries(deps.getPluginConfig())
|
||||||
|
.filter(([, entry]) => entry.enabled)
|
||||||
|
.map(([id, entry]) => loadPlugin(id, entry)),
|
||||||
|
).then(() => undefined);
|
||||||
|
return loadPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
async unloadAll(): Promise<void> {
|
||||||
|
loadPromise = null;
|
||||||
|
const timeoutMs = deps.deactivateTimeoutMs ?? DEFAULT_DEACTIVATE_TIMEOUT_MS;
|
||||||
|
await Promise.all(
|
||||||
|
[...plugins.values()].map(async (plugin) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.resolve().then(() => plugin.deactivate?.()),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error(`timed out after ${timeoutMs} ms`)),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
deps
|
||||||
|
.createLogger(`plugin:${plugin.id}`)
|
||||||
|
.error(`deactivate failed: ${errorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
disposePlugin(plugin);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a registered `<plugin id>/<name>` action, waiting for an in-progress load first so a
|
||||||
|
* command sent at startup is not lost. Resolves false when no plugin registered the action.
|
||||||
|
*/
|
||||||
|
async runAction(actionId: string): Promise<boolean> {
|
||||||
|
await loadPromise;
|
||||||
|
const action = actions.get(actionId);
|
||||||
|
if (!action) return false;
|
||||||
|
runGuarded(
|
||||||
|
deps.createLogger(`plugin:${action.pluginId}`),
|
||||||
|
`Action ${actionId}`,
|
||||||
|
action.handler,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginHost = ReturnType<typeof createPluginHost>;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildPluginSubtitleContext } from './subtitle-context';
|
||||||
|
|
||||||
|
const cues = [
|
||||||
|
{ text: '一', startTime: 1, endTime: 2 },
|
||||||
|
{ text: 'はい', startTime: 3, endTime: 4 },
|
||||||
|
{ text: '二', startTime: 5, endTime: 6 },
|
||||||
|
{ text: '三', startTime: 7, endTime: 8 },
|
||||||
|
{ text: 'はい', startTime: 9, endTime: 10 },
|
||||||
|
{ text: '四', startTime: 11, endTime: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('returns neighbors of the matching cue, preferring the one nearest the live timing', () => {
|
||||||
|
// Live timing is shifted by a +1.5 s sub-delay, so it sits outside both はい cues.
|
||||||
|
const context = buildPluginSubtitleContext({
|
||||||
|
cues,
|
||||||
|
current: { text: 'はい', startTime: 10.5, endTime: 11.5 },
|
||||||
|
recentLines: [],
|
||||||
|
before: 2,
|
||||||
|
after: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
context.before.map((line) => line.text),
|
||||||
|
['二', '三'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.after.map((line) => line.text),
|
||||||
|
['四'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to recently displayed lines when the line is not in the cue list', () => {
|
||||||
|
const context = buildPluginSubtitleContext({
|
||||||
|
cues: [],
|
||||||
|
current: { text: '今', startTime: 20, endTime: 21 },
|
||||||
|
recentLines: [
|
||||||
|
{ text: '前の前', startTime: 16, endTime: 17 },
|
||||||
|
{ text: '前', startTime: 18, endTime: 19 },
|
||||||
|
{ text: '今', startTime: 20, endTime: 21 },
|
||||||
|
],
|
||||||
|
before: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
context.before.map((line) => line.text),
|
||||||
|
['前の前', '前'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(context.after, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns an empty context when no subtitle is displayed', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildPluginSubtitleContext({
|
||||||
|
cues,
|
||||||
|
current: { text: ' ', startTime: null, endTime: null },
|
||||||
|
recentLines: [],
|
||||||
|
}),
|
||||||
|
{ current: null, before: [], after: [] },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { PluginSubtitleContext, PluginSubtitleLine } from './types';
|
||||||
|
|
||||||
|
interface CueLike {
|
||||||
|
text: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONTEXT_LINES = 20;
|
||||||
|
|
||||||
|
function normalizeText(text: string): string {
|
||||||
|
return text.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCount(value: number | undefined, fallback: number): number {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(MAX_CONTEXT_LINES, Math.max(0, Math.floor(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLine(cue: CueLike): PluginSubtitleLine {
|
||||||
|
return { text: cue.text.trim(), startTime: cue.startTime, endTime: cue.endTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates the displayed line in the parsed cue list. Text decides, timing breaks ties: mpv's live
|
||||||
|
* timing can be shifted by sub-delay, so the same text nearest the reported start wins.
|
||||||
|
*/
|
||||||
|
function findCurrentCueIndex(cues: readonly CueLike[], current: PluginSubtitleLine): number {
|
||||||
|
const target = normalizeText(current.text);
|
||||||
|
const anchor = current.startTime;
|
||||||
|
let best = -1;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
cues.forEach((cue, index) => {
|
||||||
|
if (normalizeText(cue.text) !== target) return;
|
||||||
|
const distance = anchor === null ? 0 : Math.abs(cue.startTime - anchor);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = index;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (best >= 0 || anchor === null) return best;
|
||||||
|
return cues.findIndex((cue) => anchor >= cue.startTime && anchor < cue.endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the current line plus neighbors. Uses the parsed subtitle file when one is loaded and
|
||||||
|
* falls back to recently displayed lines (no `after` context) for streams without a cue list.
|
||||||
|
*/
|
||||||
|
export function buildPluginSubtitleContext(input: {
|
||||||
|
cues: readonly CueLike[];
|
||||||
|
current: PluginSubtitleLine | null;
|
||||||
|
recentLines: readonly PluginSubtitleLine[];
|
||||||
|
before?: number;
|
||||||
|
after?: number;
|
||||||
|
}): PluginSubtitleContext {
|
||||||
|
const beforeCount = clampCount(input.before, 2);
|
||||||
|
const afterCount = clampCount(input.after, 1);
|
||||||
|
const current = input.current && normalizeText(input.current.text) ? input.current : null;
|
||||||
|
if (!current) return { current: null, before: [], after: [] };
|
||||||
|
|
||||||
|
const cues = input.cues
|
||||||
|
.filter((cue) => normalizeText(cue.text).length > 0)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||||
|
const index = findCurrentCueIndex(cues, current);
|
||||||
|
if (index >= 0) {
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
before: cues.slice(Math.max(0, index - beforeCount), index).map(toLine),
|
||||||
|
after: cues.slice(index + 1, index + 1 + afterCount).map(toLine),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = normalizeText(current.text);
|
||||||
|
const recent = input.recentLines.filter((line) => normalizeText(line.text).length > 0);
|
||||||
|
// The tracker usually already holds the current line as its newest entry.
|
||||||
|
const history =
|
||||||
|
recent.length > 0 && normalizeText(recent[recent.length - 1]!.text) === target
|
||||||
|
? recent.slice(0, -1)
|
||||||
|
: recent;
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
before: beforeCount > 0 ? history.slice(-beforeCount) : [],
|
||||||
|
after: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionId, SessionActionPayload } from '../../../types/session-bindings';
|
||||||
|
import type { MpvIpcClientEventMap } from '../mpv';
|
||||||
|
|
||||||
|
/** `plugin.json` at the root of a plugin directory. */
|
||||||
|
export interface PluginManifest {
|
||||||
|
/** Display name; defaults to the directory name. */
|
||||||
|
name: string;
|
||||||
|
version: string | null;
|
||||||
|
/** Entry module, relative to the plugin directory. */
|
||||||
|
main: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `plugins.<id>` entry from config. */
|
||||||
|
export interface PluginConfigEntry {
|
||||||
|
enabled: boolean;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mpv events plugins may observe. Render metrics are internal and stay hidden. */
|
||||||
|
export type PluginMpvEventMap = Omit<MpvIpcClientEventMap, 'subtitle-metrics-change'>;
|
||||||
|
export type PluginMpvEventName = keyof PluginMpvEventMap;
|
||||||
|
|
||||||
|
export const PLUGIN_MPV_EVENT_NAMES = [
|
||||||
|
'connection-change',
|
||||||
|
'subtitle-change',
|
||||||
|
'subtitle-timing',
|
||||||
|
'time-pos-change',
|
||||||
|
'duration-change',
|
||||||
|
'pause-change',
|
||||||
|
'fullscreen-change',
|
||||||
|
'secondary-subtitle-change',
|
||||||
|
'subtitle-track-change',
|
||||||
|
'secondary-subtitle-track-change',
|
||||||
|
'secondary-subtitle-delay-change',
|
||||||
|
'subtitle-track-list-change',
|
||||||
|
'media-path-change',
|
||||||
|
'media-title-change',
|
||||||
|
'secondary-subtitle-visibility',
|
||||||
|
'client-message',
|
||||||
|
'end-file',
|
||||||
|
] as const satisfies readonly PluginMpvEventName[];
|
||||||
|
|
||||||
|
export interface PluginSubtitleLine {
|
||||||
|
text: string;
|
||||||
|
/** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
|
||||||
|
startTime: number | null;
|
||||||
|
endTime: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSubtitleContext {
|
||||||
|
current: PluginSubtitleLine | null;
|
||||||
|
/** Oldest first. */
|
||||||
|
before: PluginSubtitleLine[];
|
||||||
|
after: PluginSubtitleLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPanelContent {
|
||||||
|
title: string;
|
||||||
|
/** Rendered as a safe markdown subset; raw HTML is shown as text. */
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPanelHandle {
|
||||||
|
/** Aborts when the user closes the panel or another panel replaces it. */
|
||||||
|
readonly signal: AbortSignal;
|
||||||
|
readonly closed: boolean;
|
||||||
|
update: (content: Partial<PluginPanelContent>) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginWindowOptions {
|
||||||
|
/** HTML file to load, relative to the plugin directory. */
|
||||||
|
html: string;
|
||||||
|
/** Preload script, relative to the plugin directory. Runs with context isolation. */
|
||||||
|
preload?: string;
|
||||||
|
/** Window title; Hyprland matches it to float the window above the overlay. */
|
||||||
|
title?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the host needs to show a plugin window; paths are absolute and already validated. */
|
||||||
|
export interface PluginWindowRequest {
|
||||||
|
/** Same key, same window: reopening focuses the existing one. */
|
||||||
|
key: string;
|
||||||
|
htmlPath: string;
|
||||||
|
preloadPath: string | null;
|
||||||
|
title: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginExecOptions {
|
||||||
|
/** Written to the child's stdin, which is then closed. */
|
||||||
|
stdin?: string;
|
||||||
|
cwd?: string;
|
||||||
|
/** Merged over the parent environment. */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** Kills the child and rejects after this many milliseconds. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Kills the child and rejects when aborted. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginExecResult {
|
||||||
|
/** Null when the child was terminated by a signal. */
|
||||||
|
exitCode: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginNotifyVariant = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface PluginLogger {
|
||||||
|
debug: (message: string, ...meta: unknown[]) => void;
|
||||||
|
info: (message: string, ...meta: unknown[]) => void;
|
||||||
|
warn: (message: string, ...meta: unknown[]) => void;
|
||||||
|
error: (message: string, ...meta: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Disposer = () => void;
|
||||||
|
|
||||||
|
/** The API object passed to a plugin's `activate(ctx)`. */
|
||||||
|
export interface PluginContext<TUnstable = unknown> {
|
||||||
|
readonly id: string;
|
||||||
|
/** Absolute path of the plugin directory. */
|
||||||
|
readonly dir: string;
|
||||||
|
/** `plugins.<id>.settings` from config. */
|
||||||
|
readonly settings: Readonly<Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* `<config dir>/plugin-data/<id>/` for caches and state. Kept apart from `dir`, so replacing the
|
||||||
|
* plugin folder keeps its data. Created before `activate`.
|
||||||
|
*/
|
||||||
|
readonly dataDir: string;
|
||||||
|
readonly log: PluginLogger;
|
||||||
|
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||||
|
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||||
|
/** One-shot timer; cleared automatically when the plugin unloads. */
|
||||||
|
after: (delayMs: number, callback: () => unknown) => Disposer;
|
||||||
|
readonly mpv: {
|
||||||
|
readonly connected: boolean;
|
||||||
|
on: <E extends PluginMpvEventName>(
|
||||||
|
event: E,
|
||||||
|
listener: (payload: PluginMpvEventMap[E]) => unknown,
|
||||||
|
) => Disposer;
|
||||||
|
command: (command: (string | number)[]) => void;
|
||||||
|
getProperty: (name: string) => Promise<unknown>;
|
||||||
|
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||||
|
ensureRunning: () => Promise<boolean>;
|
||||||
|
};
|
||||||
|
readonly subtitles: {
|
||||||
|
current: () => PluginSubtitleLine | null;
|
||||||
|
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||||
|
};
|
||||||
|
readonly actions: {
|
||||||
|
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||||
|
register: (name: string, handler: () => unknown) => Disposer;
|
||||||
|
/** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */
|
||||||
|
run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||||
|
readonly ui: {
|
||||||
|
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||||
|
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
||||||
|
/**
|
||||||
|
* Opens a window for this plugin, or focuses it if the same `html` is already open.
|
||||||
|
* The window is destroyed when the plugin unloads.
|
||||||
|
*/
|
||||||
|
openWindow: (options: PluginWindowOptions) => BrowserWindow;
|
||||||
|
};
|
||||||
|
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||||
|
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||||
|
/** Raw app internals. No stability guarantee across releases. */
|
||||||
|
readonly unstable: TUnstable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginModule {
|
||||||
|
activate?: (ctx: PluginContext) => unknown;
|
||||||
|
deactivate?: () => unknown;
|
||||||
|
default?: { activate?: (ctx: PluginContext) => unknown; deactivate?: () => unknown };
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
playNextSubtitle: () => calls.push('play-next'),
|
playNextSubtitle: () => calls.push('play-next'),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||||
|
runPluginAction: async (pluginActionId) => {
|
||||||
|
calls.push(`plugin:${pluginActionId}`);
|
||||||
|
return pluginActionId === 'ai-explain/explain';
|
||||||
|
},
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['mark-watched']);
|
assert.deepEqual(calls, ['mark-watched']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
|
||||||
|
const { calls, deps } = createDeps();
|
||||||
|
|
||||||
|
await dispatchSessionAction(
|
||||||
|
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchSessionAction(
|
||||||
|
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'plugin:ai-explain/explain',
|
||||||
|
'plugin:ai-explain/missing',
|
||||||
|
'osd:Plugin action not available: ai-explain/missing',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
|
|||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
playNextPlaylistItem: () => void;
|
playNextPlaylistItem: () => void;
|
||||||
|
/** Returns false when no loaded plugin registered the action. */
|
||||||
|
runPluginAction: (pluginActionId: string) => Promise<boolean>;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'runPluginAction': {
|
||||||
|
const pluginActionId = request.payload?.pluginActionId;
|
||||||
|
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
|
||||||
|
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return assertUnreachableSessionAction(request.actionId);
|
return assertUnreachableSessionAction(request.actionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,31 @@ test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings maps plugin action commands to a validated session action', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts(),
|
||||||
|
keybindings: [
|
||||||
|
createKeybinding('Ctrl+E', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}ai-explain/explain`]),
|
||||||
|
createKeybinding('Ctrl+R', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}no-action-name`]),
|
||||||
|
],
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
const binding = result.bindings[0];
|
||||||
|
assert.equal(result.bindings.length, 1);
|
||||||
|
assert.ok(binding?.actionType === 'session-action');
|
||||||
|
assert.equal(binding.actionId, 'runPluginAction');
|
||||||
|
assert.deepEqual(binding.payload, { pluginActionId: 'ai-explain/explain' });
|
||||||
|
assert.deepEqual(
|
||||||
|
parseSessionActionDispatchRequest({ actionId: binding.actionId, payload: binding.payload }),
|
||||||
|
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||||
|
['unsupported:keybindings[1].command'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||||
const result = compileSessionBindings({
|
const result = compileSessionBindings({
|
||||||
shortcuts: createShortcuts(),
|
shortcuts: createShortcuts(),
|
||||||
|
|||||||
@@ -342,6 +342,16 @@ function resolveCommandBinding(
|
|||||||
if (command.length !== 1) return null;
|
if (command.length !== 1) return null;
|
||||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||||
}
|
}
|
||||||
|
if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
|
||||||
|
const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
|
||||||
|
const [pluginId, actionName, ...rest] = pluginActionId.split('/');
|
||||||
|
if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
|
||||||
|
return {
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'runPluginAction',
|
||||||
|
payload: { pluginActionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (command.length !== 1) {
|
if (command.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
|
|||||||
loadSubtitlePosition: () => void;
|
loadSubtitlePosition: () => void;
|
||||||
resolveKeybindings: () => void;
|
resolveKeybindings: () => void;
|
||||||
createMpvClient: () => void;
|
createMpvClient: () => void;
|
||||||
|
/** Loads enabled plugins; full startup only. */
|
||||||
|
startPlugins?: () => void;
|
||||||
reloadConfig: () => void;
|
reloadConfig: () => void;
|
||||||
getResolvedConfig: () => AppReadyConfigLike;
|
getResolvedConfig: () => AppReadyConfigLike;
|
||||||
getConfigWarnings: () => ConfigValidationWarning[];
|
getConfigWarnings: () => ConfigValidationWarning[];
|
||||||
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
deps.initRuntimeOptionsManager();
|
deps.initRuntimeOptionsManager();
|
||||||
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
||||||
|
deps.startPlugins?.();
|
||||||
|
|
||||||
const wsConfig = config.websocket || {};
|
const wsConfig = config.websocket || {};
|
||||||
const wsEnabled = wsConfig.enabled ?? 'auto';
|
const wsEnabled = wsConfig.enabled ?? 'auto';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test('getYomitanExtensionSearchPaths prioritizes generated build output before p
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(searchPaths, [
|
assert.deepEqual(searchPaths, [
|
||||||
path.join(repoRoot, 'build', 'yomitan'),
|
path.join(repoRoot, 'dist', 'yomitan'),
|
||||||
path.join(resourcesPath, 'yomitan'),
|
path.join(resourcesPath, 'yomitan'),
|
||||||
'/usr/share/SubMiner/yomitan',
|
'/usr/share/SubMiner/yomitan',
|
||||||
path.join(userDataPath, 'yomitan'),
|
path.join(userDataPath, 'yomitan'),
|
||||||
@@ -29,19 +29,19 @@ test('getYomitanExtensionSearchPaths prioritizes generated build output before p
|
|||||||
|
|
||||||
test('resolveExistingYomitanExtensionPath returns first manifest-backed candidate', () => {
|
test('resolveExistingYomitanExtensionPath returns first manifest-backed candidate', () => {
|
||||||
const existing = new Set<string>([
|
const existing = new Set<string>([
|
||||||
path.join('/repo', 'build', 'yomitan', 'manifest.json'),
|
path.join('/repo', 'dist', 'yomitan', 'manifest.json'),
|
||||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext', 'manifest.json'),
|
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext', 'manifest.json'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveExistingYomitanExtensionPath(
|
const resolved = resolveExistingYomitanExtensionPath(
|
||||||
[
|
[
|
||||||
path.join('/repo', 'build', 'yomitan'),
|
path.join('/repo', 'dist', 'yomitan'),
|
||||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext'),
|
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext'),
|
||||||
],
|
],
|
||||||
(candidate) => existing.has(candidate),
|
(candidate) => existing.has(candidate),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(resolved, path.join('/repo', 'build', 'yomitan'));
|
assert.equal(resolved, path.join('/repo', 'dist', 'yomitan'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveExistingYomitanExtensionPath ignores source tree without built manifest', () => {
|
test('resolveExistingYomitanExtensionPath ignores source tree without built manifest', () => {
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export function getYomitanExtensionSearchPaths(
|
|||||||
const searchPaths: string[] = [];
|
const searchPaths: string[] = [];
|
||||||
|
|
||||||
pushUnique(searchPaths, options.explicitPath ? path.resolve(options.explicitPath) : null);
|
pushUnique(searchPaths, options.explicitPath ? path.resolve(options.explicitPath) : null);
|
||||||
pushUnique(searchPaths, options.cwd ? path.resolve(options.cwd, 'build', 'yomitan') : null);
|
pushUnique(searchPaths, options.cwd ? path.resolve(options.cwd, 'dist', 'yomitan') : null);
|
||||||
pushUnique(
|
pushUnique(
|
||||||
searchPaths,
|
searchPaths,
|
||||||
options.moduleDir
|
options.moduleDir ? path.resolve(options.moduleDir, '..', '..', '..', 'dist', 'yomitan') : null,
|
||||||
? path.resolve(options.moduleDir, '..', '..', '..', 'build', 'yomitan')
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
pushUnique(
|
pushUnique(
|
||||||
searchPaths,
|
searchPaths,
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
|
||||||
buildNotifySendEnv,
|
|
||||||
createNotifySendReplacer,
|
|
||||||
resolveDefaultNotificationIconPath,
|
|
||||||
} from './notification';
|
|
||||||
|
|
||||||
test('notify-send child environment drops the AppImage library-path override', () => {
|
|
||||||
const env = buildNotifySendEnv({
|
|
||||||
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
|
||||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
|
||||||
HOME: '/home/user',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
|
||||||
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
|
||||||
assert.equal(env.HOME, '/home/user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||||
const path = resolveDefaultNotificationIconPath({
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import electron from 'electron';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||||
|
|
||||||
const { Notification, nativeImage } = electron;
|
const { Notification, nativeImage } = electron;
|
||||||
const logger = createLogger('core:notification');
|
const logger = createLogger('core:notification');
|
||||||
@@ -203,19 +204,8 @@ export function createNotifySendReplacer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libnotify predates the
|
|
||||||
* symbols the system notify-send links against, so an inherited environment kills the child with a
|
|
||||||
* symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
|
|
||||||
* so the override is dropped entirely rather than filtered.
|
|
||||||
*/
|
|
||||||
export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
||||||
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
|
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
|
||||||
execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
|
execFile('notify-send', args, { timeout: 5_000, env: buildSystemBinaryEnv() }, (error, stdout) =>
|
||||||
callback(error, stdout ?? ''),
|
callback(error, stdout ?? ''),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||||
|
|
||||||
|
test('system binary environment drops the AppImage library-path override', () => {
|
||||||
|
const env = buildSystemBinaryEnv({
|
||||||
|
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
||||||
|
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||||
|
HOME: '/home/user',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
||||||
|
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
||||||
|
assert.equal(env.HOME, '/home/user');
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libraries (libnotify and
|
||||||
|
* friends) predate the symbols system binaries link against, so an inherited environment can kill a
|
||||||
|
* spawned child with a symbol lookup error. A system binary resolves its own libraries fine, so the
|
||||||
|
* override is dropped entirely rather than filtered.
|
||||||
|
*/
|
||||||
|
export function buildSystemBinaryEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||||
|
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user