From 438345164474e6afe8cfe914e5895f829ee4d09c Mon Sep 17 00:00:00 2001 From: "kozhin.iv" Date: Sun, 21 Jun 2026 23:19:58 +0500 Subject: [PATCH] Add desktop runtime settings --- README.md | 7 +- apps/api/src/app.ts | 3 +- apps/api/src/mock-routes.test.ts | 21 +++ apps/api/src/mock-routes.ts | 38 ++++- apps/desktop/src/main/backend.test.ts | 82 +++++++++- apps/desktop/src/main/backend.ts | 15 +- apps/desktop/src/main/runtime-settings.ts | 145 ++++++++++++++++++ apps/web/src/App.tsx | 5 + apps/web/src/app/desktop-settings-actions.ts | 54 +++++++ apps/web/src/app/desktop-settings.ts | 62 ++++++++ apps/web/src/app/useAppConfig.ts | 26 +++- apps/web/src/app/useDesktopSettings.ts | 74 +++++++++ apps/web/src/app/useSpecDockController.ts | 3 + apps/web/src/app/useSpecDockState.ts | 37 ++--- .../DesktopRuntimeSettingsSection.tsx | 129 ++++++++++++++++ apps/web/src/components/SettingsDialog.tsx | 27 +++- .../src/styles/settings-workspace-blocks.css | 27 ++++ apps/web/src/vite-env.d.ts | 9 ++ docs/DESKTOP.md | 36 +++++ packages/core/src/api-types.ts | 20 +++ 20 files changed, 773 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/main/runtime-settings.ts create mode 100644 apps/web/src/app/desktop-settings-actions.ts create mode 100644 apps/web/src/app/desktop-settings.ts create mode 100644 apps/web/src/app/useDesktopSettings.ts create mode 100644 apps/web/src/components/DesktopRuntimeSettingsSection.tsx diff --git a/README.md b/README.md index e49a719..b78edfe 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,11 @@ Check health: curl -fsS http://127.0.0.1:3000/api/health ``` -Use immutable version tags such as `docker.io/d8vik/specdock:v1.0.0`. -The project does not rely on `latest` for releases. +Use immutable version tags such as `docker.io/d8vik/specdock:v1.0.0`; the project does not rely on `latest` for releases. + +## Desktop + +Download desktop installers from [GitHub Releases](https://github.com/dev-ik/specdock/releases). The desktop app runs the SpecDock API on `127.0.0.1` with proxy and mock features disabled by default; `Settings -> Desktop runtime` controls local mock/proxy settings. See [Desktop](docs/DESKTOP.md) for packaging and release workflow details. ## Configuration diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1e101c5..523adcf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -25,6 +25,7 @@ export type AppOptions = { fetchImplementation?: typeof fetch; generationRunner?: GenerationRunner; logger?: boolean; + mockRoutesMode?: "auto" | "runtime"; webDistDir?: string | null; }; @@ -77,7 +78,7 @@ export const buildApp = (options: AppOptions = {}) => { registerGenerateRoutes(app, options.generationRunner); registerProxyRoute(app, fetchImplementation); - if (resolveMockServerConfig().enabled) { + if (options.mockRoutesMode === "runtime" || resolveMockServerConfig().enabled) { registerMockRoutes(app); } diff --git a/apps/api/src/mock-routes.test.ts b/apps/api/src/mock-routes.test.ts index f8f355d..eddb045 100644 --- a/apps/api/src/mock-routes.test.ts +++ b/apps/api/src/mock-routes.test.ts @@ -50,6 +50,27 @@ describe("mock routes", () => { } }); + it("supports runtime mock route enablement", async () => { + vi.stubEnv("MOCK_SERVER_ENABLED", "false"); + const app = buildApp({ + logger: false, + mockRoutesMode: "runtime", + webDistDir: null + }); + + try { + const disabled = await app.inject(mockRequest()); + + vi.stubEnv("MOCK_SERVER_ENABLED", "true"); + const enabled = await app.inject(mockRequest()); + + expect(disabled.statusCode).toBe(404); + expect(enabled.statusCode).toBe(200); + } finally { + await app.close(); + } + }); + it("serves saved live mock routes when enabled", async () => { vi.stubEnv("MOCK_SERVER_ENABLED", "true"); const app = buildApp({ logger: false, webDistDir: null }); diff --git a/apps/api/src/mock-routes.ts b/apps/api/src/mock-routes.ts index 0ceaf17..83ff9ab 100644 --- a/apps/api/src/mock-routes.ts +++ b/apps/api/src/mock-routes.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply } from "fastify"; import { createMockResponse, normalizeSpec, @@ -8,7 +8,7 @@ import { type OpenApiProject } from "@specdock/core"; import { sendError } from "./errors.js"; -import { resolveMockServerConfig } from "./mock-config.js"; +import { resolveMockServerConfig, type MockServerConfig } from "./mock-config.js"; import { createMockRouteRegistry, liveMockPath } from "./mock-registry.js"; import { createRateLimit } from "./rate-limit.js"; import { @@ -27,7 +27,10 @@ export const registerMockRoutes = (app: FastifyInstance): void => { "/api/mock/response", { schema: mockResponseRequestSchema, preHandler: mockRateLimit }, async (request, reply) => { - const config = resolveMockServerConfig(); + const config = ensureMockServerEnabled(reply); + + if (!config) return; + const project = buildMockProject(request.body.spec); const response = createMockResponse(project, request.body); @@ -57,7 +60,9 @@ export const registerMockRoutes = (app: FastifyInstance): void => { "/api/mock/routes", { schema: mockRouteUpsertSchema, preHandler: mockRateLimit }, async (request, reply) => { - const config = resolveMockServerConfig(); + const config = ensureMockServerEnabled(reply); + + if (!config) return; if (Buffer.byteLength(request.body.body, "utf8") > config.maxResponseBodyBytes) { return sendError( @@ -74,11 +79,19 @@ export const registerMockRoutes = (app: FastifyInstance): void => { } ); - app.get("/api/mock/routes", async () => ({ - routes: registry.list() - })); + app.get("/api/mock/routes", async (_request, reply) => { + const config = ensureMockServerEnabled(reply); + + if (!config) return; + + return { routes: registry.list() }; + }); app.all("/mock/*", async (request, reply) => { + const config = ensureMockServerEnabled(reply); + + if (!config) return; + const route = registry.find( request.method.toUpperCase() as HttpMethod, liveMockPath(request.url) @@ -96,6 +109,17 @@ export const registerMockRoutes = (app: FastifyInstance): void => { }); }; +const ensureMockServerEnabled = (reply: FastifyReply): MockServerConfig | undefined => { + const config = resolveMockServerConfig(); + + if (!config.enabled) { + sendError(reply, 404, "NOT_FOUND", "Route not found."); + return undefined; + } + + return config; +}; + const buildMockProject = (spec: unknown): OpenApiProject => { const normalized = normalizeSpec(spec); diff --git a/apps/desktop/src/main/backend.test.ts b/apps/desktop/src/main/backend.test.ts index 8cbde32..063b03d 100644 --- a/apps/desktop/src/main/backend.test.ts +++ b/apps/desktop/src/main/backend.test.ts @@ -1,8 +1,11 @@ +import { LIMITS } from "@specdock/core"; import { describe, expect, it } from "vitest"; +import { buildApp } from "../../../api/src/app.js"; import { createDesktopApiEnv, DESKTOP_API_HOST, - formatDesktopApiBaseUrl + formatDesktopApiBaseUrl, + registerDesktopSettingsRoutes } from "./backend.js"; describe("desktop backend", () => { @@ -23,6 +26,12 @@ describe("desktop backend", () => { expect(env.APP_PORT).toBe("43125"); expect(env.PORT).toBe("43125"); expect(env.PROXY_ENABLED).toBe("false"); + expect(env.PROXY_ALLOWED_HOSTS).toBe(""); + expect(env.PROXY_ALLOW_PRIVATE_TARGETS).toBe("false"); + expect(env.PROXY_MAX_REQUEST_BYTES).toBe(String(LIMITS.maxProxyRequestBodyBytes)); + expect(env.PROXY_MAX_RESPONSE_BYTES).toBe(String(LIMITS.maxProxyResponseBodyBytes)); + expect(env.PROXY_TIMEOUT_MS).toBe(String(LIMITS.proxyTimeoutMs)); + expect(env.MOCK_MAX_RESPONSE_BYTES).toBe(String(LIMITS.maxProxyResponseBodyBytes)); expect(env.MOCK_SERVER_ENABLED).toBe("false"); expect(env.TRUST_PROXY).toBe("false"); expect(env.WEB_DIST_DIR).toBe("/tmp/specdock-web-dist"); @@ -31,4 +40,75 @@ describe("desktop backend", () => { it("formats a loopback-only API base URL", () => { expect(formatDesktopApiBaseUrl(43126)).toBe("http://127.0.0.1:43126"); }); + + it("updates desktop mock server settings at runtime", async () => { + process.env.MOCK_MAX_RESPONSE_BYTES = String(LIMITS.maxProxyResponseBodyBytes); + process.env.MOCK_SERVER_ENABLED = "false"; + process.env.PROXY_ENABLED = "false"; + process.env.PROXY_ALLOWED_HOSTS = ""; + process.env.PROXY_ALLOW_PRIVATE_TARGETS = "false"; + process.env.PROXY_MAX_RESPONSE_BYTES = String(LIMITS.maxProxyResponseBodyBytes); + process.env.PROXY_TIMEOUT_MS = String(LIMITS.proxyTimeoutMs); + const app = buildApp({ + logger: false, + mockRoutesMode: "runtime", + webDistDir: null + }); + registerDesktopSettingsRoutes(app); + + try { + const initialSettings = await app.inject("/api/desktop/settings"); + const initialConfig = await app.inject("/api/config"); + const updatedSettings = await app.inject({ + url: "/api/desktop/settings", + method: "PATCH", + payload: { + mockServerEnabled: true, + mockMaxResponseBytes: 2048, + proxyAllowPrivateTargets: true, + proxyAllowedHosts: " Example.COM, api.example.com ", + proxyEnabled: true, + proxyMaxResponseBytes: 4096, + proxyTimeoutMs: 750 + } + }); + const updatedConfig = await app.inject("/api/config"); + + expect(initialSettings.json()).toEqual({ + mockMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + mockServerEnabled: false, + proxyAllowPrivateTargets: false, + proxyAllowedHosts: "", + proxyEnabled: false, + proxyMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + proxyTimeoutMs: LIMITS.proxyTimeoutMs + }); + expect(initialConfig.json()).toMatchObject({ mockServer: { enabled: false } }); + expect(updatedSettings.json()).toEqual({ + mockMaxResponseBytes: 2048, + mockServerEnabled: true, + proxyAllowPrivateTargets: true, + proxyAllowedHosts: "example.com,api.example.com", + proxyEnabled: true, + proxyMaxResponseBytes: 4096, + proxyTimeoutMs: 750 + }); + expect(updatedConfig.json()).toMatchObject({ mockServer: { enabled: true } }); + expect(process.env.MOCK_MAX_RESPONSE_BYTES).toBe("2048"); + expect(process.env.PROXY_ENABLED).toBe("true"); + expect(process.env.PROXY_ALLOWED_HOSTS).toBe("example.com,api.example.com"); + expect(process.env.PROXY_ALLOW_PRIVATE_TARGETS).toBe("true"); + expect(process.env.PROXY_MAX_RESPONSE_BYTES).toBe("4096"); + expect(process.env.PROXY_TIMEOUT_MS).toBe("750"); + } finally { + process.env.MOCK_MAX_RESPONSE_BYTES = String(LIMITS.maxProxyResponseBodyBytes); + process.env.MOCK_SERVER_ENABLED = "false"; + process.env.PROXY_ENABLED = "false"; + process.env.PROXY_ALLOWED_HOSTS = ""; + process.env.PROXY_ALLOW_PRIVATE_TARGETS = "false"; + process.env.PROXY_MAX_RESPONSE_BYTES = String(LIMITS.maxProxyResponseBodyBytes); + process.env.PROXY_TIMEOUT_MS = String(LIMITS.proxyTimeoutMs); + await app.close(); + } + }); }); diff --git a/apps/desktop/src/main/backend.ts b/apps/desktop/src/main/backend.ts index 48794a9..e6bb173 100644 --- a/apps/desktop/src/main/backend.ts +++ b/apps/desktop/src/main/backend.ts @@ -14,6 +14,13 @@ import type { GenerationResult, GenerationRunner } from "../../../api/src/generation-runner.js"; +import { + applyDesktopRuntimeEnv, + createDesktopRuntimeEnv, + registerDesktopSettingsRoutes +} from "./runtime-settings.js"; + +export { registerDesktopSettingsRoutes } from "./runtime-settings.js"; export const DESKTOP_API_HOST = "127.0.0.1"; @@ -39,12 +46,11 @@ export function createDesktopApiEnv( ): NodeJS.ProcessEnv { return { ...baseEnv, + ...createDesktopRuntimeEnv(), APP_IP: DESKTOP_API_HOST, HOST: DESKTOP_API_HOST, APP_PORT: String(port), PORT: String(port), - PROXY_ENABLED: "false", - MOCK_SERVER_ENABLED: "false", TRUST_PROXY: "false", WEB_DIST_DIR: webDistDir }; @@ -85,8 +91,10 @@ export async function startDesktopApi( const server = buildApp({ generationRunner: runDesktopGenerationJob, logger: false, + mockRoutesMode: "runtime", webDistDir: options.webDistDir }); + registerDesktopSettingsRoutes(server); await server.listen({ port, host: DESKTOP_API_HOST }); @@ -101,8 +109,7 @@ function applyDesktopApiEnv(env: NodeJS.ProcessEnv): void { process.env.HOST = env.HOST; process.env.APP_PORT = env.APP_PORT; process.env.PORT = env.PORT; - process.env.PROXY_ENABLED = env.PROXY_ENABLED; - process.env.MOCK_SERVER_ENABLED = env.MOCK_SERVER_ENABLED; + applyDesktopRuntimeEnv(env); process.env.TRUST_PROXY = env.TRUST_PROXY; process.env.WEB_DIST_DIR = env.WEB_DIST_DIR; } diff --git a/apps/desktop/src/main/runtime-settings.ts b/apps/desktop/src/main/runtime-settings.ts new file mode 100644 index 0000000..b4e260f --- /dev/null +++ b/apps/desktop/src/main/runtime-settings.ts @@ -0,0 +1,145 @@ +import type { FastifyInstance } from "fastify"; +import { + LIMITS, + type DesktopRuntimeSettings, + type DesktopRuntimeSettingsPatch +} from "@specdock/core"; + +export const desktopRuntimeDefaults = { + mockMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + proxyMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + proxyTimeoutMs: LIMITS.proxyTimeoutMs +} as const; + +export function createDesktopRuntimeEnv(): NodeJS.ProcessEnv { + return { + MOCK_MAX_RESPONSE_BYTES: String(desktopRuntimeDefaults.mockMaxResponseBytes), + MOCK_SERVER_ENABLED: "false", + PROXY_ALLOWED_HOSTS: "", + PROXY_ALLOW_PRIVATE_TARGETS: "false", + PROXY_ENABLED: "false", + PROXY_MAX_REQUEST_BYTES: String(LIMITS.maxProxyRequestBodyBytes), + PROXY_MAX_RESPONSE_BYTES: String(desktopRuntimeDefaults.proxyMaxResponseBytes), + PROXY_TIMEOUT_MS: String(desktopRuntimeDefaults.proxyTimeoutMs) + }; +} + +export function applyDesktopRuntimeEnv(env: NodeJS.ProcessEnv): void { + process.env.MOCK_MAX_RESPONSE_BYTES = env.MOCK_MAX_RESPONSE_BYTES; + process.env.MOCK_SERVER_ENABLED = env.MOCK_SERVER_ENABLED; + process.env.PROXY_ALLOWED_HOSTS = env.PROXY_ALLOWED_HOSTS; + process.env.PROXY_ALLOW_PRIVATE_TARGETS = env.PROXY_ALLOW_PRIVATE_TARGETS; + process.env.PROXY_ENABLED = env.PROXY_ENABLED; + process.env.PROXY_MAX_REQUEST_BYTES = env.PROXY_MAX_REQUEST_BYTES; + process.env.PROXY_MAX_RESPONSE_BYTES = env.PROXY_MAX_RESPONSE_BYTES; + process.env.PROXY_TIMEOUT_MS = env.PROXY_TIMEOUT_MS; +} + +export function registerDesktopSettingsRoutes(server: FastifyInstance): void { + server.get("/api/desktop/settings", async (): Promise => + readDesktopRuntimeSettings() + ); + + server.patch<{ Body: DesktopRuntimeSettingsPatch }>( + "/api/desktop/settings", + { + schema: { + body: { + type: "object", + additionalProperties: false, + properties: { + mockMaxResponseBytes: maxBytesSchema, + mockServerEnabled: { type: "boolean" }, + proxyAllowPrivateTargets: { type: "boolean" }, + proxyAllowedHosts: { type: "string", maxLength: 2000 }, + proxyEnabled: { type: "boolean" }, + proxyMaxResponseBytes: maxBytesSchema, + proxyTimeoutMs: { + type: "integer", + minimum: 1, + maximum: LIMITS.proxyTimeoutMs + } + } + } + } + }, + async (request): Promise => { + applyDesktopSettingsPatch(request.body); + return readDesktopRuntimeSettings(); + } + ); +} + +function readDesktopRuntimeSettings(): DesktopRuntimeSettings { + return { + mockMaxResponseBytes: readLimitedInteger( + process.env.MOCK_MAX_RESPONSE_BYTES, + desktopRuntimeDefaults.mockMaxResponseBytes + ), + mockServerEnabled: process.env.MOCK_SERVER_ENABLED === "true", + proxyAllowPrivateTargets: process.env.PROXY_ALLOW_PRIVATE_TARGETS === "true", + proxyAllowedHosts: process.env.PROXY_ALLOWED_HOSTS ?? "", + proxyEnabled: process.env.PROXY_ENABLED === "true", + proxyMaxResponseBytes: readLimitedInteger( + process.env.PROXY_MAX_RESPONSE_BYTES, + desktopRuntimeDefaults.proxyMaxResponseBytes + ), + proxyTimeoutMs: readLimitedInteger( + process.env.PROXY_TIMEOUT_MS, + desktopRuntimeDefaults.proxyTimeoutMs + ) + }; +} + +function applyDesktopSettingsPatch(patch: DesktopRuntimeSettingsPatch): void { + if (typeof patch.mockMaxResponseBytes === "number") { + process.env.MOCK_MAX_RESPONSE_BYTES = String( + readLimitedInteger(String(patch.mockMaxResponseBytes), desktopRuntimeDefaults.mockMaxResponseBytes) + ); + } + if (typeof patch.mockServerEnabled === "boolean") { + process.env.MOCK_SERVER_ENABLED = patch.mockServerEnabled ? "true" : "false"; + } + if (typeof patch.proxyEnabled === "boolean") { + process.env.PROXY_ENABLED = patch.proxyEnabled ? "true" : "false"; + } + if (typeof patch.proxyAllowedHosts === "string") { + process.env.PROXY_ALLOWED_HOSTS = sanitizeAllowedHosts(patch.proxyAllowedHosts); + } + if (typeof patch.proxyAllowPrivateTargets === "boolean") { + process.env.PROXY_ALLOW_PRIVATE_TARGETS = patch.proxyAllowPrivateTargets ? "true" : "false"; + } + if (typeof patch.proxyMaxResponseBytes === "number") { + process.env.PROXY_MAX_RESPONSE_BYTES = String( + readLimitedInteger(String(patch.proxyMaxResponseBytes), desktopRuntimeDefaults.proxyMaxResponseBytes) + ); + } + if (typeof patch.proxyTimeoutMs === "number") { + process.env.PROXY_TIMEOUT_MS = String( + readLimitedInteger(String(patch.proxyTimeoutMs), desktopRuntimeDefaults.proxyTimeoutMs) + ); + } +} + +function sanitizeAllowedHosts(value: string): string { + return value + .split(",") + .map((host) => host.trim().toLowerCase()) + .filter(Boolean) + .join(","); +} + +function readLimitedInteger(value: string | undefined, fallback: number): number { + if (!value) return fallback; + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback; + + return Math.min(parsed, fallback); +} + +const maxBytesSchema = { + type: "integer", + minimum: 1, + maximum: LIMITS.maxProxyResponseBodyBytes +} as const; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index deee7d0..80e7f81 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -86,6 +86,9 @@ export const App = () => { historyCount={app.historyCount} authProfiles={app.projectAuthProfiles} hiddenPanelIds={panelLayout.hiddenPanelIds} + desktopSettingsAvailable={app.desktopSettingsAvailable} + desktopSettings={app.desktopSettings} + defaultRequestMode={app.defaultRequestMode} onClose={() => setIsSettingsOpen(false)} onClearHistory={app.clearRequestHistory} onPanelVisibilityChange={panelLayout.setPanelVisibility} @@ -93,6 +96,8 @@ export const App = () => { onAddAuthProfile={app.addAuthProfile} onUpdateAuthProfile={app.updateAuthProfile} onDeleteAuthProfile={app.deleteAuthProfile} + onDesktopSettingsChange={(patch) => void app.updateDesktopSettings(patch)} + onDefaultRequestModeChange={app.updateDefaultRequestMode} /> ); diff --git a/apps/web/src/app/desktop-settings-actions.ts b/apps/web/src/app/desktop-settings-actions.ts new file mode 100644 index 0000000..efd9316 --- /dev/null +++ b/apps/web/src/app/desktop-settings-actions.ts @@ -0,0 +1,54 @@ +import type { DesktopRuntimeSettings, RequestState } from "@specdock/core"; +import { + desktopSettingsStorageKey, + updateDesktopRuntimeSettings, + withDefaultDesktopSettings +} from "./desktop-settings.js"; +import { writeLocalJson } from "./local-storage.js"; +import type { useSpecDockState } from "./useSpecDockState.js"; + +type State = ReturnType; + +export const createDesktopSettingsActions = (state: State) => ({ + updateDefaultRequestMode: (mode: RequestState["requestMode"]) => { + state.setDefaultRequestMode(mode); + state.setRequestStates((current) => syncRequestMode(current, mode)); + state.storageAdapter.saveSettings({ + ...state.storageAdapter.getSettings(), + defaultRequestMode: mode + }); + state.setStatus(`Default request mode set to ${mode}`); + }, + updateDesktopSettings: async (patch: Partial) => { + const nextSettings = withDefaultDesktopSettings(state.desktopSettings, patch); + + state.setDesktopSettings(nextSettings); + writeLocalJson(desktopSettingsStorageKey, nextSettings); + try { + const saved = await updateDesktopRuntimeSettings(nextSettings); + + state.setDesktopSettings(saved); + writeLocalJson(desktopSettingsStorageKey, saved); + await state.reloadAppConfig(); + state.setMockServerState({}); + state.setStatus("Desktop settings updated"); + } catch (error) { + state.setStatus( + error instanceof Error + ? error.message + : "Unable to update desktop settings." + ); + } + } +}); + +const syncRequestMode = ( + states: Record, + requestMode: RequestState["requestMode"] +): Record => + Object.fromEntries( + Object.entries(states).map(([key, value]) => [ + key, + { ...value, requestMode } + ]) + ); diff --git a/apps/web/src/app/desktop-settings.ts b/apps/web/src/app/desktop-settings.ts new file mode 100644 index 0000000..2b15662 --- /dev/null +++ b/apps/web/src/app/desktop-settings.ts @@ -0,0 +1,62 @@ +import type { + AppConfigResponse, + DesktopRuntimeSettings, + DesktopRuntimeSettingsPatch +} from "@specdock/core"; +import { LIMITS } from "@specdock/core"; +import { fetchAppConfig } from "./deployment-policy.js"; + +export const desktopSettingsStorageKey = "specdock:desktop:settings"; + +export const isDesktopApp = (): boolean => + typeof window !== "undefined" && Boolean(window.specdockDesktop); + +export const readDesktopRuntimeSettings = async (): Promise => { + const response = await fetch("/api/desktop/settings"); + + if (!response.ok) { + throw new Error("Unable to load desktop settings."); + } + + return (await response.json()) as DesktopRuntimeSettings; +}; + +export const updateDesktopRuntimeSettings = async ( + patch: DesktopRuntimeSettingsPatch +): Promise => { + const response = await fetch("/api/desktop/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch) + }); + + if (!response.ok) { + throw new Error("Unable to update desktop settings."); + } + + return (await response.json()) as DesktopRuntimeSettings; +}; + +export const refreshAppConfig = async (): Promise => + fetchAppConfig(); + +export const withDefaultDesktopSettings = ( + current: DesktopRuntimeSettings, + patch: DesktopRuntimeSettingsPatch +): DesktopRuntimeSettings => ({ + mockMaxResponseBytes: + patch.mockMaxResponseBytes ?? + current.mockMaxResponseBytes ?? + LIMITS.maxProxyResponseBodyBytes, + mockServerEnabled: patch.mockServerEnabled ?? current.mockServerEnabled ?? false, + proxyAllowPrivateTargets: + patch.proxyAllowPrivateTargets ?? current.proxyAllowPrivateTargets ?? false, + proxyAllowedHosts: patch.proxyAllowedHosts ?? current.proxyAllowedHosts ?? "", + proxyEnabled: patch.proxyEnabled ?? current.proxyEnabled ?? false, + proxyMaxResponseBytes: + patch.proxyMaxResponseBytes ?? + current.proxyMaxResponseBytes ?? + LIMITS.maxProxyResponseBodyBytes, + proxyTimeoutMs: + patch.proxyTimeoutMs ?? current.proxyTimeoutMs ?? LIMITS.proxyTimeoutMs +}); diff --git a/apps/web/src/app/useAppConfig.ts b/apps/web/src/app/useAppConfig.ts index ab63797..d07428d 100644 --- a/apps/web/src/app/useAppConfig.ts +++ b/apps/web/src/app/useAppConfig.ts @@ -1,15 +1,27 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { AppConfigResponse } from "@specdock/core"; import { defaultAppConfig, fetchAppConfig } from "./deployment-policy.js"; -export const useAppConfig = (): AppConfigResponse => { +export const useAppConfig = (): { + appConfig: AppConfigResponse; + reloadAppConfig: () => Promise; +} => { const [appConfig, setAppConfig] = useState(defaultAppConfig); + const reloadAppConfig = useCallback(async (): Promise => { + try { + const nextConfig = await fetchAppConfig(); - useEffect(() => { - void fetchAppConfig() - .then(setAppConfig) - .catch(() => setAppConfig(defaultAppConfig)); + setAppConfig(nextConfig); + return nextConfig; + } catch { + setAppConfig(defaultAppConfig); + return defaultAppConfig; + } }, []); - return appConfig; + useEffect(() => { + void reloadAppConfig(); + }, [reloadAppConfig]); + + return { appConfig, reloadAppConfig }; }; diff --git a/apps/web/src/app/useDesktopSettings.ts b/apps/web/src/app/useDesktopSettings.ts new file mode 100644 index 0000000..828bed4 --- /dev/null +++ b/apps/web/src/app/useDesktopSettings.ts @@ -0,0 +1,74 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { LIMITS, type DesktopRuntimeSettings } from "@specdock/core"; +import { + desktopSettingsStorageKey, + isDesktopApp, + readDesktopRuntimeSettings, + updateDesktopRuntimeSettings +} from "./desktop-settings.js"; +import { readLocalJson, writeLocalJson } from "./local-storage.js"; + +export const useDesktopSettings = ( + reloadAppConfig: () => Promise +): { + desktopSettings: DesktopRuntimeSettings; + setDesktopSettings: Dispatch>; + desktopSettingsAvailable: boolean; +} => { + const [desktopSettings, setDesktopSettings] = useState(() => + readLocalJson(desktopSettingsStorageKey, defaultDesktopSettings) + ); + const [desktopSettingsAvailable, setDesktopSettingsAvailable] = useState(false); + + useEffect(() => { + if (!isDesktopApp()) return; + + setDesktopSettingsAvailable(true); + void updateDesktopRuntimeSettings(desktopSettings) + .then((settings) => { + setDesktopSettings((current) => + sameDesktopSettings(current, settings) ? current : settings + ); + writeLocalJson(desktopSettingsStorageKey, settings); + return reloadAppConfig(); + }) + .catch(() => { + void readDesktopRuntimeSettings() + .then((settings) => { + setDesktopSettings((current) => + sameDesktopSettings(current, settings) ? current : settings + ); + writeLocalJson(desktopSettingsStorageKey, settings); + }) + .then(() => reloadAppConfig()); + }); + }, [desktopSettings, reloadAppConfig]); + + return { + desktopSettings, + setDesktopSettings, + desktopSettingsAvailable + }; +}; + +const sameDesktopSettings = ( + left: DesktopRuntimeSettings, + right: DesktopRuntimeSettings +): boolean => + left.mockServerEnabled === right.mockServerEnabled && + left.mockMaxResponseBytes === right.mockMaxResponseBytes && + left.proxyAllowPrivateTargets === right.proxyAllowPrivateTargets && + left.proxyAllowedHosts === right.proxyAllowedHosts && + left.proxyEnabled === right.proxyEnabled && + left.proxyMaxResponseBytes === right.proxyMaxResponseBytes && + left.proxyTimeoutMs === right.proxyTimeoutMs; + +const defaultDesktopSettings: DesktopRuntimeSettings = { + mockMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + mockServerEnabled: false, + proxyAllowPrivateTargets: false, + proxyAllowedHosts: "", + proxyEnabled: false, + proxyMaxResponseBytes: LIMITS.maxProxyResponseBodyBytes, + proxyTimeoutMs: LIMITS.proxyTimeoutMs +}; diff --git a/apps/web/src/app/useSpecDockController.ts b/apps/web/src/app/useSpecDockController.ts index b4791d9..3695f3b 100644 --- a/apps/web/src/app/useSpecDockController.ts +++ b/apps/web/src/app/useSpecDockController.ts @@ -12,6 +12,7 @@ import { } from "./controller-helpers.js"; import { createCurlActions } from "./curl-actions.js"; import { directRequestBlockReason } from "./deployment-policy.js"; +import { createDesktopSettingsActions } from "./desktop-settings-actions.js"; import { createHttpCollection } from "./http-collection.js"; import { createMockActions } from "./mock-actions.js"; import { createProjectActions } from "./project-actions.js"; @@ -166,6 +167,7 @@ export const useSpecDockController = () => { const authActions = createAuthActions(state); const contractDiffActions = createContractDiffActions(state); const mockActions = createMockActions(state); + const desktopSettingsActions = createDesktopSettingsActions(state); const requestExecutionBlockReason = directRequestBlockReason( state.appConfig, state.requestState?.requestMode, @@ -211,6 +213,7 @@ export const useSpecDockController = () => { importRawSpec, uploadSpec, ...requestActions, + ...desktopSettingsActions, ...authActions, generate, downloadZip, diff --git a/apps/web/src/app/useSpecDockState.ts b/apps/web/src/app/useSpecDockState.ts index 810bc95..b1bd439 100644 --- a/apps/web/src/app/useSpecDockState.ts +++ b/apps/web/src/app/useSpecDockState.ts @@ -4,34 +4,16 @@ import { createRequestState } from "../request.js"; import { createWorkspaceStorage } from "../workspace.js"; import { readLocalJson, readLocalString, removeLocalValue, writeLocalJson, writeLocalString } from "./local-storage.js"; import { sampleSpec } from "./sample-spec.js"; -import { - baseUrlsStorageKey, - exchangesStorageKey, - generateOptionsStorageKey, - latestExchangeKeyStorageKey, - requestStatesStorageKey, - responseScopeStorageKey -} from "./storage-keys.js"; -import { - hydrateStoredRequestStates, - sanitizeRequestStatesForStorage -} from "./request-state-storage.js"; +import { baseUrlsStorageKey, exchangesStorageKey, generateOptionsStorageKey, latestExchangeKeyStorageKey, requestStatesStorageKey, responseScopeStorageKey } from "./storage-keys.js"; +import { hydrateStoredRequestStates, sanitizeRequestStatesForStorage } from "./request-state-storage.js"; import type { GeneratedFilesDiff, GeneratedFilesTarget } from "./sdk-diff.js"; import { applyProjectBaseUrl } from "./base-url.js"; import { hydrateGenerateOptions } from "./generate-options.js"; +import { useDesktopSettings } from "./useDesktopSettings.js"; import { useMockRouteHydration } from "./mock-route-hydration.js"; import { useAppConfig } from "./useAppConfig.js"; import { useSpecDockDerivedState } from "./useSpecDockDerivedState.js"; -import type { - ExchangeMap, - GenerateMeta, - MockServerState, - ProjectBaseUrlMap, - RequestBodyFileMap, - RequestStateMap, - ResponseScope, - ThemeMode -} from "./types.js"; +import type { ExchangeMap, GenerateMeta, MockServerState, ProjectBaseUrlMap, RequestBodyFileMap, RequestStateMap, ResponseScope, ThemeMode } from "./types.js"; export const useSpecDockState = () => { const storageAdapter = useMemo(() => createWorkspaceStorage(), []); @@ -76,7 +58,12 @@ export const useSpecDockState = () => { readLocalString(responseScopeStorageKey) === "latest" ? "latest" : "operation" ); const [mockServerState, setMockServerState] = useState({}); - const appConfig = useAppConfig(); + const { appConfig, reloadAppConfig } = useAppConfig(); + const { + desktopSettings, + setDesktopSettings, + desktopSettingsAvailable + } = useDesktopSettings(reloadAppConfig); const [status, setStatus] = useState(() => storageAdapter.getDiagnostics().some((diagnostic) => diagnostic.code !== "missing-key") ? "Recovered local workspace storage. Some invalid saved data was reset." @@ -229,6 +216,10 @@ export const useSpecDockState = () => { mockServerState, setMockServerState, appConfig, + reloadAppConfig, + desktopSettings, + setDesktopSettings, + desktopSettingsAvailable, status, setStatus, isImportingUrl, diff --git a/apps/web/src/components/DesktopRuntimeSettingsSection.tsx b/apps/web/src/components/DesktopRuntimeSettingsSection.tsx new file mode 100644 index 0000000..a4889c5 --- /dev/null +++ b/apps/web/src/components/DesktopRuntimeSettingsSection.tsx @@ -0,0 +1,129 @@ +import type { DesktopRuntimeSettings, RequestState } from "@specdock/core"; + +export const DesktopRuntimeSettingsSection = ({ + settings, + defaultRequestMode, + onDefaultRequestModeChange, + onChange +}: { + settings: DesktopRuntimeSettings; + defaultRequestMode: RequestState["requestMode"]; + onDefaultRequestModeChange(mode: RequestState["requestMode"]): void; + onChange(patch: Partial): void; +}) => ( +
+
+
+

Desktop runtime

+

Enable local-only desktop services.

+
+
+ + onChange({ mockServerEnabled })} + /> + onChange({ proxyEnabled })} + /> +