From 7ed7ec0ea37edcd565482c7895c132aed5a80335 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Sun, 22 Feb 2026 15:58:29 -0600 Subject: [PATCH] [V1] Flatpak KCEF Fix & Extension Management --- dev.moku.app.yml | 121 +++++++++- .../extensions/ExtensionList.module.css | 114 ++++++++- src/components/extensions/ExtensionList.tsx | 228 ++++++++++++++++-- src/lib/client.ts | 2 +- src/lib/queries.ts | 19 ++ vite.config.ts | 2 +- 6 files changed, 448 insertions(+), 38 deletions(-) diff --git a/dev.moku.app.yml b/dev.moku.app.yml index 6aa7db9..8f15b8b 100644 --- a/dev.moku.app.yml +++ b/dev.moku.app.yml @@ -1,6 +1,6 @@ app-id: dev.moku.app runtime: org.gnome.Platform -runtime-version: '47' +runtime-version: '48' sdk: org.gnome.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable @@ -9,10 +9,14 @@ separate-locales: false finish-args: - --socket=wayland + - --socket=x11 - --socket=fallback-x11 - --share=ipc - --device=dri - --share=network + - --socket=session-bus + - --socket=system-bus + - --filesystem=home - --filesystem=xdg-data/moku:create - --talk-name=org.freedesktop.Flatpak @@ -20,10 +24,8 @@ build-options: append-path: /usr/lib/sdk/rust-stable/bin env: CARGO_HOME: /run/build/moku/cargo - RUSTFLAGS: '' modules: - - name: openjdk buildsystem: simple build-commands: @@ -35,17 +37,122 @@ modules: sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d dest-filename: jdk.tar.gz + # catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and + # exits just that thread instead of killing the whole JVM. Official Suwayomi + # fix for headless environments. Source inlined to avoid upstream drift. + - name: catch-abort + buildsystem: simple + build-commands: + - mkdir -p /app/lib + - gcc -shared -fPIC -o /app/lib/catch_abort.so catch_abort.c -ldl -lpthread + sources: + - type: inline + dest-filename: catch_abort.c + contents: | + // Linux only: + // Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process + + #define _GNU_SOURCE + #include + #include + #include + #include + #include + + void signalHandler(int signum, siginfo_t* si, void* uc) { + void *retaddrs[64]; + int n = backtrace(retaddrs, sizeof(retaddrs) / sizeof(retaddrs[0])); + printf("\n### ABORT :: Backtrace: ###\n"); + backtrace_symbols_fd(retaddrs, n, STDERR_FILENO); + printf("### ABORT :: Exiting this thread. If this causes problems, please report the above backtrace to Suwayomi. ###\n\n"); + pthread_exit(NULL); + } + + __attribute__((constructor)) + void dlmain() { + struct sigaction sa = {0}; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sa.sa_sigaction = &signalHandler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTRAP, &sa, NULL) != 0) { + printf("[FATAL] sigaction failed\n"); + } + if (sigaction(SIGILL, &sa, NULL) != 0) { + printf("[FATAL] sigaction failed\n"); + } + } + - name: tachidesk-server buildsystem: simple build-commands: - - mkdir -p /app/tachidesk /app/bin + - mkdir -p /app/tachidesk /app/bin /app/tachidesk/default-conf - cp Suwayomi-Server.jar /app/tachidesk/ + + - | + cat > /app/tachidesk/default-conf/server.conf << 'EOF' + server.ip = "127.0.0.1" + server.port = 4567 + server.webUIEnabled = false + server.initialOpenInBrowserEnabled = false + server.systemTrayEnabled = false + server.webUIInterface = "browser" + server.webUIFlavor = "WebUI" + server.webUIChannel = "stable" + server.electronPath = "" + server.debugLogsEnabled = false + server.downloadAsCbz = true + server.autoDownloadNewChapters = false + server.globalUpdateInterval = 12 + server.maxSourcesInParallel = 6 + server.extensionRepos = [] + EOF + - | cat > /app/bin/tachidesk-server << 'EOF' #!/bin/sh - exec /app/jre/bin/java -jar /app/tachidesk/Suwayomi-Server.jar "$@" + + DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" + mkdir -p "$DATA_DIR" + + # Seed conf on first run + if [ ! -f "$DATA_DIR/server.conf" ]; then + cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf" + fi + + # Force-patch the three keys that cause JCEF/GUI crashes every launch. + # Suwayomi ignores -D JVM flags when a conf file exists on disk. + sed -i \ + -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ + -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ + -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ + "$DATA_DIR/server.conf" + + # Append keys if absent + grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" + grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" + grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" + + unset DISPLAY + unset WAYLAND_DISPLAY + + export _JAVA_OPTIONS="-Djava.awt.headless=true" + export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" + + # Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just + # that thread instead of crashing the whole JVM process. + export LD_PRELOAD="/app/lib/catch_abort.so" + + exec /app/jre/bin/java \ + -Djava.awt.headless=true \ + -Dapple.awt.UIElement=true \ + -Dsun.java2d.noddraw=true \ + -Dsun.awt.disablegui=true \ + -Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \ + -jar /app/tachidesk/Suwayomi-Server.jar EOF + - chmod +x /app/bin/tachidesk-server + sources: - type: file url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar @@ -54,14 +161,12 @@ modules: - name: moku buildsystem: simple - build-options: env: CARGO_HOME: /run/build/moku/cargo XDG_DATA_HOME: /run/build/moku/xdg-data TAURI_SKIP_DEVSERVER_CHECK: 'true' PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig - build-commands: - tar -xzf frontend-dist.tar.gz - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml @@ -71,7 +176,6 @@ modules: - install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png - install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml - sources: - type: dir path: . @@ -85,6 +189,5 @@ modules: contents: | [source.crates-io] replace-with = "vendored-sources" - [source.vendored-sources] directory = "/run/build/moku/cargo/vendor" \ No newline at end of file diff --git a/src/components/extensions/ExtensionList.module.css b/src/components/extensions/ExtensionList.module.css index eb52a43..0755073 100644 --- a/src/components/extensions/ExtensionList.module.css +++ b/src/components/extensions/ExtensionList.module.css @@ -18,23 +18,53 @@ } .iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .iconBtn:disabled { opacity: 0.4; } +.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); } +.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); } +.externalPanel { + display: flex; flex-direction: column; gap: var(--sp-2); + padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; + animation: fadeIn 0.1s ease both; +} +.externalHeader { + display: flex; align-items: center; justify-content: space-between; +} +.externalTitle { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-muted); letter-spacing: var(--tracking-wide); +} .externalRow { - display: flex; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; + display: flex; gap: var(--sp-2); } .externalInput { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; + transition: border-color var(--t-base); } .externalInput:focus { border-color: var(--border-focus); } +.externalInput:disabled { opacity: 0.5; } +.externalInputError { border-color: var(--color-error) !important; } +.externalError { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--color-error); letter-spacing: var(--tracking-wide); + padding: 0 2px; +} .installBtn { + 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 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); - border: 1px solid var(--accent-dim); cursor: pointer; + border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; + transition: filter var(--t-base), opacity var(--t-base); + white-space: nowrap; +} +.installBtn:hover:not(:disabled) { filter: brightness(1.1); } +.installBtn:disabled { opacity: 0.5; cursor: default; } +.installBtnSuccess { + background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f); + color: #fff; } -.installBtn:hover { filter: brightness(1.1); } .controls { display: flex; align-items: center; justify-content: space-between; @@ -169,4 +199,80 @@ display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); -} \ No newline at end of file +} + +/* ── Panel shared styles ── */ +.externalPanel { + display: flex; flex-direction: column; gap: var(--sp-2); + padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; + animation: fadeIn 0.1s ease both; +} +.panelHeader { + display: flex; align-items: center; justify-content: space-between; +} +.panelTitle { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-muted); letter-spacing: var(--tracking-wide); +} +.panelError { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--color-error); letter-spacing: var(--tracking-wide); + padding: 0 2px; +} +.externalRow { display: flex; gap: var(--sp-2); } +.externalInput { + flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); + border-radius: var(--radius-md); padding: 6px var(--sp-3); + color: var(--text-primary); font-size: var(--text-sm); outline: none; + transition: border-color var(--t-base); +} +.externalInput:focus { border-color: var(--border-focus); } +.externalInput:disabled { opacity: 0.5; } +.externalInputError { border-color: var(--color-error) !important; } +.installBtn { + 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 14px; border-radius: var(--radius-md); + background: var(--accent-muted); color: var(--accent-fg); + border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; + transition: filter var(--t-base), opacity var(--t-base); + white-space: nowrap; +} +.installBtn:hover:not(:disabled) { filter: brightness(1.1); } +.installBtn:disabled { opacity: 0.5; cursor: default; } +.installBtnSuccess { + background: color-mix(in srgb, var(--accent-fg) 20%, transparent); + border-color: var(--accent-fg); color: var(--accent-fg); +} + +/* ── Repo list ── */ +.repoLoading { + display: flex; align-items: center; justify-content: center; + padding: var(--sp-3); +} +.repoEmpty { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); + padding: var(--sp-1) 2px; +} +.repoList { + display: flex; flex-direction: column; gap: 2px; +} +.repoRow { + display: flex; align-items: center; gap: var(--sp-2); + padding: 5px var(--sp-2); border-radius: var(--radius-md); + background: var(--bg-raised); border: 1px solid var(--border-dim); +} +.repoUrl { + flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs); + color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + letter-spacing: 0; +} +.repoRemoveBtn { + display: flex; align-items: center; justify-content: center; + width: 20px; height: 20px; border-radius: var(--radius-sm); + color: var(--text-faint); flex-shrink: 0; + transition: color var(--t-base), background var(--t-base); +} +.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); } +.repoRemoveBtn:disabled { opacity: 0.4; } \ No newline at end of file diff --git a/src/components/extensions/ExtensionList.tsx b/src/components/extensions/ExtensionList.tsx index 721ec97..588ff41 100644 --- a/src/components/extensions/ExtensionList.tsx +++ b/src/components/extensions/ExtensionList.tsx @@ -1,16 +1,17 @@ import { useEffect, useState, useMemo } from "react"; -import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown } from "@phosphor-icons/react"; +import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, + GET_SETTINGS, SET_EXTENSION_REPOS, } from "../../lib/queries"; import { useStore } from "../../store"; import type { Extension } from "../../lib/types"; import s from "./ExtensionList.module.css"; type Filter = "installed" | "available" | "updates" | "all"; +type Panel = null | "apk" | "repos"; -// Strip language tag suffix e.g. "MangaDex (EN)" → "MangaDex" function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); } @@ -18,7 +19,7 @@ function baseName(name: string): string { interface ExtGroup { base: string; primary: Extension; - variants: Extension[]; // all variants excluding primary + variants: Extension[]; } export default function ExtensionList() { @@ -29,8 +30,21 @@ export default function ExtensionList() { const [search, setSearch] = useState(""); const [working, setWorking] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); - const [externalUrl, setExternalUrl] = useState(""); - const [showExternal, setShowExternal] = useState(false); + const [panel, setPanel] = useState(null); + + // APK install state + const [externalUrl, setExternalUrl] = useState(""); + const [installing, setInstalling] = useState(false); + const [installError, setInstallError] = useState(null); + const [installSuccess, setInstallSuccess] = useState(false); + + // Repo management state + const [repos, setRepos] = useState([]); + const [reposLoading, setReposLoading] = useState(false); + const [newRepoUrl, setNewRepoUrl] = useState(""); + const [repoError, setRepoError] = useState(null); + const [savingRepos, setSavingRepos] = useState(false); + const preferredLang = useStore((s) => s.settings.preferredExtensionLang); async function load() { @@ -47,6 +61,52 @@ export default function ExtensionList() { .finally(() => setRefreshing(false)); } + async function loadRepos() { + setReposLoading(true); + try { + const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); + setRepos(d.settings.extensionRepos ?? []); + } catch (e) { + console.error(e); + } finally { + setReposLoading(false); + } + } + + async function saveRepos(updated: string[]) { + setSavingRepos(true); + try { + const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>( + SET_EXTENSION_REPOS, { repos: updated } + ); + setRepos(d.setSettings.settings.extensionRepos); + } catch (e: unknown) { + setRepoError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSavingRepos(false); + } + } + + function addRepo() { + const url = newRepoUrl.trim(); + if (!url) return; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + setRepoError("URL must start with http:// or https://"); + return; + } + if (repos.includes(url)) { + setRepoError("Repo already added"); + return; + } + setRepoError(null); + setNewRepoUrl(""); + saveRepos([...repos, url]); + } + + function removeRepo(url: string) { + saveRepos(repos.filter((r) => r !== url)); + } + const mutate = async (fn: () => Promise, pkgName: string) => { setWorking((p) => new Set(p).add(pkgName)); await fn().catch(console.error); @@ -55,11 +115,47 @@ export default function ExtensionList() { }; async function installExternal() { - if (!externalUrl.trim()) return; - await gql(INSTALL_EXTERNAL_EXTENSION, { url: externalUrl.trim() }).catch(console.error); + const url = externalUrl.trim(); + if (!url) return; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + setInstallError("URL must start with http:// or https://"); + return; + } + if (!url.endsWith(".apk")) { + setInstallError("URL must point to an .apk file"); + return; + } + setInstalling(true); + setInstallError(null); + setInstallSuccess(false); + try { + await gql(INSTALL_EXTERNAL_EXTENSION, { url }); + setInstallSuccess(true); + setExternalUrl(""); + await load(); + setTimeout(() => { + setPanel(null); + setInstallSuccess(false); + }, 1500); + } catch (e: unknown) { + setInstallError(e instanceof Error ? e.message : "Install failed"); + } finally { + setInstalling(false); + } + } + + function openPanel(p: Panel) { + if (panel === p) { + setPanel(null); + return; + } + setPanel(p); + setInstallError(null); + setInstallSuccess(false); setExternalUrl(""); - setShowExternal(false); - await load(); + setRepoError(null); + setNewRepoUrl(""); + if (p === "repos") loadRepos(); } useEffect(() => { @@ -76,8 +172,6 @@ export default function ExtensionList() { return matchSearch && matchFilter; }); - // Group by base name. Primary is the preferred/en/first variant. - // variants contains only the non-primary ones for the expanded list. const groups = useMemo(() => { const map = new Map(); for (const ext of filtered) { @@ -131,7 +225,14 @@ export default function ExtensionList() {

Extensions

- +
- {showExternal && ( -
- setExternalUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && installExternal()} autoFocus /> - + {/* ── APK install panel ── */} + {panel === "apk" && ( +
+
+ Install from APK URL + +
+
+ { setExternalUrl(e.target.value); setInstallError(null); }} + onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()} + autoFocus + disabled={installing} + /> + +
+ {installError &&
{installError}
} +
+ )} + + {/* ── Repo management panel ── */} + {panel === "repos" && ( +
+
+ Extension Repositories + +
+ + {reposLoading ? ( +
+ +
+ ) : ( + <> + {repos.length === 0 ? ( +
No repos configured.
+ ) : ( +
+ {repos.map((url) => ( +
+ {url} + +
+ ))} +
+ )} + +
+ { setNewRepoUrl(e.target.value); setRepoError(null); }} + onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()} + disabled={savingRepos} + /> + +
+ {repoError &&
{repoError}
} + + )}
)} @@ -176,7 +362,6 @@ export default function ExtensionList() { {groups.map(({ base, primary, variants }) => { const isExpanded = expanded.has(base); const hasVariants = variants.length > 0; - return (
@@ -194,14 +379,11 @@ export default function ExtensionList() { {hasVariants && ( )}
- {isExpanded && hasVariants && (
{variants.map((v) => ( diff --git a/src/lib/client.ts b/src/lib/client.ts index 03d0980..97f1b88 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -3,7 +3,7 @@ const DEFAULT_URL = "http://127.0.0.1:4567"; function getServerUrl(): string { // Read from persisted Zustand store if available, fall back to default try { - const raw = localStorage.getItem("moku-settings"); + const raw = localStorage.getItem("moku-store"); if (raw) { const parsed = JSON.parse(raw); const url = parsed?.state?.settings?.serverUrl; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 68b5110..8ad9732 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -399,4 +399,23 @@ export const INSTALL_EXTERNAL_EXTENSION = ` } } } +`; +// ── Settings ────────────────────────────────────────────────────────────────── + +export const GET_SETTINGS = ` + query GetSettings { + settings { + extensionRepos + } + } +`; + +export const SET_EXTENSION_REPOS = ` + mutation SetExtensionRepos($repos: [String!]!) { + setSettings(input: { settings: { extensionRepos: $repos } }) { + settings { + extensionRepos + } + } + } `; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index e5c1b33..68f7899 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ port: 1420, strictPort: true, watch: { - ignored: ["**/src-tauri/**"], + ignored: ["**/src-tauri/**", '**/build-dir/**', '**/repo/**', '**/.flatpak-builder/**'], }, }, }); \ No newline at end of file