Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6735cde8 | ||
|
|
7f64c6b635 | ||
|
|
69f324ead7 | ||
|
|
e839c84bf0 | ||
|
|
7c39383655 | ||
|
|
84ec431441 | ||
|
|
cf129714be |
@@ -30,7 +30,10 @@ ALLOWED_HOSTS=j621.rainbow-herring.ts.net,localhost,127.0.0.1
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-origin access (needed for the separate frontend + backend deploys)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The origin the SPA is served from, e.g. https://j621-frontend.<tailnet>.ts.net
|
||||
# The origin the SPA is served from for split deploys, e.g.
|
||||
# https://j621-frontend.<tailnet>.ts.net. gen_env.sh writes this plus the
|
||||
# desktop shell's app://j621 origin into the line below (and keeps existing
|
||||
# entries on --update).
|
||||
# CORS_ALLOWED_ORIGINS=
|
||||
# CSRF_TRUSTED_ORIGINS=
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ The SPA asks where the backend is (`/setup`) in production builds:
|
||||
backend's funnel URL, e.g. `https://j621-backend.<tailnet>.ts.net:8443`,
|
||||
and give the backend `CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net`
|
||||
(plus `CSRF_TRUSTED_ORIGINS` for the admin).
|
||||
- **Desktop app** — the Electron shell (`desktop/`) is served from the
|
||||
`app://j621` origin, which `gen_env.sh` includes in `CORS_ALLOWED_ORIGINS`
|
||||
automatically (add it by hand if you wrote `.env` yourself). The shell's
|
||||
setup screen prints the exact origin when the connection test fails.
|
||||
- Without a backend at all, choose **"Continue without a backend"** to run in
|
||||
local mode (e621 browsing only).
|
||||
|
||||
@@ -132,6 +136,29 @@ Push multi-arch images to the Gitea registry:
|
||||
They tag `:latest` and `:<commit-sha>` and expect `docker login
|
||||
gitea.rainbow-herring.ts.net` to succeed.
|
||||
|
||||
Build the desktop installers without publishing anything:
|
||||
|
||||
```bash
|
||||
./build_desktop.sh # Arch + Debian + Windows -> desktop/release/
|
||||
./build_desktop.sh --linux # Arch + Debian only
|
||||
./build_desktop.sh --win # Windows installer only
|
||||
```
|
||||
|
||||
Hand the files out or attach them to a Gitea release manually — the script
|
||||
prints sizes and SHA-256 sums for the release notes.
|
||||
|
||||
Push the desktop builds and their update metadata to the frontend's feed:
|
||||
|
||||
```bash
|
||||
./push_desktop.sh # Linux packages (deb + pacman)
|
||||
./push_desktop.sh --win # also cross-build the NSIS installer (wine)
|
||||
```
|
||||
|
||||
Artifacts land in `deploy/data/desktop/`, which the frontend nginx mounts
|
||||
read-only and serves at `/desktop/`. The desktop app's "Check for updates…"
|
||||
menu item reads `latest-linux.yml` / `latest.yml` from there (see
|
||||
`desktop/README.md`). Backend-only composes have no frontend, so no feed.
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
Compose files with a backend also run a **`scheduler`** service — the same
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Build the J621 desktop packages into desktop/release/ — Arch (.pkg.tar.zst),
|
||||
# Debian (.deb) and the Windows NSIS installer. Nothing is published: install
|
||||
# the package locally, hand the files out, or attach them to a Gitea release
|
||||
# manually. Use push_desktop.sh when the in-app update feed should get them.
|
||||
#
|
||||
# Usage: ./build_desktop.sh [--linux | --win | --all]
|
||||
# --linux Arch + Debian packages only
|
||||
# --win Windows installer only (cross-built with wine)
|
||||
# --all everything (default)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="${1:---all}"
|
||||
case "$TARGET" in
|
||||
--linux) TARGET=linux ;;
|
||||
--win) TARGET=win ;;
|
||||
--all) TARGET=all ;;
|
||||
*)
|
||||
echo "usage: $0 [--linux|--win|--all]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d desktop/node_modules ]; then
|
||||
echo "==> Installing desktop dependencies ..."
|
||||
npm --prefix desktop ci --no-audit --no-fund
|
||||
fi
|
||||
|
||||
if [ "$TARGET" != "linux" ] && ! command -v wine >/dev/null 2>&1; then
|
||||
echo "==> wine is not installed; the Windows installer needs it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$TARGET" in
|
||||
linux) npm --prefix desktop run dist:linux ;;
|
||||
win) npm --prefix desktop run dist:win ;;
|
||||
all) npm --prefix desktop run dist:all ;;
|
||||
esac
|
||||
|
||||
VERSION="$(node -p "require('./desktop/package.json').version")"
|
||||
|
||||
case "$TARGET" in
|
||||
linux) ARTIFACTS=(-name "*.deb" -o -name "*.pkg.tar.zst") ;;
|
||||
win) ARTIFACTS=(-name "*.exe") ;;
|
||||
all) ARTIFACTS=(-name "*.deb" -o -name "*.pkg.tar.zst" -o -name "*.exe") ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "==> Artifacts in desktop/release/:"
|
||||
find desktop/release -maxdepth 1 -type f \( "${ARTIFACTS[@]}" \) \
|
||||
-printf '%s\t%p\n' |
|
||||
sort -rn |
|
||||
while IFS=$'\t' read -r size file; do
|
||||
printf ' %8s %s\n' "$(numfmt --to=iec "$size")" "$file"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==> SHA-256 (for release notes):"
|
||||
find desktop/release -maxdepth 1 -type f \( "${ARTIFACTS[@]}" \) \
|
||||
-exec sha256sum {} + | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==> J621 desktop $VERSION built."
|
||||
echo " Install here: sudo pacman -U desktop/release/j621-desktop-*.pkg.tar.zst"
|
||||
echo " Gitea release tag: desktop-v$VERSION"
|
||||
@@ -20,6 +20,9 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -21,6 +21,9 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -100,6 +100,9 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -99,6 +99,9 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -86,7 +86,26 @@ FQDN="${FQDN:-$DEFAULT_FQDN}"
|
||||
# Derive the rest from the tailnet hostname.
|
||||
TS_HOSTNAME="${FQDN%%.*}"
|
||||
ALLOWED_HOSTS="$FQDN,localhost,127.0.0.1"
|
||||
CORS_ALLOWED_ORIGINS="${FRONTEND:+https://$FRONTEND}"
|
||||
|
||||
# Cross-origin access: the optional split-deploy frontend plus the desktop
|
||||
# shell, which is always a different origin from the backend. On --update the
|
||||
# existing list is kept, so hand-added origins survive.
|
||||
CORS_ALLOWED_ORIGINS=""
|
||||
add_origin() {
|
||||
[ -n "${1:-}" ] || return 0
|
||||
case ",$CORS_ALLOWED_ORIGINS," in
|
||||
*",$1,"*) ;;
|
||||
*) CORS_ALLOWED_ORIGINS="${CORS_ALLOWED_ORIGINS:+$CORS_ALLOWED_ORIGINS,}$1" ;;
|
||||
esac
|
||||
}
|
||||
if [ "$UPDATE" -eq 1 ]; then
|
||||
while IFS= read -r origin; do
|
||||
add_origin "$origin"
|
||||
done < <(existing CORS_ALLOWED_ORIGINS | tr ',' '\n')
|
||||
fi
|
||||
[ -n "$FRONTEND" ] && add_origin "https://$FRONTEND"
|
||||
add_origin "app://j621"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS="${FRONTEND:+https://$FRONTEND}"
|
||||
|
||||
J621_SECRET_KEY="$SECRET_KEY" \
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Build the J621 desktop packages and publish them to the frontend's update
|
||||
# feed (deploy/data/desktop, mounted read-only into the frontend nginx and
|
||||
# served at /desktop/). electron-updater reads latest-linux.yml / latest.yml
|
||||
# from there; the app's feed URL comes from desktop/electron-builder.yml.
|
||||
#
|
||||
# Usage: ./push_desktop.sh [--win] [--no-build]
|
||||
# --win also cross-build the Windows NSIS installer (needs wine)
|
||||
# --no-build publish what is already in desktop/release/
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUILD=1
|
||||
WIN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--win) WIN=1 ;;
|
||||
--no-build) BUILD=0 ;;
|
||||
*)
|
||||
echo "usage: $0 [--win] [--no-build]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$BUILD" = 1 ]; then
|
||||
echo "==> Building Linux packages (deb + pacman) ..."
|
||||
npm --prefix desktop run dist:linux
|
||||
if [ "$WIN" = 1 ]; then
|
||||
echo "==> Cross-building the Windows installer (wine) ..."
|
||||
npm --prefix desktop run dist:win
|
||||
fi
|
||||
fi
|
||||
|
||||
FEED=deploy/data/desktop
|
||||
mkdir -p "$FEED"
|
||||
|
||||
shopt -s nullglob
|
||||
deb=(desktop/release/*.deb)
|
||||
zst=(desktop/release/*.pkg.tar.zst)
|
||||
linux_meta=(desktop/release/latest-linux.yml)
|
||||
win_exe=("desktop/release/J621 Setup "*.exe)
|
||||
win_meta=(desktop/release/latest.yml)
|
||||
blockmaps=(desktop/release/*.blockmap)
|
||||
|
||||
if [ "${#deb[@]}" -eq 0 ] && [ "${#zst[@]}" -eq 0 ]; then
|
||||
echo "No artifacts in desktop/release/ — run without --no-build first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace the previous release's metadata before copying the new artifacts.
|
||||
rm -f "$FEED"/latest-linux.yml "$FEED"/latest.yml
|
||||
cp -f "${deb[@]}" "${zst[@]}" "${linux_meta[@]}" "$FEED"/ 2>/dev/null || true
|
||||
if [ "${#win_exe[@]}" -gt 0 ]; then
|
||||
cp -f "${win_exe[@]}" "${win_meta[@]}" "${blockmaps[@]}" "$FEED"/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "==> Published to $FEED:"
|
||||
ls -1sh "$FEED" | sed 's/^/ /'
|
||||
echo
|
||||
echo "Served read-only by the frontend nginx at /desktop/ (no restart needed):"
|
||||
echo " https://<frontend-host>/desktop/latest-linux.yml"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,103 @@
|
||||
# J621 desktop (Electron)
|
||||
|
||||
An Electron shell around the web build. The main process serves the SPA from
|
||||
the privileged `app://j621` scheme — a real origin, so `localStorage`, OPFS,
|
||||
Web Workers and WebCodecs all behave exactly like they do in Chrome — and the
|
||||
SPA keeps its normal first-run flow: on launch it asks where the backend is
|
||||
(or runs in local mode with no backend at all).
|
||||
|
||||
The renderer code is not forked: the shell loads `frontend/dist` as built by
|
||||
`npm run build` in `frontend/`.
|
||||
|
||||
Licensed under the Jake Labs Non-Commercial Software Licence — see
|
||||
`../LICENSE`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/main.ts window, app:// protocol handler, menu, external links
|
||||
src/preload.ts window.j621Desktop bridge
|
||||
electron-builder.yml deb + pacman + nsis packaging
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# terminal 1: the SPA with its /api proxy (needs the backend for library pages)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# terminal 2
|
||||
cd desktop && npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` builds the main process and points the window at
|
||||
`http://localhost:5173` (`J621_DEV_SERVER`). Without the dev server,
|
||||
`npm start` builds both halves and serves the bundled SPA over `app://j621`.
|
||||
|
||||
## Builds
|
||||
|
||||
```bash
|
||||
npm run dist:linux # release/j621-desktop_0.1.0_amd64.deb + j621-desktop-0.1.0.pkg.tar.zst
|
||||
npm run dist:win # release/J621 Setup 0.1.0.exe (cross-built with wine; untested)
|
||||
npm run dist:all
|
||||
```
|
||||
|
||||
`deploy/build_desktop.sh` wraps the same commands, installs dependencies on
|
||||
first run and prints sizes plus SHA-256 sums for release notes. Nothing is
|
||||
published by it; `deploy/push_desktop.sh` is the one that feeds auto-updates.
|
||||
|
||||
The Arch package can be installed and removed with pacman:
|
||||
|
||||
```bash
|
||||
sudo pacman -U release/j621-desktop-*.pkg.tar.zst
|
||||
sudo pacman -R j621-desktop
|
||||
```
|
||||
|
||||
It installs to `/opt/J621` with a `/usr/bin/j621-desktop` symlink and a
|
||||
`io.j621.desktop` launcher entry (`StartupWMClass` matches the shell's
|
||||
`desktopName`). The Windows installer is a per-user NSIS build, so no admin
|
||||
rights are needed to install or update it — but it is unsigned, so SmartScreen
|
||||
will warn, and it has not been smoke-tested on real Windows.
|
||||
|
||||
## Updates
|
||||
|
||||
The app checks only when asked (**J621 → Check for updates…** in the menu):
|
||||
Linux packages install through pacman/dpkg, which needs administrator rights,
|
||||
and the Windows build is unsigned, so nothing installs silently. The check
|
||||
reads `latest-linux.yml` / `latest.yml` from the feed configured in
|
||||
`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set
|
||||
`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this).
|
||||
|
||||
Publishing a release:
|
||||
|
||||
1. Bump `version` in `desktop/package.json` — that is what the updater compares.
|
||||
2. `./deploy/push_desktop.sh --win` builds deb/pacman/NSIS and copies the
|
||||
artifacts plus both channel files into `deploy/data/desktop/`, which the
|
||||
frontend nginx serves read-only at `/desktop/`.
|
||||
3. Existing installs find the new version on their next manual check.
|
||||
|
||||
`package-type` in the app resources tells electron-updater whether to run
|
||||
`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
|
||||
updates without elevation.
|
||||
|
||||
## Smoke test
|
||||
|
||||
`npm run smoke` builds everything, runs Electron headless through `xvfb-run`
|
||||
and checks that the bundled SPA loads over `app://j621`: the SPA fallback, an
|
||||
asset fetch, `localStorage`, OPFS, WebCodecs and the preload bridge. It exits
|
||||
non-zero on failure. With `J621_UPDATE_URL` set it also checks that the feed
|
||||
reports the expected version. Pointing `J621_DEV_SERVER` at the Vite dev
|
||||
server checks the development path instead of the bundled one.
|
||||
|
||||
## Backend CORS
|
||||
|
||||
A desktop app is cross-origin to the backend, exactly like the frontend-only
|
||||
Docker deployment. Add the shell's origin to the backend:
|
||||
|
||||
```
|
||||
CORS_ALLOWED_ORIGINS=app://j621
|
||||
```
|
||||
|
||||
The setup screen shows this hint (with the actual origin) when the connection
|
||||
test fails. Without a backend, choose "Continue without a backend" — the
|
||||
e621-facing pages work the same as in the browser.
|
||||
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 405 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,70 @@
|
||||
appId: io.j621.desktop
|
||||
productName: J621
|
||||
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
|
||||
|
||||
# Update feed served by the frontend nginx (deploy/data/desktop, published
|
||||
# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override
|
||||
# at runtime with J621_UPDATE_URL for a fork or a test feed.
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://j621.rainbow-herring.ts.net/desktop
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
# The Electron main process and preload live in dist/ (tsc output). The SPA is
|
||||
# copied from the web build as an extra resource and served over app://j621.
|
||||
files:
|
||||
- dist/**
|
||||
- package.json
|
||||
|
||||
extraResources:
|
||||
- from: ../frontend/dist
|
||||
to: dist
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
linux:
|
||||
target:
|
||||
- deb
|
||||
- pacman
|
||||
category: AudioVideo
|
||||
synopsis: Desktop client for the J621 self-hosted media archive
|
||||
description: >-
|
||||
J621 is a self-hosted browser for an e621-linked media library. This
|
||||
desktop build talks to a J621 backend over the network, the same way the
|
||||
web app does.
|
||||
icon: build/icons
|
||||
syncDesktopName: true
|
||||
|
||||
pacman:
|
||||
compression: zstd
|
||||
artifactName: ${name}-${version}.pkg.tar.zst
|
||||
# Electron's shared-library requirements on Arch (the electron-builder
|
||||
# defaults still list an ancient Electron 2 dependency set).
|
||||
depends:
|
||||
- gtk3
|
||||
- nss
|
||||
- libxss
|
||||
- libxtst
|
||||
- xdg-utils
|
||||
- at-spi2-core
|
||||
- libsecret
|
||||
- libnotify
|
||||
- alsa-lib
|
||||
- libcups
|
||||
- libdrm
|
||||
- mesa
|
||||
- libxkbcommon
|
||||
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
icon: build/icon.ico
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
shortcutName: J621
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "j621-desktop",
|
||||
"productName": "J621",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Desktop shell for the J621 self-hosted media archive",
|
||||
"author": {
|
||||
"name": "JakeBreath",
|
||||
"email": "tolozacdcmo@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.rainbow-herring.ts.net/JakeBreath/J621",
|
||||
"license": "LicenseRef-Jake-Labs-Non-Commercial",
|
||||
"desktopName": "io.j621.desktop",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"build:renderer": "npm --prefix ../frontend run build",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"dev": "npm run build:main && J621_DEV_SERVER=http://localhost:5173 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"smoke": "npm run build && xvfb-run -a electron . --j621-smoke",
|
||||
"dist:linux": "npm run build && electron-builder --linux",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:all": "npm run build && electron-builder --linux --win"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.6",
|
||||
"electron": "^44.4.3",
|
||||
"electron-builder": "^26.15.3",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Electron shell for the J621 SPA.
|
||||
*
|
||||
* The renderer is the normal web build (`frontend/dist`), served from the
|
||||
* privileged `app://j621` scheme so it is a real secure origin: localStorage,
|
||||
* OPFS, Web Workers, WebCodecs and `history.pushState` all behave exactly
|
||||
* like they do in Chrome. In development `J621_DEV_SERVER` points the window
|
||||
* at the Vite dev server instead, which keeps HMR and the `/api` proxy.
|
||||
*
|
||||
* The shell is deliberately thin: the SPA still asks for its backend URL on
|
||||
* first launch (or runs in local e621-only mode) and talks to it over HTTP
|
||||
* the same way it does in a browser. The only desktop-specific bits are the
|
||||
* origin, external-link handling and (later) updates.
|
||||
*/
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const SCHEME = "app";
|
||||
const HOST = "j621";
|
||||
const APP_ORIGIN = `${SCHEME}://${HOST}`;
|
||||
const DEV_SERVER = (process.env.J621_DEV_SERVER ?? "").replace(/\/+$/, "");
|
||||
const SMOKE = process.argv.includes("--j621-smoke");
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wasm": "application/wasm",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
/** Where `vite build` output lives, packaged or not. */
|
||||
function rendererRoot(): string {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "dist")
|
||||
: path.resolve(__dirname, "..", "..", "frontend", "dist");
|
||||
}
|
||||
|
||||
async function existingFile(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(candidate)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileResponse(file: string): Promise<Response> {
|
||||
const body = await readFile(file);
|
||||
const type =
|
||||
MIME_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
||||
return new Response(body, {
|
||||
headers: { "content-type": type, "cache-control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAppRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.host !== HOST) return new Response("Not found", { status: 404 });
|
||||
|
||||
const root = rendererRoot();
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (!pathname || pathname === "/") pathname = "/index.html";
|
||||
|
||||
const target = path.resolve(root, "." + pathname);
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (await existingFile(target)) return await fileResponse(target);
|
||||
// Missing assets keep their 404; anything else is a client route and
|
||||
// falls back to the SPA entry point (BrowserRouter).
|
||||
if (path.extname(pathname)) return new Response("Not found", { status: 404 });
|
||||
return await fileResponse(path.join(root, "index.html"));
|
||||
} catch (error) {
|
||||
const hint = app.isPackaged
|
||||
? "The bundled frontend is missing from the application resources."
|
||||
: "Build the frontend first: npm --prefix ../frontend run build";
|
||||
console.error("[app://]", error);
|
||||
return new Response(`${hint}\n`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOW_STATE: WindowState = {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
maximized: false,
|
||||
};
|
||||
|
||||
function stateFile(): string {
|
||||
return path.join(app.getPath("userData"), "window-state.json");
|
||||
}
|
||||
|
||||
function readWindowState(): WindowState {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(stateFile(), "utf8"),
|
||||
) as Partial<WindowState>;
|
||||
return { ...DEFAULT_WINDOW_STATE, ...parsed };
|
||||
} catch {
|
||||
return DEFAULT_WINDOW_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
function saveWindowState(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) return;
|
||||
const { x, y, width, height } = win.getNormalBounds();
|
||||
const state: WindowState = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
maximized: win.isMaximized(),
|
||||
};
|
||||
void writeFile(stateFile(), JSON.stringify(state)).catch(() => {
|
||||
// Window geometry is a nice-to-have; never fail a quit over it.
|
||||
});
|
||||
}
|
||||
|
||||
function openExternal(rawUrl: string): void {
|
||||
if (/^https?:/i.test(rawUrl)) void shell.openExternal(rawUrl);
|
||||
}
|
||||
|
||||
/** File extensions the SPA navigates to when it wants a save dialog. */
|
||||
const DOWNLOAD_EXTENSIONS = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpe",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".swf",
|
||||
".zip",
|
||||
".pdf",
|
||||
".bin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Downloads in the SPA are same-window navigations (`location.href =
|
||||
* url?download=1`, `<a href=...>`), and Chromium cancels those when
|
||||
* `will-navigate` is prevented. Recognise them so they become real
|
||||
* downloads instead of being opened in the browser.
|
||||
*/
|
||||
function isDownloadNavigation(url: URL): boolean {
|
||||
if (/(?:^|&)download=1(?:&|$)/.test(url.search.slice(1))) return true;
|
||||
return DOWNLOAD_EXTENSIONS.has(path.extname(url.pathname).toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates are manual by design: Linux packages install through pacman/dpkg
|
||||
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
|
||||
* downloading and again before installing. The feed comes from `publish` in
|
||||
* electron-builder.yml and can be overridden with J621_UPDATE_URL.
|
||||
*/
|
||||
type UpdateEvent =
|
||||
| { state: "available"; version: string }
|
||||
| { state: "not-available" }
|
||||
| { state: "downloaded"; version: string }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
let updateReporter: ((event: UpdateEvent) => void) | null = null;
|
||||
let updateCheckRunning = false;
|
||||
|
||||
function setUpUpdates(win: BrowserWindow): void {
|
||||
const override = process.env.J621_UPDATE_URL?.trim();
|
||||
if (override) autoUpdater.setFeedURL({ provider: "generic", url: override });
|
||||
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
updateReporter?.({ state: "error", message: error.message });
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
if (SMOKE) return updateReporter?.({ state: "not-available" });
|
||||
void dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${app.getVersion()} is up to date.`,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
if (SMOKE) {
|
||||
return updateReporter?.({ state: "available", version: info.version });
|
||||
}
|
||||
void (async () => {
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${info.version} is available.`,
|
||||
detail:
|
||||
"Download it now? Installing it later needs administrator rights " +
|
||||
"on Linux (pacman/dpkg); the Windows installer runs without them.",
|
||||
buttons: ["Download", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response !== 0) return;
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
await dialog.showMessageBox(win, {
|
||||
type: "error",
|
||||
title: "Update failed",
|
||||
message: "The update could not be downloaded.",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
win.setProgressBar(Math.min(progress.percent / 100, 1));
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
win.setProgressBar(-1);
|
||||
if (SMOKE) {
|
||||
return updateReporter?.({ state: "downloaded", version: info.version });
|
||||
}
|
||||
void (async () => {
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${info.version} is ready to install.`,
|
||||
detail:
|
||||
process.platform === "linux"
|
||||
? "Installing asks for administrator rights and then restarts J621."
|
||||
: "J621 will restart to finish installing.",
|
||||
buttons: ["Restart and install", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response === 0) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
} else {
|
||||
// Linux packages elevate, so never install silently on quit there.
|
||||
autoUpdater.autoInstallOnAppQuit = process.platform !== "linux";
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates(win: BrowserWindow): Promise<void> {
|
||||
if (updateCheckRunning) return;
|
||||
updateCheckRunning = true;
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateReporter?.({ state: "error", message });
|
||||
if (!SMOKE) {
|
||||
await dialog.showMessageBox(win, {
|
||||
type: "error",
|
||||
title: "Updates",
|
||||
message: "Could not check for updates.",
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
updateCheckRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function buildMenu(win: BrowserWindow): void {
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "J621",
|
||||
submenu: [
|
||||
{
|
||||
label: "Backend setup…",
|
||||
click: () => void win.loadURL(`${APP_ORIGIN}/setup`),
|
||||
},
|
||||
{
|
||||
label: "Open backend in browser",
|
||||
click: () => win.webContents.send("j621:open-backend"),
|
||||
},
|
||||
{
|
||||
label: "Check for updates…",
|
||||
click: () => void checkForUpdates(win),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const state = readWindowState();
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#11111b",
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (state.maximized) win.maximize();
|
||||
win.once("ready-to-show", () => win.show());
|
||||
win.on("close", () => saveWindowState(win));
|
||||
win.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Links to the web open in the user's browser, never in this window.
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const allowed = DEV_SERVER
|
||||
? url.startsWith(DEV_SERVER)
|
||||
: url.startsWith(APP_ORIGIN);
|
||||
if (allowed) return;
|
||||
event.preventDefault();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (/^https?:$/.test(parsed.protocol) && isDownloadNavigation(parsed)) {
|
||||
win.webContents.downloadURL(url);
|
||||
return;
|
||||
}
|
||||
openExternal(url);
|
||||
});
|
||||
|
||||
buildMenu(win);
|
||||
setUpUpdates(win);
|
||||
void win.loadURL(DEV_SERVER || `${APP_ORIGIN}/`);
|
||||
if (SMOKE) runSmokeTest(win);
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* `npm run smoke`: load the bundled SPA and check the things that are only
|
||||
* true under the custom scheme. Exits non-zero on the first broken promise.
|
||||
*/
|
||||
function runSmokeTest(win: BrowserWindow): void {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = (await win.webContents.executeJavaScript(`(async () => {
|
||||
const waitFor = async (probe, timeout = 8000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (probe()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const asset = await fetch("/favicon.svg");
|
||||
const route = await fetch("/gallery/some/deep/route");
|
||||
const routeBody = await route.text();
|
||||
localStorage.setItem("j621.smoke", "ok");
|
||||
history.pushState({}, "", "/gallery");
|
||||
return {
|
||||
origin: location.origin,
|
||||
title: document.title,
|
||||
assetOk: asset.ok && (await asset.text()).includes("<svg"),
|
||||
routeOk: route.ok && routeBody.includes('id="root"'),
|
||||
mounted: await waitFor(() => (document.querySelector("#root")?.childElementCount ?? 0) > 0),
|
||||
setupOk: document.body.innerText.includes("Where is your backend?"),
|
||||
storageOk: localStorage.getItem("j621.smoke") === "ok",
|
||||
historyOk: location.pathname === "/gallery",
|
||||
opfsOk: typeof navigator.storage?.getDirectory === "function",
|
||||
webcodecsOk: typeof VideoEncoder !== "undefined",
|
||||
bridgeOk: window.j621Desktop?.isDesktop === true,
|
||||
};
|
||||
})()`, true)) as Record<string, unknown>;
|
||||
|
||||
const expected: Record<string, unknown> = {
|
||||
// In dev the window hosts the Vite dev server, which has no /setup
|
||||
// screen (the SPA only asks for a backend in production builds).
|
||||
origin: DEV_SERVER || APP_ORIGIN,
|
||||
title: "J621",
|
||||
assetOk: true,
|
||||
routeOk: true,
|
||||
mounted: true,
|
||||
setupOk: !DEV_SERVER,
|
||||
storageOk: true,
|
||||
historyOk: true,
|
||||
opfsOk: true,
|
||||
webcodecsOk: true,
|
||||
bridgeOk: true,
|
||||
};
|
||||
const failures = Object.entries(expected).filter(
|
||||
([key, value]) => result[key] !== value,
|
||||
);
|
||||
console.log(
|
||||
"[smoke] electron",
|
||||
process.versions.electron,
|
||||
"chrome",
|
||||
process.versions.chrome,
|
||||
);
|
||||
console.log("[smoke]", JSON.stringify(result));
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
"[smoke] FAILED:",
|
||||
failures.map(([key]) => key).join(", "),
|
||||
);
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
if (process.env.J621_UPDATE_URL) {
|
||||
const update = await new Promise<UpdateEvent>((resolve) => {
|
||||
updateReporter = resolve;
|
||||
const timer = setTimeout(
|
||||
() => resolve({ state: "error", message: "update check timed out" }),
|
||||
60_000,
|
||||
);
|
||||
void checkForUpdates(win).finally(() => clearTimeout(timer));
|
||||
});
|
||||
updateReporter = null;
|
||||
console.log("[smoke] update:", JSON.stringify(update));
|
||||
if (update.state === "error") {
|
||||
console.error("[smoke] FAILED: update check");
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log("[smoke] OK");
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
console.error("[smoke] FAILED:", error);
|
||||
app.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.setAppUserModelId("io.j621.desktop");
|
||||
|
||||
app.whenReady().then(() => {
|
||||
protocol.handle(SCHEME, handleAppRequest);
|
||||
|
||||
ipcMain.handle("j621:version", () => app.getVersion());
|
||||
ipcMain.handle("j621:open-external", (_event, url: unknown) => {
|
||||
if (typeof url === "string") openExternal(url);
|
||||
});
|
||||
|
||||
mainWindow = createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The only bridge between the desktop shell and the SPA. Keep it tiny: the
|
||||
* renderer is the normal web build and must not need Electron to work.
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("j621Desktop", {
|
||||
isDesktop: true as const,
|
||||
platform: process.platform,
|
||||
// The SPA's own origin (`app://j621`), shown by the setup screen when it
|
||||
// has to explain which origin to allow in the backend's CORS settings.
|
||||
origin: window.location.origin,
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke("j621:version"),
|
||||
});
|
||||
|
||||
// Menu → "Open backend in browser": read the SPA's stored backend URL and
|
||||
// hand it to the main process after checking it is a real web URL.
|
||||
ipcRenderer.on("j621:open-backend", () => {
|
||||
let url = "";
|
||||
try {
|
||||
url = window.localStorage.getItem("j621.backend") ?? "";
|
||||
} catch {
|
||||
// Storage can be unavailable in rare cases; nothing to open then.
|
||||
}
|
||||
if (url === "none") url = "";
|
||||
if (url) void ipcRenderer.invoke("j621:open-external", url);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -41,11 +41,17 @@ async function testBackend(base: string): Promise<TestResult> {
|
||||
text: `The backend answered HTTP ${response.status}.`,
|
||||
};
|
||||
} catch {
|
||||
// The desktop shell is a different origin from the backend, and that is
|
||||
// the usual reason the first connection test fails there.
|
||||
const desktopHint = window.j621Desktop
|
||||
? ` The desktop app's origin is ${window.j621Desktop.origin} — allow it in CORS_ALLOWED_ORIGINS on the backend.`
|
||||
: "";
|
||||
return {
|
||||
ok: false,
|
||||
text:
|
||||
"Could not reach the backend. Check the URL, and if it lives on " +
|
||||
"another domain add this site to CORS_ALLOWED_ORIGINS there.",
|
||||
"another domain add this site to CORS_ALLOWED_ORIGINS there." +
|
||||
desktopHint,
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
/** Baked at build time by Vite (see vite.config.ts). */
|
||||
declare const __GIT_HASH__: string;
|
||||
|
||||
/**
|
||||
* Injected by the Electron shell (desktop/src/preload.ts) when it hosts the
|
||||
* SPA. Absent in browsers.
|
||||
*/
|
||||
interface J621DesktopBridge {
|
||||
readonly isDesktop: true;
|
||||
readonly platform: string;
|
||||
/** The shell's origin (e.g. `app://j621`), for backend CORS hints. */
|
||||
readonly origin: string;
|
||||
getVersion(): Promise<string>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
j621Desktop?: J621DesktopBridge;
|
||||
}
|
||||
|
||||