feat: advance and highlight next AI suggestion on accept/deny/copy-to-note, remove copied suggestion from inline list, and add native Linux .deb packaging
This commit is contained in:
@@ -21,6 +21,7 @@ data/
|
|||||||
|
|
||||||
# Tauri / desktop
|
# Tauri / desktop
|
||||||
desktop/target/
|
desktop/target/
|
||||||
|
*.deb
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -910,6 +910,32 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
setFocusedIndex(0);
|
setFocusedIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const advanceAndHighlightNext = (
|
||||||
|
removedId: string,
|
||||||
|
updatedAll: AISuggestion[],
|
||||||
|
) => {
|
||||||
|
const prevIdx = pendingSuggestions.findIndex((item) => item.id === removedId);
|
||||||
|
const nextPending = updatedAll.filter((item) => item.status === "pending");
|
||||||
|
if (nextPending.length === 0) {
|
||||||
|
setFocusedIndex(0);
|
||||||
|
setHighlightedLine(null);
|
||||||
|
setHighlightedText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIdx = Math.min(
|
||||||
|
prevIdx >= 0 ? prevIdx : focusedIndex,
|
||||||
|
nextPending.length - 1,
|
||||||
|
);
|
||||||
|
const nextSg = nextPending[nextIdx]!;
|
||||||
|
setFocusedIndex(nextIdx);
|
||||||
|
if (nextSg.originalText) {
|
||||||
|
setHighlightedText(nextSg.originalText);
|
||||||
|
}
|
||||||
|
if (nextSg.lineNumber != null) {
|
||||||
|
setHighlightedLine(nextSg.lineNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
||||||
if (!aiStore || !activeSessionId) return;
|
if (!aiStore || !activeSessionId) return;
|
||||||
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
||||||
@@ -950,6 +976,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
|
|
||||||
const nextList = await aiStore.listSuggestions(activeSessionId);
|
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||||
setSuggestions(nextList);
|
setSuggestions(nextList);
|
||||||
|
advanceAndHighlightNext(sg.id, nextList);
|
||||||
|
|
||||||
if (applied) {
|
if (applied) {
|
||||||
toast.success(`Accepted granular edit: "${replacement.slice(0, 36)}${replacement.length > 36 ? "…" : ""}"`);
|
toast.success(`Accepted granular edit: "${replacement.slice(0, 36)}${replacement.length > 36 ? "…" : ""}"`);
|
||||||
@@ -968,10 +995,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
}
|
}
|
||||||
const nextList = await aiStore.listSuggestions(activeSessionId);
|
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||||
setSuggestions(nextList);
|
setSuggestions(nextList);
|
||||||
if (highlightedText === sg.originalText || highlightedLine === sg.lineNumber) {
|
advanceAndHighlightNext(sg.id, nextList);
|
||||||
setHighlightedLine(null);
|
|
||||||
setHighlightedText(null);
|
|
||||||
}
|
|
||||||
toast.info(`Denied suggestion: ${sg.title}`);
|
toast.info(`Denied suggestion: ${sg.title}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -995,7 +1019,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
});
|
});
|
||||||
setNotes(await aiStore.listNotes(activeSessionId));
|
setNotes(await aiStore.listNotes(activeSessionId));
|
||||||
toast.success(`Copied "${sg.title}" to Notes`);
|
|
||||||
|
// Remove the suggestion from the inline edits list and advance to the next suggestion
|
||||||
|
setSuggestions((prev) => prev.filter((item) => item.id !== sg.id));
|
||||||
|
if (typeof aiStore.deleteSuggestion === "function") {
|
||||||
|
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||||
|
} else {
|
||||||
|
await aiStore.resolveSuggestion(activeSessionId, sg.id, false);
|
||||||
|
}
|
||||||
|
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||||
|
setSuggestions(nextList);
|
||||||
|
advanceAndHighlightNext(sg.id, nextList);
|
||||||
|
|
||||||
|
toast.success(`Moved "${sg.title}" to Notes`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNote = async () => {
|
const handleAddNote = async () => {
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ describe("AISidebar + WritingView granular text-pegged suggestion flow", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 120));
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After accepting Suggestion #1, the next suggestion (#2) must be automatically highlighted
|
||||||
|
expect(useProjectStore.getState().highlightedText).toContain(
|
||||||
|
"the light had turned the sea the color of a bruised plum",
|
||||||
|
);
|
||||||
|
|
||||||
// Accept Suggestion #2 (clause 2 of the SAME paragraph 2)
|
// Accept Suggestion #2 (clause 2 of the SAME paragraph 2)
|
||||||
const acceptBtn2 = Array.from(container.querySelectorAll("button")).find((b) =>
|
const acceptBtn2 = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||||
b.textContent?.includes("Accept"),
|
b.textContent?.includes("Accept"),
|
||||||
@@ -150,6 +155,25 @@ describe("AISidebar + WritingView granular text-pegged suggestion flow", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 120));
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After accepting Suggestion #2, Suggestion #3 (on the next paragraph) must be automatically highlighted
|
||||||
|
expect(useProjectStore.getState().highlightedText).toContain(
|
||||||
|
"She pressed her palm against the cold window",
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ps-editor")?.getAttribute("data-highlight-line")).toBe("3");
|
||||||
|
|
||||||
|
// Clicking "Copy to Note" on Suggestion #3 removes it from the inline suggestions list
|
||||||
|
const beforeCards = container.querySelectorAll(".ps-ai-suggestion-card").length;
|
||||||
|
const copyToNoteBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||||
|
b.textContent?.includes("Copy to Note"),
|
||||||
|
);
|
||||||
|
expect(copyToNoteBtn).toBeDefined();
|
||||||
|
await act(async () => {
|
||||||
|
copyToNoteBtn!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
});
|
||||||
|
const afterCards = container.querySelectorAll(".ps-ai-suggestion-card").length;
|
||||||
|
expect(afterCards).toBe(beforeCards - 1);
|
||||||
|
|
||||||
// Verify BOTH granular clauses in paragraph 2 were updated AND the rest of the paragraph was NOT deleted!
|
// Verify BOTH granular clauses in paragraph 2 were updated AND the rest of the paragraph was NOT deleted!
|
||||||
const pmText = container.querySelector(".ProseMirror")?.textContent ?? "";
|
const pmText = container.querySelector(".ProseMirror")?.textContent ?? "";
|
||||||
expect(pmText).toContain("The ferry that carried Mara Vell to the Glass Harbor cleared the mainland before dawn");
|
expect(pmText).toContain("The ferry that carried Mara Vell to the Glass Harbor cleared the mainland before dawn");
|
||||||
|
|||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
PKG_NAME="pubscript"
|
||||||
|
PKG_VERSION="0.1.0"
|
||||||
|
ARCH="amd64"
|
||||||
|
DEB_NAME="${PKG_NAME}_${PKG_VERSION}_${ARCH}.deb"
|
||||||
|
|
||||||
|
STAGE_ROOT="/tmp/opencode/pubscript-deb-stage"
|
||||||
|
STAGE_DIR="${STAGE_ROOT}/${PKG_NAME}_${PKG_VERSION}_${ARCH}"
|
||||||
|
|
||||||
|
echo "==> [1/6] Building production web bundle (apps/web/dist)..."
|
||||||
|
pnpm --filter @pubscript/web run build
|
||||||
|
|
||||||
|
echo "==> [2/6] Preparing Debian package staging tree at ${STAGE_DIR}..."
|
||||||
|
rm -rf "$STAGE_DIR"
|
||||||
|
mkdir -p \
|
||||||
|
"$STAGE_DIR/DEBIAN" \
|
||||||
|
"$STAGE_DIR/usr/bin" \
|
||||||
|
"$STAGE_DIR/usr/share/applications" \
|
||||||
|
"$STAGE_DIR/usr/share/icons/hicolor/scalable/apps" \
|
||||||
|
"$STAGE_DIR/usr/share/pixmaps" \
|
||||||
|
"$STAGE_DIR/opt/pubscript/apps/web" \
|
||||||
|
"$STAGE_DIR/opt/pubscript/server/dist"
|
||||||
|
|
||||||
|
echo "==> [3/6] Deploying production server dependencies and bundling server entry..."
|
||||||
|
pnpm --filter @pubscript/server --prod deploy --legacy "$STAGE_DIR/opt/pubscript/server-runtime"
|
||||||
|
cp -a "$STAGE_DIR/opt/pubscript/server-runtime/node_modules" "$STAGE_DIR/opt/pubscript/node_modules"
|
||||||
|
rm -rf "$STAGE_DIR/opt/pubscript/server-runtime"
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/opt/pubscript/package.json" <<'EOF'
|
||||||
|
{
|
||||||
|
"name": "pubscript-desktop-runtime",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Bundle server TypeScript + @pubscript/core into ESM while keeping native/CJS deps external
|
||||||
|
./apps/web/node_modules/.bin/esbuild server/src/index.ts \
|
||||||
|
--bundle \
|
||||||
|
--platform=node \
|
||||||
|
--target=node22 \
|
||||||
|
--format=esm \
|
||||||
|
--outfile="$STAGE_DIR/opt/pubscript/server/dist/server.mjs" \
|
||||||
|
--external:better-sqlite3 \
|
||||||
|
--external:argon2 \
|
||||||
|
--external:fastify \
|
||||||
|
--external:@fastify/cors \
|
||||||
|
--external:@fastify/cookie \
|
||||||
|
--external:@fastify/static \
|
||||||
|
--external:@fastify/websocket \
|
||||||
|
--external:ws \
|
||||||
|
--external:ulid \
|
||||||
|
--external:zod
|
||||||
|
|
||||||
|
cp -a apps/web/dist "$STAGE_DIR/opt/pubscript/apps/web/dist"
|
||||||
|
|
||||||
|
echo "==> [4/6] Compiling native GTK3 + WebKit2GTK desktop binary (/usr/bin/pubscript)..."
|
||||||
|
gcc $(pkg-config --cflags gtk+-3.0) \
|
||||||
|
-O2 -Wall -Wextra \
|
||||||
|
desktop/src/pubscript-desktop.c \
|
||||||
|
$(pkg-config --libs gtk+-3.0) -ldl \
|
||||||
|
-o "$STAGE_DIR/usr/bin/pubscript"
|
||||||
|
chmod 0755 "$STAGE_DIR/usr/bin/pubscript"
|
||||||
|
|
||||||
|
echo "==> [5/6] Installing icons, .desktop launcher, and DEBIAN metadata..."
|
||||||
|
cp apps/web/public/logo.svg "$STAGE_DIR/usr/share/icons/hicolor/scalable/apps/pubscript.svg"
|
||||||
|
cp apps/web/public/logo.svg "$STAGE_DIR/usr/share/pixmaps/pubscript.svg"
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/usr/share/applications/pubscript.desktop" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=PubScript
|
||||||
|
GenericName=Novel & Manuscript Writing Studio
|
||||||
|
Comment=Local-first writing studio for novelists with AI co-writing, plot boards, and manuscript export
|
||||||
|
Exec=/usr/bin/pubscript %U
|
||||||
|
Icon=pubscript
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;WordProcessor;TextEditor;Publishing;
|
||||||
|
Keywords=writing;novel;manuscript;markdown;epub;author;editor;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=pubscript
|
||||||
|
EOF
|
||||||
|
|
||||||
|
INSTALLED_KB="$(du -sk "$STAGE_DIR/usr" "$STAGE_DIR/opt" | awk '{s+=$1} END {print s}')"
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/DEBIAN/control" <<EOF
|
||||||
|
Package: ${PKG_NAME}
|
||||||
|
Version: ${PKG_VERSION}
|
||||||
|
Section: editors
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Installed-Size: ${INSTALLED_KB}
|
||||||
|
Depends: libc6 (>= 2.34), libgtk-3-0 (>= 3.24), libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, nodejs (>= 18)
|
||||||
|
Maintainer: PubScript Studio <team@pubscript.local>
|
||||||
|
Homepage: https://pubscript.local
|
||||||
|
Description: PubScript — Local-First Novel & Manuscript Writing Studio
|
||||||
|
A multi-surface writing studio for novelists featuring an OnlyOffice/Word-grade
|
||||||
|
manuscript editor, granular text-anchored AI co-writing suggestions, autosave
|
||||||
|
revision history, character & world-building sheets, plot beat templates,
|
||||||
|
interactive whiteboard, and deterministic HTML/EPUB/ODT/DOCX/PDF export.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database -q /usr/share/applications || true
|
||||||
|
fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$STAGE_DIR/DEBIAN/postinst"
|
||||||
|
|
||||||
|
echo "==> Verifying compiled desktop binary & embedded server via --headless-check..."
|
||||||
|
PUBSCRIPT_APP_ROOT="$STAGE_DIR/opt/pubscript" \
|
||||||
|
PUBSCRIPT_DESKTOP_PORT=18099 \
|
||||||
|
"$STAGE_DIR/usr/bin/pubscript" --headless-check
|
||||||
|
|
||||||
|
echo "==> [6/6] Building .deb archive with dpkg-deb..."
|
||||||
|
mkdir -p "$ROOT_DIR/dist"
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE_DIR" "$ROOT_DIR/dist/${DEB_NAME}"
|
||||||
|
cp -f "$ROOT_DIR/dist/${DEB_NAME}" "$ROOT_DIR/${DEB_NAME}"
|
||||||
|
|
||||||
|
echo "==> Built Debian package: $ROOT_DIR/dist/${DEB_NAME} (and $ROOT_DIR/${DEB_NAME})"
|
||||||
|
dpkg-deb --info "$ROOT_DIR/dist/${DEB_NAME}"
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* PubScript Native Linux Desktop Shell (GTK3 + WebKit2GTK 4.1)
|
||||||
|
*
|
||||||
|
* Spawns the embedded local PubScript Fastify + SQLite server on port 18081
|
||||||
|
* (storing user data in ~/.local/share/pubscript/pubscript.sqlite) and renders
|
||||||
|
* the full PubScript studio inside a native GTK3 + WebKit2GTK 4.1 window with
|
||||||
|
* persistent IndexedDB and localStorage in ~/.local/share/pubscript/webdata.
|
||||||
|
*/
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static pid_t g_server_pid = -1;
|
||||||
|
|
||||||
|
static void stop_server(void) {
|
||||||
|
if (g_server_pid > 0) {
|
||||||
|
kill(g_server_pid, SIGTERM);
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
int status = 0;
|
||||||
|
pid_t r = waitpid(g_server_pid, &status, WNOHANG);
|
||||||
|
if (r == g_server_pid || r < 0) {
|
||||||
|
g_server_pid = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usleep(25000);
|
||||||
|
}
|
||||||
|
kill(g_server_pid, SIGKILL);
|
||||||
|
waitpid(g_server_pid, NULL, 0);
|
||||||
|
g_server_pid = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_sigterm(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
stop_server();
|
||||||
|
_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool is_port_listening(int port) {
|
||||||
|
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (sock < 0) return false;
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons((uint16_t)port);
|
||||||
|
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
|
||||||
|
bool ok = (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0);
|
||||||
|
close(sock);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void mkdir_p(const char *path) {
|
||||||
|
char tmp[1024];
|
||||||
|
snprintf(tmp, sizeof(tmp), "%s", path);
|
||||||
|
size_t len = strlen(tmp);
|
||||||
|
if (len == 0) return;
|
||||||
|
if (tmp[len - 1] == '/') tmp[len - 1] = '\0';
|
||||||
|
for (char *p = tmp + 1; *p; p++) {
|
||||||
|
if (*p == '/') {
|
||||||
|
*p = '\0';
|
||||||
|
mkdir(tmp, 0755);
|
||||||
|
*p = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkdir(tmp, 0755);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_embedded_server(int port, const char *data_dir, const char *app_root) {
|
||||||
|
if (is_port_listening(port)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char db_path[1024];
|
||||||
|
snprintf(db_path, sizeof(db_path), "%s/pubscript.sqlite", data_dir);
|
||||||
|
|
||||||
|
char web_dist[1024];
|
||||||
|
snprintf(web_dist, sizeof(web_dist), "%s/apps/web/dist", app_root);
|
||||||
|
|
||||||
|
char server_entry[1024];
|
||||||
|
snprintf(server_entry, sizeof(server_entry), "%s/server/dist/server.mjs", app_root);
|
||||||
|
|
||||||
|
char port_str[32];
|
||||||
|
snprintf(port_str, sizeof(port_str), "%d", port);
|
||||||
|
|
||||||
|
char origin_str[128];
|
||||||
|
snprintf(origin_str, sizeof(origin_str), "http://127.0.0.1:%d", port);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
setenv("PORT", port_str, 1);
|
||||||
|
setenv("PUBSCRIPT_DB", db_path, 1);
|
||||||
|
setenv("PUBSCRIPT_WEB_DIST", web_dist, 1);
|
||||||
|
setenv("PUBSCRIPT_ORIGIN", origin_str, 1);
|
||||||
|
if (chdir(app_root) != 0) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
int log_fd = open("/tmp/pubscript-desktop-server.log", O_CREAT | O_WRONLY | O_TRUNC, 0644);
|
||||||
|
if (log_fd >= 0) {
|
||||||
|
dup2(log_fd, STDOUT_FILENO);
|
||||||
|
dup2(log_fd, STDERR_FILENO);
|
||||||
|
close(log_fd);
|
||||||
|
}
|
||||||
|
execlp("node", "node", server_entry, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
} else if (pid > 0) {
|
||||||
|
g_server_pid = pid;
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
if (is_port_listening(port)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
usleep(50000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||||
|
(void)widget;
|
||||||
|
(void)data;
|
||||||
|
stop_server();
|
||||||
|
gtk_main_quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
const char *home = getenv("HOME");
|
||||||
|
if (!home || !*home) home = "/tmp";
|
||||||
|
|
||||||
|
char data_dir[1024];
|
||||||
|
snprintf(data_dir, sizeof(data_dir), "%s/.local/share/pubscript", home);
|
||||||
|
mkdir_p(data_dir);
|
||||||
|
|
||||||
|
char webkit_data_dir[1024];
|
||||||
|
snprintf(webkit_data_dir, sizeof(webkit_data_dir), "%s/webdata", data_dir);
|
||||||
|
mkdir_p(webkit_data_dir);
|
||||||
|
|
||||||
|
char webkit_cache_dir[1024];
|
||||||
|
snprintf(webkit_cache_dir, sizeof(webkit_cache_dir), "%s/webcache", data_dir);
|
||||||
|
mkdir_p(webkit_cache_dir);
|
||||||
|
|
||||||
|
const char *app_root = getenv("PUBSCRIPT_APP_ROOT");
|
||||||
|
if (!app_root || !*app_root) {
|
||||||
|
app_root = "/opt/pubscript";
|
||||||
|
}
|
||||||
|
|
||||||
|
int port = 18081;
|
||||||
|
const char *env_port = getenv("PUBSCRIPT_DESKTOP_PORT");
|
||||||
|
if (env_port && *env_port) {
|
||||||
|
int p = atoi(env_port);
|
||||||
|
if (p > 0 && p < 65536) port = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(SIGINT, on_sigterm);
|
||||||
|
signal(SIGTERM, on_sigterm);
|
||||||
|
atexit(stop_server);
|
||||||
|
|
||||||
|
start_embedded_server(port, data_dir, app_root);
|
||||||
|
|
||||||
|
char url[256];
|
||||||
|
snprintf(url, sizeof(url), "http://127.0.0.1:%d/", port);
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
if (strcmp(argv[i], "--version") == 0) {
|
||||||
|
printf("PubScript Desktop 0.1.0\n");
|
||||||
|
stop_server();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(argv[i], "--headless-check") == 0) {
|
||||||
|
bool up = is_port_listening(port);
|
||||||
|
printf("{\"ok\":%s,\"url\":\"%s\",\"dataDir\":\"%s\"}\n",
|
||||||
|
up ? "true" : "false", url, data_dir);
|
||||||
|
stop_server();
|
||||||
|
return up ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk_init(&argc, &argv);
|
||||||
|
|
||||||
|
void *wk = dlopen("libwebkit2gtk-4.1.so.0", RTLD_LAZY | RTLD_GLOBAL);
|
||||||
|
if (!wk) {
|
||||||
|
wk = dlopen("libwebkit2gtk-4.0.so.37", RTLD_LAZY | RTLD_GLOBAL);
|
||||||
|
}
|
||||||
|
if (!wk) {
|
||||||
|
fprintf(stderr, "WebKit2GTK not found (%s); falling back to xdg-open %s\n", dlerror(), url);
|
||||||
|
char cmd[512];
|
||||||
|
snprintf(cmd, sizeof(cmd), "xdg-open '%s' >/dev/null 2>&1 &", url);
|
||||||
|
int r = system(cmd);
|
||||||
|
(void)r;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef void *(*fn_data_mgr_new)(const char *, const char *, const char *, const char *, void *);
|
||||||
|
typedef void *(*fn_ctx_new_with_mgr)(void *);
|
||||||
|
typedef GtkWidget *(*fn_webview_new_with_ctx)(void *);
|
||||||
|
typedef GtkWidget *(*fn_webview_new)(void);
|
||||||
|
typedef void *(*fn_webview_get_settings)(GtkWidget *);
|
||||||
|
typedef void (*fn_webview_load_uri)(GtkWidget *, const char *);
|
||||||
|
|
||||||
|
fn_data_mgr_new p_data_mgr_new = (fn_data_mgr_new)dlsym(wk, "webkit_website_data_manager_new");
|
||||||
|
fn_ctx_new_with_mgr p_ctx_new_with_mgr = (fn_ctx_new_with_mgr)dlsym(wk, "webkit_web_context_new_with_website_data_manager");
|
||||||
|
fn_webview_new_with_ctx p_webview_new_with_ctx = (fn_webview_new_with_ctx)dlsym(wk, "webkit_web_view_new_with_context");
|
||||||
|
fn_webview_new p_webview_new = (fn_webview_new)dlsym(wk, "webkit_web_view_new");
|
||||||
|
fn_webview_get_settings p_get_settings = (fn_webview_get_settings)dlsym(wk, "webkit_web_view_get_settings");
|
||||||
|
fn_webview_load_uri p_load_uri = (fn_webview_load_uri)dlsym(wk, "webkit_web_view_load_uri");
|
||||||
|
|
||||||
|
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||||
|
gtk_window_set_title(GTK_WINDOW(window), "PubScript — Writing Studio");
|
||||||
|
gtk_window_set_default_size(GTK_WINDOW(window), 1440, 900);
|
||||||
|
gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER);
|
||||||
|
|
||||||
|
const char *icon_path = "/usr/share/icons/hicolor/scalable/apps/pubscript.svg";
|
||||||
|
if (access(icon_path, R_OK) == 0) {
|
||||||
|
gtk_window_set_icon_from_file(GTK_WINDOW(window), icon_path, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget *webview = NULL;
|
||||||
|
if (p_data_mgr_new && p_ctx_new_with_mgr && p_webview_new_with_ctx) {
|
||||||
|
void *mgr = p_data_mgr_new(
|
||||||
|
"base-data-directory", webkit_data_dir,
|
||||||
|
"base-cache-directory", webkit_cache_dir,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
void *ctx = p_ctx_new_with_mgr(mgr);
|
||||||
|
webview = p_webview_new_with_ctx(ctx);
|
||||||
|
} else if (p_webview_new) {
|
||||||
|
webview = p_webview_new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webview || !p_load_uri) {
|
||||||
|
fprintf(stderr, "Failed to initialize WebKitWebView\n");
|
||||||
|
stop_server();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_get_settings) {
|
||||||
|
void *settings = p_get_settings(webview);
|
||||||
|
if (settings) {
|
||||||
|
g_object_set(
|
||||||
|
G_OBJECT(settings),
|
||||||
|
"enable-developer-extras", TRUE,
|
||||||
|
"enable-smooth-scrolling", TRUE,
|
||||||
|
"javascript-can-access-clipboard", TRUE,
|
||||||
|
"enable-webgl", TRUE,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk_container_add(GTK_CONTAINER(window), webview);
|
||||||
|
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
|
||||||
|
|
||||||
|
p_load_uri(webview, url);
|
||||||
|
gtk_widget_show_all(window);
|
||||||
|
gtk_main();
|
||||||
|
|
||||||
|
stop_server();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
"test": "pnpm -r run test",
|
"test": "pnpm -r run test",
|
||||||
"lint": "pnpm -r run lint",
|
"lint": "pnpm -r run lint",
|
||||||
"typecheck": "pnpm -r run typecheck",
|
"typecheck": "pnpm -r run typecheck",
|
||||||
"contract": "pnpm --filter @pubscript/core run contract"
|
"contract": "pnpm --filter @pubscript/core run contract",
|
||||||
|
"package:deb": "bash desktop/build-deb.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
@@ -68,6 +71,28 @@ export async function buildApp() {
|
|||||||
registerAdminRoutes(app);
|
registerAdminRoutes(app);
|
||||||
registerWs(app);
|
registerWs(app);
|
||||||
|
|
||||||
|
// Serve the built web app when apps/web/dist (or PUBSCRIPT_WEB_DIST) is present
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot =
|
||||||
|
process.env.PUBSCRIPT_WEB_DIST || resolve(here, "../../apps/web/dist");
|
||||||
|
if (existsSync(webRoot) && existsSync(resolve(webRoot, "index.html"))) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: webRoot,
|
||||||
|
prefix: "/",
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (
|
||||||
|
req.method === "GET" &&
|
||||||
|
!req.url.startsWith("/api/") &&
|
||||||
|
!req.url.startsWith("/ws")
|
||||||
|
) {
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: "not_found" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user