mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Flatpak KCEF Fix & Extension Management
This commit is contained in:
+112
-9
@@ -1,6 +1,6 @@
|
|||||||
app-id: dev.moku.app
|
app-id: dev.moku.app
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '47'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
sdk-extensions:
|
sdk-extensions:
|
||||||
- org.freedesktop.Sdk.Extension.rust-stable
|
- org.freedesktop.Sdk.Extension.rust-stable
|
||||||
@@ -9,10 +9,14 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
|
- --socket=x11
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
|
- --socket=session-bus
|
||||||
|
- --socket=system-bus
|
||||||
|
- --filesystem=home
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --talk-name=org.freedesktop.Flatpak
|
||||||
|
|
||||||
@@ -20,10 +24,8 @@ build-options:
|
|||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
env:
|
env:
|
||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
RUSTFLAGS: ''
|
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
|
|
||||||
- name: openjdk
|
- name: openjdk
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -35,17 +37,122 @@ modules:
|
|||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||||
dest-filename: jdk.tar.gz
|
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 <stdio.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <execinfo.h>
|
||||||
|
|
||||||
|
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
|
- name: tachidesk-server
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
- mkdir -p /app/tachidesk /app/bin
|
- mkdir -p /app/tachidesk /app/bin /app/tachidesk/default-conf
|
||||||
- cp Suwayomi-Server.jar /app/tachidesk/
|
- 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'
|
cat > /app/bin/tachidesk-server << 'EOF'
|
||||||
#!/bin/sh
|
#!/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
|
EOF
|
||||||
|
|
||||||
- chmod +x /app/bin/tachidesk-server
|
- chmod +x /app/bin/tachidesk-server
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
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
|
- name: moku
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
env:
|
env:
|
||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
||||||
|
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- 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
|
- . /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.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 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
|
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: dir
|
||||||
path: .
|
path: .
|
||||||
@@ -85,6 +189,5 @@ modules:
|
|||||||
contents: |
|
contents: |
|
||||||
[source.crates-io]
|
[source.crates-io]
|
||||||
replace-with = "vendored-sources"
|
replace-with = "vendored-sources"
|
||||||
|
|
||||||
[source.vendored-sources]
|
[source.vendored-sources]
|
||||||
directory = "/run/build/moku/cargo/vendor"
|
directory = "/run/build/moku/cargo/vendor"
|
||||||
@@ -18,23 +18,53 @@
|
|||||||
}
|
}
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.iconBtn:disabled { opacity: 0.4; }
|
.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 {
|
.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 {
|
.externalInput {
|
||||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
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: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 {
|
.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);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
padding: 6px 14px; border-radius: var(--radius-md);
|
padding: 6px 14px; border-radius: var(--radius-md);
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
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 {
|
.controls {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
@@ -170,3 +200,79 @@
|
|||||||
flex: 1; color: var(--text-faint);
|
flex: 1; color: var(--text-faint);
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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; }
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
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 { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
||||||
|
GET_SETTINGS, SET_EXTENSION_REPOS,
|
||||||
} from "../../lib/queries";
|
} from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Extension } from "../../lib/types";
|
import type { Extension } from "../../lib/types";
|
||||||
import s from "./ExtensionList.module.css";
|
import s from "./ExtensionList.module.css";
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
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 {
|
function baseName(name: string): string {
|
||||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,7 @@ function baseName(name: string): string {
|
|||||||
interface ExtGroup {
|
interface ExtGroup {
|
||||||
base: string;
|
base: string;
|
||||||
primary: Extension;
|
primary: Extension;
|
||||||
variants: Extension[]; // all variants excluding primary
|
variants: Extension[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExtensionList() {
|
export default function ExtensionList() {
|
||||||
@@ -29,8 +30,21 @@ export default function ExtensionList() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
const [working, setWorking] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [externalUrl, setExternalUrl] = useState("");
|
const [panel, setPanel] = useState<Panel>(null);
|
||||||
const [showExternal, setShowExternal] = useState(false);
|
|
||||||
|
// APK install state
|
||||||
|
const [externalUrl, setExternalUrl] = useState("");
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
|
const [installSuccess, setInstallSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Repo management state
|
||||||
|
const [repos, setRepos] = useState<string[]>([]);
|
||||||
|
const [reposLoading, setReposLoading] = useState(false);
|
||||||
|
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||||
|
const [repoError, setRepoError] = useState<string | null>(null);
|
||||||
|
const [savingRepos, setSavingRepos] = useState(false);
|
||||||
|
|
||||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -47,6 +61,52 @@ export default function ExtensionList() {
|
|||||||
.finally(() => setRefreshing(false));
|
.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<unknown>, pkgName: string) => {
|
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
||||||
setWorking((p) => new Set(p).add(pkgName));
|
setWorking((p) => new Set(p).add(pkgName));
|
||||||
await fn().catch(console.error);
|
await fn().catch(console.error);
|
||||||
@@ -55,11 +115,47 @@ export default function ExtensionList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function installExternal() {
|
async function installExternal() {
|
||||||
if (!externalUrl.trim()) return;
|
const url = externalUrl.trim();
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url: externalUrl.trim() }).catch(console.error);
|
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("");
|
setExternalUrl("");
|
||||||
setShowExternal(false);
|
setRepoError(null);
|
||||||
await load();
|
setNewRepoUrl("");
|
||||||
|
if (p === "repos") loadRepos();
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,8 +172,6 @@ export default function ExtensionList() {
|
|||||||
return matchSearch && matchFilter;
|
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<ExtGroup[]>(() => {
|
const groups = useMemo<ExtGroup[]>(() => {
|
||||||
const map = new Map<string, Extension[]>();
|
const map = new Map<string, Extension[]>();
|
||||||
for (const ext of filtered) {
|
for (const ext of filtered) {
|
||||||
@@ -131,7 +225,14 @@ export default function ExtensionList() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Extensions</h1>
|
<h1 className={s.heading}>Extensions</h1>
|
||||||
<div className={s.headerActions}>
|
<div className={s.headerActions}>
|
||||||
<button className={s.iconBtn} onClick={() => setShowExternal(!showExternal)} title="Install from URL">
|
<button
|
||||||
|
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => openPanel("repos")} title="Manage repos">
|
||||||
|
<GitBranch size={14} weight="light" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => openPanel("apk")} title="Install from URL">
|
||||||
<Plus size={14} weight="light" />
|
<Plus size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||||
@@ -140,12 +241,97 @@ export default function ExtensionList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showExternal && (
|
{/* ── APK install panel ── */}
|
||||||
<div className={s.externalRow}>
|
{panel === "apk" && (
|
||||||
<input className={s.externalInput} placeholder="APK URL"
|
<div className={s.externalPanel}>
|
||||||
value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)}
|
<div className={s.panelHeader}>
|
||||||
onKeyDown={(e) => e.key === "Enter" && installExternal()} autoFocus />
|
<span className={s.panelTitle}>Install from APK URL</span>
|
||||||
<button className={s.installBtn} onClick={installExternal}>Install</button>
|
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
<div className={s.externalRow}>
|
||||||
|
<input
|
||||||
|
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
||||||
|
placeholder="https://example.com/extension.apk"
|
||||||
|
value={externalUrl}
|
||||||
|
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||||
|
autoFocus
|
||||||
|
disabled={installing}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
||||||
|
onClick={installExternal}
|
||||||
|
disabled={installing || !externalUrl.trim()}
|
||||||
|
>
|
||||||
|
{installing
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
|
: installSuccess
|
||||||
|
? <><Check size={13} weight="bold" /> Done</>
|
||||||
|
: "Install"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{installError && <div className={s.panelError}>{installError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Repo management panel ── */}
|
||||||
|
{panel === "repos" && (
|
||||||
|
<div className={s.externalPanel}>
|
||||||
|
<div className={s.panelHeader}>
|
||||||
|
<span className={s.panelTitle}>Extension Repositories</span>
|
||||||
|
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reposLoading ? (
|
||||||
|
<div className={s.repoLoading}>
|
||||||
|
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{repos.length === 0 ? (
|
||||||
|
<div className={s.repoEmpty}>No repos configured.</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.repoList}>
|
||||||
|
{repos.map((url) => (
|
||||||
|
<div key={url} className={s.repoRow}>
|
||||||
|
<span className={s.repoUrl}>{url}</span>
|
||||||
|
<button
|
||||||
|
className={s.repoRemoveBtn}
|
||||||
|
onClick={() => removeRepo(url)}
|
||||||
|
disabled={savingRepos}
|
||||||
|
title="Remove repo"
|
||||||
|
>
|
||||||
|
{savingRepos
|
||||||
|
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
||||||
|
: <X size={12} weight="bold" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
||||||
|
<input
|
||||||
|
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
||||||
|
placeholder="https://example.com/index.min.json"
|
||||||
|
value={newRepoUrl}
|
||||||
|
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||||
|
disabled={savingRepos}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={s.installBtn}
|
||||||
|
onClick={addRepo}
|
||||||
|
disabled={savingRepos || !newRepoUrl.trim()}
|
||||||
|
>
|
||||||
|
{savingRepos
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
|
: "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{repoError && <div className={s.panelError}>{repoError}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -176,7 +362,6 @@ export default function ExtensionList() {
|
|||||||
{groups.map(({ base, primary, variants }) => {
|
{groups.map(({ base, primary, variants }) => {
|
||||||
const isExpanded = expanded.has(base);
|
const isExpanded = expanded.has(base);
|
||||||
const hasVariants = variants.length > 0;
|
const hasVariants = variants.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={base} className={s.group}>
|
<div key={base} className={s.group}>
|
||||||
<div className={s.row}>
|
<div className={s.row}>
|
||||||
@@ -194,14 +379,11 @@ export default function ExtensionList() {
|
|||||||
{hasVariants && (
|
{hasVariants && (
|
||||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
||||||
title={`${variants.length + 1} languages`}>
|
title={`${variants.length + 1} languages`}>
|
||||||
{isExpanded
|
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
||||||
? <CaretDown size={12} weight="light" />
|
|
||||||
: <CaretRight size={12} weight="light" />}
|
|
||||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
<span className={s.expandCount}>{variants.length + 1}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && hasVariants && (
|
{isExpanded && hasVariants && (
|
||||||
<div className={s.variants}>
|
<div className={s.variants}>
|
||||||
{variants.map((v) => (
|
{variants.map((v) => (
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ const DEFAULT_URL = "http://127.0.0.1:4567";
|
|||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
// Read from persisted Zustand store if available, fall back to default
|
// Read from persisted Zustand store if available, fall back to default
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("moku-settings");
|
const raw = localStorage.getItem("moku-store");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const url = parsed?.state?.settings?.serverUrl;
|
const url = parsed?.state?.settings?.serverUrl;
|
||||||
|
|||||||
@@ -400,3 +400,22 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
+1
-1
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**", '**/build-dir/**', '**/repo/**', '**/.flatpak-builder/**'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user