mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e159bbd73 | |||
| cb3d8d64fa | |||
| c0a95ff899 | |||
| ddaca9d126 | |||
| 77b28e97a4 |
@@ -0,0 +1,78 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working as expected
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. The more detail you include, the faster it gets fixed.
|
||||
You can use the **Report a Bug** button in **Settings → About** to pre-fill most of this automatically.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What's broken? A clear, concise summary.
|
||||
placeholder: "e.g. Library card stats don't appear even with 'Always show' enabled"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Exact steps to trigger the bug.
|
||||
placeholder: |
|
||||
1. Open Settings → Library
|
||||
2. Enable "Always show card stats"
|
||||
3. Return to Library
|
||||
4. Unread counts are not visible
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: "Unread and download counts should be permanently visible on manga cards"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: "Counts only appear on hover, or not at all"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Copy this from Settings → About → Report a Bug, or fill in manually.
|
||||
placeholder: |
|
||||
- Moku Version: v0.9.4
|
||||
- Platform: Windows / macOS / Linux / Web
|
||||
- OS Version: Windows 11 24H2
|
||||
- Server: Suwayomi v2.2.2196
|
||||
- Server URL: localhost:4567
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: settings
|
||||
attributes:
|
||||
label: Relevant Settings
|
||||
description: Settings related to the bug (auto-filled by the in-app reporter, or paste manually).
|
||||
placeholder: |
|
||||
libraryStatsAlways: true
|
||||
libraryCropCovers: true
|
||||
libraryPageSize: 48
|
||||
render: yaml
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Screenshots, screen recordings, console errors, anything else helpful.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions (Questions & Support)
|
||||
url: https://github.com/moku-project/Moku/discussions
|
||||
about: Not a bug? Ask questions and get help here.
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Got an idea? Describe what you want and why it would be useful.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: What's the gap or frustration this would address?
|
||||
placeholder: "e.g. There's no way to bulk-mark chapters as read without opening each series"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: What would you like to see?
|
||||
placeholder: "A 'Mark all read' option in the series long-press context menu"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any workarounds you've tried, or other ways this could be solved.
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Optional — useful if this is platform-specific.
|
||||
placeholder: |
|
||||
- Moku Version: v0.9.4
|
||||
- Platform: Windows / macOS / Linux / Web
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Mockups, references, examples from other apps, etc.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sourced by CI jobs that need versions from nix/versions.nix.
|
||||
# Usage: source .github/read_versions.sh
|
||||
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
||||
|
||||
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
||||
_t=$(cat "$_nix")
|
||||
|
||||
_pick() { echo "$_t" | grep -oP "${1}\s*=\s*\"\K[^\"]+"; }
|
||||
|
||||
export MOKU_VERSION=$(_pick "moku")
|
||||
export SUWA_VERSION=$(_pick "version")
|
||||
export SUWA_HASH_WINDOWS=$(_pick "windowsHash")
|
||||
export SUWA_HASH_LINUX=$(_pick "linuxHash")
|
||||
export SUWA_HASH_MACOS_ARM64=$(_pick "macosArm64Hash")
|
||||
export SUWA_HASH_MACOS_X64=$(_pick "macosX64Hash")
|
||||
|
||||
unset _nix _t
|
||||
unset -f _pick
|
||||
@@ -16,24 +16,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-linux
|
||||
path: dist/
|
||||
@@ -43,77 +34,56 @@ jobs:
|
||||
name: Tauri (Linux x64)
|
||||
needs: frontend
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-linux
|
||||
path: dist/
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist-linux, path: dist/ }
|
||||
|
||||
- name: Read versions
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "MOKU_VERSION=$MOKU_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH=$SUWA_HASH_LINUX" >> $GITHUB_ENV
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libfuse2
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-gnu }
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi (Linux x64)
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196-linux-x64.tar.gz" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-linux-x64.tar.gz" \
|
||||
-o suwayomi-linux.tar.gz
|
||||
|
||||
echo "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f suwayomi-linux.tar.gz" | sha256sum -c -
|
||||
|
||||
echo "${SUWA_HASH} suwayomi-linux.tar.gz" | sha256sum -c -
|
||||
mkdir -p suwayomi-extracted
|
||||
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
||||
JAVA="suwayomi-extracted/jre/bin/java"
|
||||
CATCH="suwayomi-extracted/bin/catch_abort.so"
|
||||
|
||||
for f in "$JAR" "$JAVA" "$CATCH"; do
|
||||
if [ ! -e "$f" ]; then
|
||||
echo "ERROR: expected file not found: $f"
|
||||
find suwayomi-extracted -type f | head -40
|
||||
exit 1
|
||||
fi
|
||||
for f in suwayomi-extracted/bin/Suwayomi-Server.jar \
|
||||
suwayomi-extracted/jre/bin/java \
|
||||
suwayomi-extracted/bin/catch_abort.so; do
|
||||
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
|
||||
done
|
||||
|
||||
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
|
||||
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||
|
||||
@@ -129,43 +99,30 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||
env:
|
||||
NO_STRIP: "true"
|
||||
env: { NO_STRIP: "true" }
|
||||
|
||||
- name: Upload Linux artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||
echo "Waiting for release to exist... attempt $i"
|
||||
sleep 15
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found release ID: $RELEASE_ID"
|
||||
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local name="$2"
|
||||
echo "Uploading $name..."
|
||||
upload() {
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$file" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||
--data-binary @"$1" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||
}
|
||||
|
||||
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||
|
||||
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
||||
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
|
||||
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
|
||||
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
@@ -16,28 +16,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: frontend-dist, path: dist/, retention-days: 1 }
|
||||
|
||||
tauri:
|
||||
name: Tauri (macOS)
|
||||
@@ -46,149 +34,103 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist, path: dist/ }
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
- name: Read versions
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH_ARM64=$SUWA_HASH_MACOS_ARM64" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH_X64=$SUWA_HASH_MACOS_X64" >> $GITHUB_ENV
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi binaries
|
||||
run: |
|
||||
download_suwayomi() {
|
||||
dl() {
|
||||
local asset="$1" sha="$2" outdir="$3"
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/${asset}" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
|
||||
-o "${outdir}.tar.gz"
|
||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||
mkdir -p "${outdir}"
|
||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||
}
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.2.2196-macOS-arm64.tar.gz" \
|
||||
"9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43" \
|
||||
"suwayomi-arm64"
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.2.2196-macOS-x64.tar.gz" \
|
||||
"eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5" \
|
||||
"suwayomi-x64"
|
||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
|
||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
||||
|
||||
- name: Stage Suwayomi sidecars
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
stage_arch() {
|
||||
local srcdir="$1"
|
||||
local arch="$2"
|
||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||
|
||||
stage() {
|
||||
local srcdir="$1" arch="$2"
|
||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||
find "$srcdir" -type f | head -30
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||
find "$srcdir" -type f | head -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||
|
||||
cp -r "$srcdir" "$bundle_dest"
|
||||
|
||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||
chmod +x "$sidecar"
|
||||
echo "Staged sidecar: $sidecar"
|
||||
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
||||
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
||||
}
|
||||
|
||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||
stage suwayomi-arm64 aarch64-apple-darwin
|
||||
stage suwayomi-x64 x86_64-apple-darwin
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
run: |
|
||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Swap bundle for aarch64
|
||||
- name: Build Tauri app (aarch64)
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||
src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (aarch64)
|
||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||
pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
- name: Swap bundle for x86_64
|
||||
- name: Build Tauri app (x86_64)
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||
src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (x86_64)
|
||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
- name: Upload macOS artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# Wait for the Windows workflow to have created the draft release
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||
echo "Waiting for release to exist... attempt $i"
|
||||
sleep 15
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found release ID: $RELEASE_ID"
|
||||
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local name="$2"
|
||||
echo "Uploading $name..."
|
||||
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||
upload() {
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$1" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||
}
|
||||
|
||||
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
|
||||
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||
ARM64=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
X64=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
[ -n "$ARM64" ] && upload "$ARM64" "moku-macos-arm64-${{ github.event.inputs.version }}.dmg"
|
||||
[ -n "$X64" ] && upload "$X64" "moku-macos-x64-${{ github.event.inputs.version }}.dmg"
|
||||
|
||||
@@ -16,120 +16,81 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
|
||||
|
||||
tauri:
|
||||
name: Tauri (Windows x64)
|
||||
needs: frontend
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist-windows, path: dist/ }
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- name: Read versions
|
||||
shell: bash
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-pc-windows-msvc }
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi (Windows x64)
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196-windows-x64.zip" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
echo "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d suwayomi-windows.zip" | sha256sum -c -
|
||||
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
- name: Extract Suwayomi bundle
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p suwayomi-extracted
|
||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||
cp -r "$INNER"/. suwayomi-extracted/
|
||||
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
||||
else
|
||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||
fi
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java.exe not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
||||
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
||||
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Validate staging
|
||||
shell: bash
|
||||
run: |
|
||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||
echo "Staging OK"
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Delete existing draft release if present
|
||||
- name: Delete existing draft release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -138,14 +99,10 @@ jobs:
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "Deleting existing draft release $RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||
echo "Deleted draft release and tag"
|
||||
else
|
||||
echo "No existing draft release found"
|
||||
fi
|
||||
|
||||
- name: Build Tauri app + create draft release
|
||||
@@ -158,10 +115,10 @@ jobs:
|
||||
releaseBody: |
|
||||
Moku v${{ github.event.inputs.version }}
|
||||
|
||||
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||
**Linux:** Download `moku.flatpak`
|
||||
**Windows:** `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||
**macOS arm64:** `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||
**macOS x64:** `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||
**Linux:** `moku.flatpak`
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 7
|
||||
param(
|
||||
[switch]$SkipFrontend,
|
||||
[switch]$SkipSuwayomi
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||
function Need($cmd) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Required tool not found: $cmd"
|
||||
}
|
||||
}
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
Step "Reading nix/versions.nix"
|
||||
$nix = Get-Content "$Root\nix\versions.nix" -Raw
|
||||
$MOKU_VERSION = if ($nix -match 'moku\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "moku version not found" }
|
||||
$SUWA_VERSION = if ($nix -match 'version\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "suwayomi version not found" }
|
||||
$SUWA_HASH = if ($nix -match 'windowsHash\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "windowsHash not found" }
|
||||
Write-Host " moku=$MOKU_VERSION suwayomi=$SUWA_VERSION"
|
||||
|
||||
Need "pnpm"; Need "cargo"; Need "node"
|
||||
|
||||
if (-not $SkipFrontend) {
|
||||
Step "pnpm install"
|
||||
pnpm install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "pnpm install failed" }
|
||||
|
||||
Step "Frontend build"
|
||||
$env:MOKU_TARGET = "static"
|
||||
pnpm build:static
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Frontend build failed" }
|
||||
}
|
||||
|
||||
$BundleDir = "$Root\src-tauri\binaries\suwayomi-bundle"
|
||||
$ZipPath = "$env:TEMP\suwayomi-windows-$SUWA_VERSION.zip"
|
||||
$ExtractDir = "$env:TEMP\suwayomi-extracted-$SUWA_VERSION"
|
||||
|
||||
if (-not $SkipSuwayomi) {
|
||||
Step "Downloading Suwayomi v$SUWA_VERSION"
|
||||
$ZipUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip"
|
||||
|
||||
if (-not (Test-Path $ZipPath)) {
|
||||
Invoke-WebRequest -Uri $ZipUrl -OutFile $ZipPath -UseBasicParsing
|
||||
}
|
||||
|
||||
$actual = (Get-FileHash $ZipPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $SUWA_HASH.ToLower()) {
|
||||
Write-Error "Hash mismatch`n expected: $SUWA_HASH`n got: $actual"
|
||||
}
|
||||
|
||||
Step "Staging bundle"
|
||||
if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force }
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir
|
||||
|
||||
$topDirs = @(Get-ChildItem $ExtractDir -Directory)
|
||||
$topFiles = @(Get-ChildItem $ExtractDir -File)
|
||||
$SrcDir = if ($topDirs.Count -eq 1 -and $topFiles.Count -eq 0) { $topDirs[0].FullName } else { $ExtractDir }
|
||||
|
||||
if (Test-Path $BundleDir) { Remove-Item $BundleDir -Recurse -Force }
|
||||
Copy-Item $SrcDir $BundleDir -Recurse
|
||||
|
||||
$java = Get-ChildItem $BundleDir -Recurse -Filter "java.exe" | Where-Object { $_.FullName -match "jre.bin" } | Select-Object -First 1
|
||||
$jar = Get-ChildItem $BundleDir -Recurse -Filter "Suwayomi-Server.jar" | Select-Object -First 1
|
||||
if (-not $java) { Write-Error "java.exe not found in staged bundle" }
|
||||
if (-not $jar) { Write-Error "Suwayomi-Server.jar not found in staged bundle" }
|
||||
Write-Host " java: $($java.FullName)"
|
||||
Write-Host " jar: $($jar.FullName)"
|
||||
} elseif (-not (Test-Path $BundleDir)) {
|
||||
Write-Error "Bundle dir missing at $BundleDir — run without -SkipSuwayomi first"
|
||||
}
|
||||
|
||||
Step "Patching tauri.conf.json"
|
||||
$tauriConf = "$Root\src-tauri\tauri.conf.json"
|
||||
$original = Get-Content $tauriConf -Raw
|
||||
Set-Content $tauriConf ($original -replace '"beforeBuildCommand":\s*"pnpm build"', '"beforeBuildCommand": ""') -NoNewline
|
||||
|
||||
Step "Tauri build"
|
||||
$env:TAURI_SKIP_DEVSERVER_CHECK = "true"
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
$buildExit = $LASTEXITCODE
|
||||
|
||||
Set-Content $tauriConf $original -NoNewline
|
||||
|
||||
if ($buildExit -ne 0) { Write-Error "Tauri build failed (exit $buildExit)" }
|
||||
|
||||
Step "Artifacts"
|
||||
$out = "$Root\src-tauri\target\x86_64-pc-windows-msvc\release\bundle"
|
||||
$msi = Get-ChildItem "$out\msi" -Filter "*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$exe = Get-ChildItem "$out\nsis" -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
Write-Host "`nDone — Moku $MOKU_VERSION" -ForegroundColor Green
|
||||
if ($msi) { Write-Host " MSI: $($msi.FullName)" -ForegroundColor Yellow }
|
||||
if ($exe) { Write-Host " EXE: $($exe.FullName)" -ForegroundColor Yellow }
|
||||
if (-not $msi -and -not $exe) { Write-Host " No artifacts found in $out" -ForegroundColor Red }
|
||||
+22
-6
@@ -85,17 +85,31 @@ PYEOF
|
||||
|
||||
if [[ $# -ge 2 ]]; then
|
||||
SUWA_VER="$2"
|
||||
JAR_URL="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}/Suwayomi-Server-v${SUWA_VER}.jar"
|
||||
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}"
|
||||
|
||||
SUWA_SHA_HEX=$(curl -fsSL "$JAR_URL" | sha256sum | awk '{print $1}')
|
||||
SUWA_SHA_SRI=$(echo "$SUWA_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||
echo "Fetching Suwayomi v${SUWA_VER} hashes (5 downloads)..."
|
||||
|
||||
sed -i "s/version = \"[^\"]*\"/version = \"$SUWA_VER\"/" "$VERSIONS"
|
||||
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"$SUWA_SHA_SRI\"|" "$VERSIONS"
|
||||
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
|
||||
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
|
||||
|
||||
JAR_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}.jar")
|
||||
WIN_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-windows-x64.zip")
|
||||
LINUX_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-linux-x64.tar.gz")
|
||||
ARM64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-arm64.tar.gz")
|
||||
X64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-x64.tar.gz")
|
||||
|
||||
JAR_SRI=$(to_sri "$JAR_SHA")
|
||||
|
||||
sed -i "s/version = \"[^\"]*\"/version = \"${SUWA_VER}\"/" "$VERSIONS"
|
||||
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"${JAR_SRI}\"|" "$VERSIONS"
|
||||
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"${WIN_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"${LINUX_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"${ARM64_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"${X64_SHA}\"|" "$VERSIONS"
|
||||
|
||||
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
|
||||
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
|
||||
python3 - "$MANIFEST" "$SUWA_SHA_HEX" <<'PYEOF'
|
||||
python3 - "$MANIFEST" "$JAR_SHA" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
@@ -106,6 +120,8 @@ if n == 0:
|
||||
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
|
||||
echo "Suwayomi hashes written."
|
||||
fi
|
||||
|
||||
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||
|
||||
+7
-3
@@ -4,11 +4,15 @@
|
||||
suwayomi = {
|
||||
version = "2.2.2196";
|
||||
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
|
||||
windowsHash = "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d";
|
||||
linuxHash = "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f";
|
||||
macosArm64Hash = "9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43";
|
||||
macosX64Hash = "eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5";
|
||||
};
|
||||
|
||||
frontend = {
|
||||
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
||||
};
|
||||
|
||||
@@ -16,6 +20,6 @@
|
||||
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||
};
|
||||
|
||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||
tarballHash = "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
<script lang="ts">
|
||||
import { Bug, Lightbulb, X, ArrowSquareOut, ClipboardText, Check, CaretDown, CaretRight } from 'phosphor-svelte'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import type { Settings } from '$lib/types/settings'
|
||||
import {
|
||||
SETTINGS_GROUPS,
|
||||
buildEnvironmentBlock,
|
||||
buildSettingsBlock,
|
||||
buildIssueUrl,
|
||||
type ReportType,
|
||||
type BugFields,
|
||||
type FeatureFields,
|
||||
} from './lib/bugReport'
|
||||
|
||||
interface Props { onClose: () => void }
|
||||
let { onClose }: Props = $props()
|
||||
|
||||
type Step = 'type' | 'compose'
|
||||
|
||||
let step: Step = $state('type')
|
||||
let reportType: ReportType = $state('bug')
|
||||
|
||||
let title = $state('')
|
||||
let description = $state('')
|
||||
let steps = $state('')
|
||||
let expected = $state('')
|
||||
let actual = $state('')
|
||||
let problem = $state('')
|
||||
let solution = $state('')
|
||||
let alternatives = $state('')
|
||||
|
||||
let serverVersion = $state<string | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
requestManager.meta.getAboutServer()
|
||||
.then(s => { serverVersion = s.version })
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
let selectedKeys = $state<Set<keyof Settings>>(new Set())
|
||||
let expandedGroups = $state<Set<string>>(new Set(['Library', 'Reader']))
|
||||
|
||||
function toggleGroup(label: string) {
|
||||
const next = new Set(expandedGroups)
|
||||
next.has(label) ? next.delete(label) : next.add(label)
|
||||
expandedGroups = next
|
||||
}
|
||||
|
||||
function selectGroupAll(keys: (keyof Settings)[]) {
|
||||
const next = new Set(selectedKeys)
|
||||
const allOn = keys.every(k => next.has(k))
|
||||
keys.forEach(k => allOn ? next.delete(k) : next.add(k))
|
||||
selectedKeys = next
|
||||
}
|
||||
|
||||
function toggleKey(k: keyof Settings) {
|
||||
const next = new Set(selectedKeys)
|
||||
next.has(k) ? next.delete(k) : next.add(k)
|
||||
selectedKeys = next
|
||||
}
|
||||
|
||||
let copied = $state(false)
|
||||
|
||||
function buildPreview(): string {
|
||||
const env = buildEnvironmentBlock(serverVersion)
|
||||
const sets = buildSettingsBlock([...selectedKeys])
|
||||
|
||||
if (reportType === 'bug') {
|
||||
return [
|
||||
`**Description**\n${description || '(empty)'}`,
|
||||
`**Steps to Reproduce**\n${steps || '(empty)'}`,
|
||||
`**Expected**\n${expected || '(empty)'}`,
|
||||
`**Actual**\n${actual || '(empty)'}`,
|
||||
`**Environment**\n${env}`,
|
||||
sets ? `**Relevant Settings**\n\`\`\`yaml\n${sets}\n\`\`\`` : null,
|
||||
].filter(Boolean).join('\n\n')
|
||||
} else {
|
||||
return [
|
||||
`**Problem / Motivation**\n${problem || '(empty)'}`,
|
||||
`**Proposed Solution**\n${solution || '(empty)'}`,
|
||||
alternatives ? `**Alternatives**\n${alternatives}` : null,
|
||||
`**Environment**\n${env}`,
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(buildPreview())
|
||||
copied = true
|
||||
setTimeout(() => (copied = false), 2000)
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
const settingsBlock = buildSettingsBlock([...selectedKeys])
|
||||
const fields: BugFields | FeatureFields = reportType === 'bug'
|
||||
? { description, steps, expected, actual }
|
||||
: { problem, solution, alternatives }
|
||||
const url = buildIssueUrl(reportType, settingsBlock, title, fields, serverVersion)
|
||||
await platformService.openExternal(url)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||
|
||||
function formatKeyVal(key: keyof Settings): string {
|
||||
const v = settingsState.settings[key]
|
||||
if (v === undefined || v === null) return '~'
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
||||
if (typeof v === 'string') return v === '' ? '""' : v.length > 18 ? v.slice(0, 18) + '…' : v
|
||||
if (typeof v === 'number') return String(v)
|
||||
return '…'
|
||||
}
|
||||
|
||||
const envBlock = $derived(buildEnvironmentBlock(serverVersion))
|
||||
|
||||
const canSubmit = $derived(
|
||||
reportType === 'bug'
|
||||
? title.trim().length > 0 && description.trim().length > 0
|
||||
: title.trim().length > 0 && problem.trim().length > 0
|
||||
)
|
||||
|
||||
function autoResize(node: HTMLTextAreaElement) {
|
||||
function resize() {
|
||||
node.style.height = 'auto'
|
||||
node.style.height = node.scrollHeight + 'px'
|
||||
}
|
||||
resize()
|
||||
node.addEventListener('input', resize)
|
||||
return { destroy() { node.removeEventListener('input', resize) } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="br-backdrop"
|
||||
role="button" tabindex="-1" aria-label="Close"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}>
|
||||
|
||||
<div class="br-shell" role="dialog" aria-label="Report an issue"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}>
|
||||
|
||||
<header class="br-header">
|
||||
<div class="br-header-left">
|
||||
{#if reportType === 'bug'}
|
||||
<Bug size={15} weight="duotone" class="br-header-icon bug" />
|
||||
{:else}
|
||||
<Lightbulb size={15} weight="duotone" class="br-header-icon feature" />
|
||||
{/if}
|
||||
<span class="br-title">
|
||||
{step === 'type' ? 'Report an Issue' : reportType === 'bug' ? 'Bug Report' : 'Feature Request'}
|
||||
</span>
|
||||
</div>
|
||||
<button class="br-close" onclick={onClose} aria-label="Close"><X size={14} weight="bold" /></button>
|
||||
</header>
|
||||
|
||||
{#if step === 'type'}
|
||||
<div class="br-type-step">
|
||||
<p class="br-type-hint">What would you like to submit?</p>
|
||||
<div class="br-type-cards">
|
||||
<button class="br-type-card" class:selected={reportType === 'bug'}
|
||||
onclick={() => { reportType = 'bug'; step = 'compose' }}>
|
||||
<Bug size={28} weight="duotone" />
|
||||
<span class="br-type-name">Bug Report</span>
|
||||
<span class="br-type-desc">Something isn't working as expected</span>
|
||||
</button>
|
||||
<button class="br-type-card" class:selected={reportType === 'feature'}
|
||||
onclick={() => { reportType = 'feature'; step = 'compose' }}>
|
||||
<Lightbulb size={28} weight="duotone" />
|
||||
<span class="br-type-name">Feature Request</span>
|
||||
<span class="br-type-desc">Suggest an improvement or new idea</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="br-compose">
|
||||
|
||||
<div class="br-form">
|
||||
<div class="br-form-scroll">
|
||||
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-title">Title <span class="br-required">*</span></label>
|
||||
<input id="br-title" class="br-input" bind:value={title}
|
||||
placeholder={reportType === 'bug' ? 'Short summary of the bug' : 'Short summary of your idea'}
|
||||
maxlength={120} spellcheck />
|
||||
</div>
|
||||
|
||||
{#if reportType === 'bug'}
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-desc">Description <span class="br-required">*</span></label>
|
||||
<textarea id="br-desc" class="br-textarea" bind:value={description}
|
||||
use:autoResize
|
||||
placeholder="What's broken? A clear, concise summary." rows={3} spellcheck></textarea>
|
||||
</div>
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-steps">Steps to Reproduce</label>
|
||||
<textarea id="br-steps" class="br-textarea" bind:value={steps}
|
||||
use:autoResize
|
||||
placeholder={"1. Open Settings → Library\n2. Enable \"Always show card stats\"\n3. Return to Library\n4. Unread counts are not visible"}
|
||||
rows={4} spellcheck></textarea>
|
||||
</div>
|
||||
<div class="br-field-row">
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-expected">Expected</label>
|
||||
<textarea id="br-expected" class="br-textarea" bind:value={expected}
|
||||
use:autoResize
|
||||
placeholder="What should have happened?" rows={2} spellcheck></textarea>
|
||||
</div>
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-actual">Actual</label>
|
||||
<textarea id="br-actual" class="br-textarea" bind:value={actual}
|
||||
use:autoResize
|
||||
placeholder="What actually happened?" rows={2} spellcheck></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-problem">Problem / Motivation <span class="br-required">*</span></label>
|
||||
<textarea id="br-problem" class="br-textarea" bind:value={problem}
|
||||
use:autoResize
|
||||
placeholder="What gap or frustration does this address?" rows={3} spellcheck></textarea>
|
||||
</div>
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-solution">Proposed Solution <span class="br-required">*</span></label>
|
||||
<textarea id="br-solution" class="br-textarea" bind:value={solution}
|
||||
use:autoResize
|
||||
placeholder="What would you like to see?" rows={3} spellcheck></textarea>
|
||||
</div>
|
||||
<div class="br-field">
|
||||
<label class="br-label" for="br-alt">Alternatives Considered</label>
|
||||
<textarea id="br-alt" class="br-textarea" bind:value={alternatives}
|
||||
use:autoResize
|
||||
placeholder="Any workarounds or other approaches?" rows={2} spellcheck></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="br-field">
|
||||
<label class="br-label">Environment <span class="br-auto">auto-filled</span></label>
|
||||
<pre class="br-env-block">{envBlock}</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if reportType === 'bug'}
|
||||
<div class="br-sidebar">
|
||||
<div class="br-sidebar-header">
|
||||
<span class="br-sidebar-title">Include Settings</span>
|
||||
<span class="br-sidebar-hint">Select groups relevant to the bug</span>
|
||||
</div>
|
||||
<div class="br-sidebar-scroll">
|
||||
{#each SETTINGS_GROUPS as group}
|
||||
{@const selectedInGroup = group.keys.filter(k => selectedKeys.has(k)).length}
|
||||
{@const allSelected = selectedInGroup === group.keys.length}
|
||||
{@const expanded = expandedGroups.has(group.label)}
|
||||
|
||||
<div class="br-group">
|
||||
<div class="br-group-row">
|
||||
<button class="br-group-toggle" onclick={() => toggleGroup(group.label)}>
|
||||
{#if expanded}
|
||||
<CaretDown size={9} weight="bold" />
|
||||
{:else}
|
||||
<CaretRight size={9} weight="bold" />
|
||||
{/if}
|
||||
<span class="br-group-label">{group.label}</span>
|
||||
{#if selectedInGroup > 0}
|
||||
<span class="br-group-count">{selectedInGroup}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="br-group-all"
|
||||
class:active={allSelected}
|
||||
onclick={() => selectGroupAll(group.keys)}
|
||||
title={allSelected ? 'Deselect all' : 'Select all'}>
|
||||
{allSelected ? 'none' : 'all'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div class="br-key-list">
|
||||
{#each group.keys as key}
|
||||
<label class="br-key-row">
|
||||
<input type="checkbox"
|
||||
checked={selectedKeys.has(key)}
|
||||
onchange={() => toggleKey(key)}
|
||||
class="br-checkbox" />
|
||||
<span class="br-key-name">{key}</span>
|
||||
<span class="br-key-val">{formatKeyVal(key)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="br-footer">
|
||||
<button class="br-btn ghost" onclick={() => (step = 'type')}>Back</button>
|
||||
<div class="br-footer-right">
|
||||
<button class="br-btn secondary" onclick={handleCopy} title="Copy report to clipboard">
|
||||
{#if copied}
|
||||
<Check size={13} /><span>Copied!</span>
|
||||
{:else}
|
||||
<ClipboardText size={13} /><span>Copy</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="br-btn primary" disabled={!canSubmit} onclick={handleOpen}>
|
||||
<ArrowSquareOut size={13} /><span>Open on GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.br-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
z-index: calc(var(--z-settings) + 1);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: br-fade 0.14s ease both;
|
||||
}
|
||||
|
||||
@keyframes br-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes br-scale { from { transform: scale(0.96); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
|
||||
.br-shell {
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
height: min(600px, calc(100vh - 64px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 32px 80px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
animation: br-scale 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
|
||||
.br-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.br-header-left { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.br-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
:global(.br-header-icon.bug) { color: var(--color-error); }
|
||||
:global(.br-header-icon.feature) { color: var(--accent-fg); }
|
||||
.br-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: var(--radius-md); border: none; background: none;
|
||||
color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.br-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.br-type-step {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: var(--sp-5); padding: var(--sp-6);
|
||||
}
|
||||
.br-type-hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); text-transform: uppercase;
|
||||
}
|
||||
.br-type-cards { display: flex; gap: var(--sp-4); }
|
||||
.br-type-card {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-2); padding: var(--sp-6) var(--sp-8);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer; color: var(--text-secondary);
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
min-width: 180px;
|
||||
}
|
||||
.br-type-card:hover { border-color: var(--border-strong); background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.br-type-card.selected { border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.br-type-name { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
.br-type-desc {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.br-compose { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||
|
||||
.br-form { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; border-right: 1px solid var(--border-dim); }
|
||||
.br-form-scroll { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.br-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.br-field-row { display: flex; gap: var(--sp-3); }
|
||||
.br-field-row .br-field { flex: 1; }
|
||||
|
||||
.br-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-1);
|
||||
}
|
||||
.br-required { color: var(--color-error); }
|
||||
.br-auto {
|
||||
font-size: var(--text-2xs); padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.br-input, .br-textarea {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
padding: 8px var(--sp-3); outline: none; resize: none;
|
||||
font-family: inherit; line-height: var(--leading-snug);
|
||||
transition: border-color var(--t-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
.br-input:focus, .br-textarea:focus { border-color: var(--border-focus); }
|
||||
.br-input::placeholder, .br-textarea::placeholder { color: var(--text-faint); }
|
||||
|
||||
.br-env-block {
|
||||
font-family: monospace; font-size: 11px;
|
||||
color: var(--text-faint); line-height: var(--leading-base);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: var(--sp-3);
|
||||
white-space: pre-wrap; margin: 0;
|
||||
}
|
||||
|
||||
.br-sidebar {
|
||||
width: 220px; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
.br-sidebar-header {
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-2);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
|
||||
}
|
||||
.br-sidebar-title {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.br-sidebar-hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
}
|
||||
.br-sidebar-scroll { flex: 1; overflow-y: auto; padding: var(--sp-1) 0 var(--sp-2); }
|
||||
|
||||
.br-group { display: flex; flex-direction: column; }
|
||||
|
||||
.br-group-row {
|
||||
display: flex; align-items: center;
|
||||
padding: 3px var(--sp-3) 3px var(--sp-3);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.br-group-toggle {
|
||||
flex: 1; display: flex; align-items: center; gap: 5px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-faint); padding: 2px 0;
|
||||
transition: color var(--t-fast);
|
||||
min-width: 0;
|
||||
}
|
||||
.br-group-toggle:hover { color: var(--text-secondary); }
|
||||
.br-group-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
.br-group-count {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
padding: 0 5px; border-radius: var(--radius-full);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.br-group-all {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-faint); padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.br-group-all:hover, .br-group-all.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.br-key-list { display: flex; flex-direction: column; padding-bottom: var(--sp-1); }
|
||||
.br-key-row {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 3px var(--sp-3) 3px 24px; cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.br-key-row:hover { background: var(--bg-raised); }
|
||||
.br-checkbox { accent-color: var(--accent); flex-shrink: 0; cursor: pointer; }
|
||||
.br-key-name {
|
||||
flex: 1; font-family: monospace; font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.br-key-val {
|
||||
font-family: monospace; font-size: 10px;
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
max-width: 52px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.br-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.br-footer-right { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.br-btn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px var(--sp-4); border-radius: var(--radius-md);
|
||||
cursor: pointer; border: 1px solid transparent;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
.br-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.br-btn.ghost { background: none; color: var(--text-faint); border-color: transparent; }
|
||||
.br-btn.ghost:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.br-btn.secondary { background: none; color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.br-btn.secondary:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.br-btn.primary { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.br-btn.primary:not(:disabled):hover { filter: brightness(1.12); }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte'
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from 'phosphor-svelte'
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot, Bug } from 'phosphor-svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
||||
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
||||
@@ -20,6 +20,7 @@
|
||||
import AboutSettings from './sections/AboutSettings.svelte'
|
||||
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
|
||||
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||
import BugReporter from './BugReporter.svelte'
|
||||
|
||||
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
|
||||
let { onclose, onOpenThemeEditor }: Props = $props()
|
||||
@@ -43,11 +44,12 @@
|
||||
]
|
||||
|
||||
const anims = $derived(settingsState.settings.qolAnimations ?? true)
|
||||
let tab: Tab = $state('general')
|
||||
let prevTabIndex = $state(0)
|
||||
let tabSlideDir = $state<'up'|'down'>('down')
|
||||
let tabIconKey = $state(0)
|
||||
let tab: Tab = $state('general')
|
||||
let prevTabIndex = $state(0)
|
||||
let tabSlideDir = $state<'up'|'down'>('down')
|
||||
let tabIconKey = $state(0)
|
||||
let contentBodyEl: HTMLDivElement
|
||||
let bugReporterOpen = $state(false)
|
||||
|
||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
||||
|
||||
@@ -66,7 +68,7 @@
|
||||
let listeningKey: keyof Keybinds | null = $state(null)
|
||||
|
||||
$effect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey) { e.stopPropagation(); close() } }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey && !bugReporterOpen) { e.stopPropagation(); close() } }
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
})
|
||||
@@ -134,6 +136,11 @@
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="s-sidebar-spacer"></div>
|
||||
<button class="s-nav-item s-bug-btn" class:anims onclick={() => (bugReporterOpen = true)}>
|
||||
<Bug size={14} weight="light" />
|
||||
<span>Report an Issue</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-content">
|
||||
@@ -189,4 +196,18 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if bugReporterOpen}
|
||||
<BugReporter onClose={() => (bugReporterOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.s-sidebar-spacer { flex: 1; }
|
||||
|
||||
.s-bug-btn { color: var(--text-faint) !important; }
|
||||
.s-bug-btn:hover {
|
||||
color: var(--color-error) !important;
|
||||
background: var(--color-error-bg) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import type { Settings } from '$lib/types/settings'
|
||||
|
||||
export type ReportType = 'bug' | 'feature'
|
||||
|
||||
export const SETTINGS_GROUPS: { label: string; keys: (keyof Settings)[] }[] = [
|
||||
{
|
||||
label: 'Library',
|
||||
keys: [
|
||||
'libraryStatsAlways', 'libraryCropCovers', 'libraryPageSize',
|
||||
'libraryBranches', 'libraryShowAllInSaved', 'libraryHideCompletedInSaved',
|
||||
'savedIsDefaultCategory', 'defaultLibraryCategoryId',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reader',
|
||||
keys: [
|
||||
'pageStyle', 'fitMode', 'readingDirection', 'readerZoom',
|
||||
'pageGap', 'optimizeContrast', 'offsetDoubleSpreads',
|
||||
'preloadPages', 'autoMarkRead', 'autoNextChapter',
|
||||
'markReadOnNext', 'autoBookmark', 'autoScroll', 'autoScrollSpeed',
|
||||
'readerDebounceMs', 'readerContainerized', 'barPosition',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Server',
|
||||
keys: ['serverUrl', 'autoStartServer', 'suwayomiWebUI', 'serverAuthMode'],
|
||||
},
|
||||
{
|
||||
label: 'Appearance',
|
||||
keys: ['theme', 'uiZoom', 'compactSidebar', 'gpuAcceleration', 'qolAnimations', 'systemThemeSync'],
|
||||
},
|
||||
{
|
||||
label: 'Downloads',
|
||||
keys: ['downloadToastsEnabled', 'downloadAutoRetry', 'storageLimitGb', 'serverDownloadsPath'],
|
||||
},
|
||||
{
|
||||
label: 'Content & Extensions',
|
||||
keys: ['contentLevel', 'sourceOverridesEnabled', 'preferredExtensionLang', 'flareSolverrEnabled', 'socksProxyEnabled'],
|
||||
},
|
||||
{
|
||||
label: 'Automation',
|
||||
keys: ['automationEnabled', 'automationEnforceGlobal'],
|
||||
},
|
||||
{
|
||||
label: 'Tracking',
|
||||
keys: ['trackerSyncBack', 'trackerSyncBackThreshold', 'trackerRespectScanlatorFilter'],
|
||||
},
|
||||
{
|
||||
label: 'Performance',
|
||||
keys: ['renderLimit', 'pinchZoom'],
|
||||
},
|
||||
]
|
||||
|
||||
const REDACTED = new Set<keyof Settings>([
|
||||
'serverAuthUser', 'serverAuthPass', 'appLockPin',
|
||||
'socksProxyUsername', 'socksProxyPassword',
|
||||
'keybinds', 'customThemes', 'heroSlots', 'mangaLinks', 'mangaPrefs',
|
||||
'libraryTabSort', 'libraryTabStatus', 'libraryTabFilters',
|
||||
'nsfwAllowedSourceIds', 'nsfwBlockedSourceIds',
|
||||
'mangaReaderSettings', 'readerPresets',
|
||||
'hiddenCategoryIds', 'libraryPinnedTabOrder', 'hiddenLibraryTabs',
|
||||
'pinnedSourceIds', 'extraScanDirs',
|
||||
])
|
||||
|
||||
function detectOs(): string {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes('Windows NT 10.0')) {
|
||||
const build = ua.match(/Windows NT 10\.0(?:\.(\d+))?/)
|
||||
return build ? `Windows 10/11 (NT ${build[0].replace('Windows ', '')})` : 'Windows 10/11'
|
||||
}
|
||||
if (ua.includes('Windows')) return 'Windows'
|
||||
if (ua.includes('Mac OS X')) {
|
||||
const v = ua.match(/Mac OS X ([\d_]+)/)
|
||||
return v ? `macOS ${v[1].replace(/_/g, '.')}` : 'macOS'
|
||||
}
|
||||
if (ua.includes('Linux')) return 'Linux'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function buildEnvironmentBlock(serverVersion?: string): string {
|
||||
const s = settingsState.settings
|
||||
const host = (() => { try { return new URL(s.serverUrl || '').host } catch { return s.serverUrl || 'unknown' } })()
|
||||
const serverLine = serverVersion
|
||||
? `- Server: Suwayomi ${serverVersion} (${host})`
|
||||
: `- Server: Suwayomi (${host})`
|
||||
return [
|
||||
`- Moku Version: ${appState.version || 'unknown'}`,
|
||||
`- Platform: ${appState.platform}`,
|
||||
`- OS: ${detectOs()}`,
|
||||
serverLine,
|
||||
`- Auth Mode: ${s.serverAuthMode}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildSettingsBlock(keys: (keyof Settings)[]): string {
|
||||
const s = settingsState.settings
|
||||
return keys
|
||||
.filter(k => !REDACTED.has(k))
|
||||
.map(k => {
|
||||
const v = s[k]
|
||||
if (v === undefined || v === null) return `${k}: ~`
|
||||
if (typeof v === 'string' && v === '') return `${k}: ""`
|
||||
return `${k}: ${JSON.stringify(v)}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export interface BugFields {
|
||||
description: string
|
||||
steps: string
|
||||
expected: string
|
||||
actual: string
|
||||
}
|
||||
|
||||
export interface FeatureFields {
|
||||
problem: string
|
||||
solution: string
|
||||
alternatives: string
|
||||
}
|
||||
|
||||
export function buildIssueUrl(
|
||||
type: ReportType,
|
||||
settingsBlock: string,
|
||||
title: string,
|
||||
fields: BugFields | FeatureFields,
|
||||
serverVersion?: string,
|
||||
): string {
|
||||
const base = 'https://github.com/moku-project/Moku/issues/new'
|
||||
|
||||
const common = {
|
||||
template: type === 'bug' ? 'bug_report.yml' : 'feature_request.yml',
|
||||
title,
|
||||
environment: buildEnvironmentBlock(serverVersion),
|
||||
}
|
||||
|
||||
const specific = type === 'bug'
|
||||
? {
|
||||
description: (fields as BugFields).description,
|
||||
steps: (fields as BugFields).steps,
|
||||
expected: (fields as BugFields).expected,
|
||||
actual: (fields as BugFields).actual,
|
||||
settings: settingsBlock,
|
||||
}
|
||||
: {
|
||||
problem: (fields as FeatureFields).problem,
|
||||
solution: (fields as FeatureFields).solution,
|
||||
alternatives: (fields as FeatureFields).alternatives,
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ ...common, ...specific })
|
||||
return `${base}?${params.toString()}`
|
||||
}
|
||||
@@ -49,23 +49,27 @@ function buildPresence(manga: Manga, chapter: Chapter, coverUrl: string) {
|
||||
|
||||
export async function initRpc(): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
if (!settingsState.settings.discordRpc) return
|
||||
sessionStart = Date.now()
|
||||
}
|
||||
|
||||
export async function destroyRpc(): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
sessionStart = null
|
||||
sessionStart = null
|
||||
activeMangaId = null
|
||||
await platformService.clearDiscordPresence()
|
||||
}
|
||||
|
||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
|
||||
if (!settingsState.settings.discordRpc) return
|
||||
activeMangaId = manga.id
|
||||
await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga)))
|
||||
}
|
||||
|
||||
export async function setIdle(): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
if (!settingsState.settings.discordRpc) return
|
||||
await platformService.setDiscordPresence({
|
||||
details: 'Browsing',
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
@@ -76,5 +80,6 @@ export async function setIdle(): Promise<void> {
|
||||
|
||||
export async function clearReading(): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
if (!settingsState.settings.discordRpc) return
|
||||
await platformService.clearDiscordPresence()
|
||||
}
|
||||
@@ -105,11 +105,6 @@
|
||||
isTauri && settingsState.settings.autoStartServer ? 2000 : 100,
|
||||
)
|
||||
|
||||
if (settingsState.settings.discordRpc) {
|
||||
await discord.initRpc()
|
||||
await discord.setIdle()
|
||||
}
|
||||
|
||||
polling = true
|
||||
pollLoop()
|
||||
|
||||
@@ -142,6 +137,14 @@
|
||||
if (appState.status === 'booting') splashDismissed = false
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (settingsState.settings.discordRpc) {
|
||||
discord.initRpc().then(() => discord.setIdle())
|
||||
} else {
|
||||
discord.destroyRpc()
|
||||
}
|
||||
})
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleDismissLock = false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user