feat: Add browser documentation
This commit is contained in:
104
electron/api/docusaurus-static.ts
Normal file
104
electron/api/docusaurus-static.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { HttpError } from './http-helpers';
|
||||
|
||||
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
function getDocsRootCandidates(): string[] {
|
||||
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (processWithResources.resourcesPath) {
|
||||
candidates.push(path.join(processWithResources.resourcesPath, 'docusaurus'));
|
||||
}
|
||||
|
||||
candidates.push(path.join(process.cwd(), 'docs-site', 'build'));
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function getDocusaurusBuildRoot(): Promise<string | null> {
|
||||
for (const candidate of getDocsRootCandidates()) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function resolveAssetPath(root: string, pathname: string): string {
|
||||
const withoutPrefix = pathname.replace(/^\/docusaurus\/?/u, '');
|
||||
const decoded = decodeURIComponent(withoutPrefix);
|
||||
const normalized = decoded.endsWith('/') || decoded === '' ? path.join(decoded, 'index.html') : decoded;
|
||||
const absolutePath = path.resolve(root, normalized);
|
||||
|
||||
if (!isPathInside(root, absolutePath)) {
|
||||
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePath: string; contentType: string }> {
|
||||
const root = await getDocusaurusBuildRoot();
|
||||
|
||||
if (!root) {
|
||||
throw new HttpError(503, 'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.', 'DOCUSAURUS_BUILD_MISSING');
|
||||
}
|
||||
|
||||
let filePath = resolveAssetPath(root, pathname);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
} catch {
|
||||
const directoryIndexPath = path.join(filePath, 'index.html');
|
||||
|
||||
try {
|
||||
await fs.access(directoryIndexPath);
|
||||
filePath = directoryIndexPath;
|
||||
} catch {
|
||||
filePath = path.join(root, '404.html');
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPathInside(root, filePath)) {
|
||||
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
|
||||
}
|
||||
|
||||
const contentType = MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
|
||||
return { filePath, contentType };
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import {
|
||||
createServer,
|
||||
IncomingMessage,
|
||||
Server,
|
||||
ServerResponse
|
||||
} from 'http';
|
||||
import { createReadStream } from 'fs';
|
||||
import { AddressInfo } from 'net';
|
||||
import { pipeline } from 'stream/promises';
|
||||
@@ -26,6 +31,7 @@ export interface LocalApiSnapshot {
|
||||
error: string | null;
|
||||
exposeOnLan: boolean;
|
||||
scalarEnabled: boolean;
|
||||
docusaurusEnabled: boolean;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
@@ -41,6 +47,7 @@ function pickBindHost(settings: LocalApiSettings): string {
|
||||
|
||||
function buildBaseUrl(host: string, port: number): string {
|
||||
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
||||
|
||||
return `http://${safeHost}:${port}`;
|
||||
}
|
||||
|
||||
@@ -64,7 +71,8 @@ export function getLocalApiSnapshot(): LocalApiSnapshot {
|
||||
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
|
||||
error: currentError,
|
||||
exposeOnLan: settings.exposeOnLan,
|
||||
scalarEnabled: settings.scalarEnabled
|
||||
scalarEnabled: settings.scalarEnabled,
|
||||
docusaurusEnabled: settings.docusaurusEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +109,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
|
||||
remoteAddress: req.socket.remoteAddress ?? '',
|
||||
bearerToken: getBearerToken(req.headers)
|
||||
};
|
||||
|
||||
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
|
||||
|
||||
if (!match) {
|
||||
@@ -110,6 +117,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
|
||||
} else {
|
||||
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,9 +130,9 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
|
||||
}
|
||||
}
|
||||
|
||||
const dataSource = getDataSource();
|
||||
const dataSource = getDataSource() ?? null;
|
||||
|
||||
if (!dataSource || !dataSource.isInitialized) {
|
||||
if (match.requiresDatabase && (!dataSource || !dataSource.isInitialized)) {
|
||||
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
|
||||
return;
|
||||
}
|
||||
@@ -142,6 +150,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
|
||||
if (bodyCache === undefined) {
|
||||
bodyCache = await readJsonBody<unknown>(req);
|
||||
}
|
||||
|
||||
return bodyCache;
|
||||
}
|
||||
});
|
||||
@@ -167,6 +176,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
|
||||
if (!(error instanceof HttpError)) {
|
||||
console.error('[LocalApi] Request handler error:', error);
|
||||
}
|
||||
|
||||
sendError(res, error);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +200,7 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
|
||||
const httpServer = createServer((req, res) => {
|
||||
void handleRequest(req, res, activeSettings!).catch((error) => {
|
||||
console.error('[LocalApi] Unhandled request error:', error);
|
||||
|
||||
try {
|
||||
sendError(res, error);
|
||||
} catch {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store
|
||||
import { buildOpenApiDocument } from './openapi';
|
||||
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
|
||||
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
|
||||
import { resolveDocusaurusRoute } from './docusaurus-static';
|
||||
import { LocalApiSettings } from '../desktop-settings';
|
||||
|
||||
export interface RouteResponse {
|
||||
@@ -20,7 +21,7 @@ export interface RouteContext {
|
||||
request: RequestContext;
|
||||
settings: LocalApiSettings;
|
||||
baseUrl: string;
|
||||
dataSource: DataSource;
|
||||
dataSource: DataSource | null;
|
||||
bodyBuffer: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ interface RouteMatch {
|
||||
handler: RouteHandler;
|
||||
params: Record<string, string>;
|
||||
requiresAuth: boolean;
|
||||
requiresDatabase: boolean;
|
||||
}
|
||||
|
||||
interface RouteDefinition {
|
||||
@@ -38,6 +40,7 @@ interface RouteDefinition {
|
||||
paramKeys: string[];
|
||||
handler: RouteHandler;
|
||||
requiresAuth: boolean;
|
||||
requiresDatabase: boolean;
|
||||
}
|
||||
|
||||
function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } {
|
||||
@@ -56,10 +59,10 @@ function compilePattern(template: string): { pattern: RegExp; paramKeys: string[
|
||||
return { pattern: new RegExp(`^${source}$`), paramKeys };
|
||||
}
|
||||
|
||||
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean): RouteDefinition {
|
||||
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean, requiresDatabase = true): RouteDefinition {
|
||||
const compiled = compilePattern(template);
|
||||
|
||||
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth };
|
||||
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth, requiresDatabase };
|
||||
}
|
||||
|
||||
function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
|
||||
@@ -73,6 +76,14 @@ function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
|
||||
return handler(query) as Promise<T>;
|
||||
}
|
||||
|
||||
function requireDataSource(dataSource: DataSource | null): DataSource {
|
||||
if (!dataSource) {
|
||||
throw new HttpError(503, 'Database not initialised', 'DB_UNAVAILABLE');
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const parsed = typeof value === 'string' ? Number(value) : NaN;
|
||||
|
||||
@@ -86,12 +97,12 @@ const ROUTES: RouteDefinition[] = [
|
||||
defineRoute('GET', '/api/health', async (ctx): Promise<RouteResponse> => ({
|
||||
status: 200,
|
||||
body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan }
|
||||
}), false),
|
||||
}), false, false),
|
||||
|
||||
defineRoute('GET', '/api/openapi.json', async (ctx): Promise<RouteResponse> => ({
|
||||
status: 200,
|
||||
body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() })
|
||||
}), false),
|
||||
}), false, false),
|
||||
|
||||
defineRoute('GET', '/docs', async (ctx): Promise<RouteResponse> => {
|
||||
if (!ctx.settings.scalarEnabled) {
|
||||
@@ -109,7 +120,27 @@ const ROUTES: RouteDefinition[] = [
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`)
|
||||
};
|
||||
}, false),
|
||||
}, false, false),
|
||||
|
||||
defineRoute('GET', '/docusaurus(?:/.*)?', async (ctx): Promise<RouteResponse> => {
|
||||
if (!ctx.settings.docusaurusEnabled) {
|
||||
return {
|
||||
status: 404,
|
||||
body: null,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
rawBody: 'Docusaurus documentation is disabled. Open documentation from the desktop app to activate it.'
|
||||
};
|
||||
}
|
||||
|
||||
const docsRoute = await resolveDocusaurusRoute(ctx.request.pathname);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: null,
|
||||
contentType: docsRoute.contentType,
|
||||
filePath: docsRoute.filePath
|
||||
};
|
||||
}, false, false),
|
||||
|
||||
defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise<RouteResponse> => {
|
||||
if (!ctx.settings.scalarEnabled) {
|
||||
@@ -133,7 +164,7 @@ const ROUTES: RouteDefinition[] = [
|
||||
contentType: 'application/javascript; charset=utf-8',
|
||||
filePath: bundlePath
|
||||
};
|
||||
}, false),
|
||||
}, false, false),
|
||||
|
||||
defineRoute('POST', '/api/auth/login', async (ctx): Promise<RouteResponse> => {
|
||||
const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown };
|
||||
@@ -213,7 +244,7 @@ const ROUTES: RouteDefinition[] = [
|
||||
}, true),
|
||||
|
||||
defineRoute('GET', '/api/profile', async (ctx): Promise<RouteResponse> => {
|
||||
const user = await runQuery<unknown>(ctx.dataSource, {
|
||||
const user = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
|
||||
type: QueryType.GetCurrentUser,
|
||||
payload: {}
|
||||
});
|
||||
@@ -226,7 +257,7 @@ const ROUTES: RouteDefinition[] = [
|
||||
}, true),
|
||||
|
||||
defineRoute('GET', '/api/rooms', async (ctx): Promise<RouteResponse> => {
|
||||
const rooms = await runQuery<unknown[]>(ctx.dataSource, {
|
||||
const rooms = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
|
||||
type: QueryType.GetAllRooms,
|
||||
payload: {}
|
||||
});
|
||||
@@ -243,7 +274,7 @@ const ROUTES: RouteDefinition[] = [
|
||||
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
|
||||
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
|
||||
|
||||
const messages = await runQuery<unknown[]>(ctx.dataSource, {
|
||||
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
|
||||
type: QueryType.GetMessages,
|
||||
payload: { roomId: decodeURIComponent(roomId), limit, offset }
|
||||
});
|
||||
@@ -278,7 +309,7 @@ export function matchRoute(method: string, pathname: string): RoutingResult {
|
||||
}
|
||||
|
||||
return {
|
||||
match: { handler: route.handler, params, requiresAuth: route.requiresAuth },
|
||||
match: { handler: route.handler, params, requiresAuth: route.requiresAuth, requiresDatabase: route.requiresDatabase },
|
||||
methodNotAllowed: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LocalApiSettings {
|
||||
port: number;
|
||||
exposeOnLan: boolean;
|
||||
scalarEnabled: boolean;
|
||||
docusaurusEnabled: boolean;
|
||||
allowedSignalingServers: string[];
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
|
||||
port: 17878,
|
||||
exposeOnLan: false,
|
||||
scalarEnabled: false,
|
||||
docusaurusEnabled: false,
|
||||
allowedSignalingServers: []
|
||||
};
|
||||
|
||||
@@ -129,6 +131,7 @@ function normalizeLocalApiSettings(value: unknown): LocalApiSettings {
|
||||
port: normalizePort(source.port, DEFAULT_LOCAL_API_SETTINGS.port),
|
||||
exposeOnLan: typeof source.exposeOnLan === 'boolean' ? source.exposeOnLan : DEFAULT_LOCAL_API_SETTINGS.exposeOnLan,
|
||||
scalarEnabled: typeof source.scalarEnabled === 'boolean' ? source.scalarEnabled : DEFAULT_LOCAL_API_SETTINGS.scalarEnabled,
|
||||
docusaurusEnabled: typeof source.docusaurusEnabled === 'boolean' ? source.docusaurusEnabled : DEFAULT_LOCAL_API_SETTINGS.docusaurusEnabled,
|
||||
allowedSignalingServers: normalizeAllowedSignalingServers(source.allowedSignalingServers)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -477,6 +477,35 @@ export function setupSystemHandlers(): void {
|
||||
return { opened: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('open-docusaurus-docs', async () => {
|
||||
let snapshot = getLocalApiSnapshot();
|
||||
|
||||
if (snapshot.status !== 'running' || !snapshot.baseUrl || !snapshot.docusaurusEnabled) {
|
||||
const currentSettings = getDesktopSettingsSnapshot();
|
||||
|
||||
updateDesktopSettings({
|
||||
localApi: {
|
||||
...currentSettings.localApi,
|
||||
enabled: true,
|
||||
docusaurusEnabled: true
|
||||
}
|
||||
});
|
||||
await applyLocalApiSettings();
|
||||
snapshot = getLocalApiSnapshot();
|
||||
}
|
||||
|
||||
if (snapshot.status !== 'running' || !snapshot.baseUrl) {
|
||||
return { opened: false, reason: snapshot.error ?? 'Local documentation server is not running' };
|
||||
}
|
||||
|
||||
if (!snapshot.docusaurusEnabled) {
|
||||
return { opened: false, reason: 'Docusaurus docs are disabled' };
|
||||
}
|
||||
|
||||
await shell.openExternal(`${snapshot.baseUrl}/docusaurus/`);
|
||||
return { opened: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('relaunch-app', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface LocalApiSettings {
|
||||
port: number;
|
||||
exposeOnLan: boolean;
|
||||
scalarEnabled: boolean;
|
||||
docusaurusEnabled: boolean;
|
||||
allowedSignalingServers: string[];
|
||||
}
|
||||
|
||||
@@ -137,6 +138,7 @@ export interface LocalApiSnapshot {
|
||||
error: string | null;
|
||||
exposeOnLan: boolean;
|
||||
scalarEnabled: boolean;
|
||||
docusaurusEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface LocalPluginDiscoveryError {
|
||||
@@ -275,6 +277,7 @@ export interface ElectronAPI {
|
||||
}>;
|
||||
getLocalApiStatus: () => Promise<LocalApiSnapshot>;
|
||||
openLocalApiDocs: () => Promise<{ opened: boolean; reason?: string }>;
|
||||
openDocusaurusDocs: () => Promise<{ opened: boolean; reason?: string }>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
@@ -386,6 +389,7 @@ const electronAPI: ElectronAPI = {
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
getLocalApiStatus: () => ipcRenderer.invoke('get-local-api-status'),
|
||||
openLocalApiDocs: () => ipcRenderer.invoke('open-local-api-docs'),
|
||||
openDocusaurusDocs: () => ipcRenderer.invoke('open-docusaurus-docs'),
|
||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||
onDeepLinkReceived: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||
|
||||
Reference in New Issue
Block a user