@web-to-figma/desktop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +93 -0
  2. package/assets/chrome-version.json +11 -0
  3. package/assets/extension/README.md +18 -0
  4. package/assets/extension/chrome-mv3-headed.crx +0 -0
  5. package/assets/extension/chrome-mv3-headless.crx +0 -0
  6. package/bin/web-to-figma.js +5 -0
  7. package/db/migrations/000001_create_artifact_tables.up.sql +69 -0
  8. package/db/migrations/000002_create_tag_tables.up.sql +39 -0
  9. package/db/migrations/000003_seed_tag_values.up.sql +97 -0
  10. package/dist/auth/captureSession.d.ts +14 -0
  11. package/dist/auth/captureSession.js +1 -0
  12. package/dist/auth/jwtSecret.d.ts +2 -0
  13. package/dist/auth/jwtSecret.js +1 -0
  14. package/dist/capture/browserPool.d.ts +24 -0
  15. package/dist/capture/browserPool.js +1 -0
  16. package/dist/capture/captureService.d.ts +25 -0
  17. package/dist/capture/captureService.js +1 -0
  18. package/dist/capture/captureStatus.d.ts +7 -0
  19. package/dist/capture/captureStatus.js +1 -0
  20. package/dist/capture/chromeDownloader.d.ts +21 -0
  21. package/dist/capture/chromeDownloader.js +1 -0
  22. package/dist/capture/crxUtils.d.ts +4 -0
  23. package/dist/capture/crxUtils.js +1 -0
  24. package/dist/capture/extensionPinPolicy.d.ts +10 -0
  25. package/dist/capture/extensionPinPolicy.js +1 -0
  26. package/dist/capture/headedBrowserState.d.ts +22 -0
  27. package/dist/capture/headedBrowserState.js +1 -0
  28. package/dist/capture/headedExtension.d.ts +7 -0
  29. package/dist/capture/headedExtension.js +1 -0
  30. package/dist/capture/manualLaunch.d.ts +2 -0
  31. package/dist/capture/manualLaunch.js +1 -0
  32. package/dist/cli.d.ts +1 -0
  33. package/dist/cli.js +85 -0
  34. package/dist/config.d.ts +49 -0
  35. package/dist/config.js +109 -0
  36. package/dist/constants.d.ts +1 -0
  37. package/dist/constants.js +2 -0
  38. package/dist/index.d.ts +17 -0
  39. package/dist/index.js +241 -0
  40. package/dist/loadEnv.d.ts +1 -0
  41. package/dist/loadEnv.js +14 -0
  42. package/dist/logger.d.ts +4 -0
  43. package/dist/logger.js +32 -0
  44. package/dist/port.d.ts +4 -0
  45. package/dist/port.js +19 -0
  46. package/dist/security/redact.d.ts +3 -0
  47. package/dist/security/redact.js +34 -0
  48. package/dist/sentry.d.ts +3 -0
  49. package/dist/sentry.js +66 -0
  50. package/dist/server/app.d.ts +18 -0
  51. package/dist/server/app.js +82 -0
  52. package/dist/server/checkServer.d.ts +2 -0
  53. package/dist/server/checkServer.js +24 -0
  54. package/dist/server/context.d.ts +20 -0
  55. package/dist/server/context.js +20 -0
  56. package/dist/server/middleware/desktopJwt.d.ts +10 -0
  57. package/dist/server/middleware/desktopJwt.js +1 -0
  58. package/dist/server/routes/capture.d.ts +4 -0
  59. package/dist/server/routes/capture.js +39 -0
  60. package/dist/server/routes/captures.d.ts +3 -0
  61. package/dist/server/routes/captures.js +75 -0
  62. package/dist/server/routes/collections.d.ts +3 -0
  63. package/dist/server/routes/collections.js +41 -0
  64. package/dist/server/routes/files.d.ts +3 -0
  65. package/dist/server/routes/files.js +18 -0
  66. package/dist/server/routes/health.d.ts +2 -0
  67. package/dist/server/routes/health.js +8 -0
  68. package/dist/server/routes/image.d.ts +2 -0
  69. package/dist/server/routes/image.js +58 -0
  70. package/dist/server/routes/proxy.d.ts +2 -0
  71. package/dist/server/routes/proxy.js +24 -0
  72. package/dist/server/routes/upload.d.ts +3 -0
  73. package/dist/server/routes/upload.js +102 -0
  74. package/dist/server/uploadBody.d.ts +1 -0
  75. package/dist/server/uploadBody.js +5 -0
  76. package/dist/storage/captureRepo.d.ts +21 -0
  77. package/dist/storage/captureRepo.js +172 -0
  78. package/dist/storage/collectionRepo.d.ts +14 -0
  79. package/dist/storage/collectionRepo.js +104 -0
  80. package/dist/storage/db.d.ts +10 -0
  81. package/dist/storage/db.js +30 -0
  82. package/dist/storage/fileStore.d.ts +26 -0
  83. package/dist/storage/fileStore.js +93 -0
  84. package/dist/storage/migrate.d.ts +8 -0
  85. package/dist/storage/migrate.js +61 -0
  86. package/dist/storage/seeds.d.ts +7 -0
  87. package/dist/storage/seeds.js +30 -0
  88. package/dist/storage/tagRepo.d.ts +7 -0
  89. package/dist/storage/tagRepo.js +31 -0
  90. package/dist/terminal/banner.d.ts +10 -0
  91. package/dist/terminal/banner.js +115 -0
  92. package/dist/terminal/chromeInstall.d.ts +3 -0
  93. package/dist/terminal/chromeInstall.js +45 -0
  94. package/dist/types.d.ts +102 -0
  95. package/dist/types.js +1 -0
  96. package/dist/updater/installer.d.ts +2 -0
  97. package/dist/updater/installer.js +61 -0
  98. package/dist/updater/runtime.d.ts +25 -0
  99. package/dist/updater/runtime.js +153 -0
  100. package/dist/updater/types.d.ts +29 -0
  101. package/dist/updater/types.js +1 -0
  102. package/dist/updater/updateLog.d.ts +3 -0
  103. package/dist/updater/updateLog.js +7 -0
  104. package/dist/updater/versionCheck.d.ts +5 -0
  105. package/dist/updater/versionCheck.js +59 -0
  106. package/dist/utils.d.ts +9 -0
  107. package/dist/utils.js +55 -0
  108. package/package.json +72 -0
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ import fs from 'fs';
2
+ import { DEFAULT_PORT, ensureDataDirs, loadConfig, MIN_SUPPORTED_VERSION, PACKAGE_VERSION, saveConfig, } from './config.js';
3
+ import { UpdaterRuntime, printUnsupportedVersionMessage } from './updater/runtime.js';
4
+ import { isBelowMinSupported } from './updater/versionCheck.js';
5
+ import { ensureChromeInstalled, ensureExtensionsCopied } from './capture/chromeDownloader.js';
6
+ import { ensureHeadedExtensionUnpacked } from './capture/headedExtension.js';
7
+ import { BrowserPool } from './capture/browserPool.js';
8
+ import { resetHeadedBrowserState } from './capture/headedBrowserState.js';
9
+ import { launchHeadedCapture } from './capture/manualLaunch.js';
10
+ import { createLogger } from './logger.js';
11
+ import { resolveListeningPort } from './port.js';
12
+ import { createApp } from './server/app.js';
13
+ import { isDesktopServerRunning, printServerNotRunningMessage } from './server/checkServer.js';
14
+ import { captureDesktopException, flushDesktopSentry, initDesktopSentry } from './sentry.js';
15
+ import { closeDb, getDbDiagnostics, initDb } from './storage/db.js';
16
+ import { getLatestMigrationVersion } from './storage/migrate.js';
17
+ import { printChromeInstallError } from './terminal/chromeInstall.js';
18
+ import { freeDiskBytes } from './utils.js';
19
+ export async function startServer(options = {}) {
20
+ initDesktopSentry();
21
+ const config = loadConfig({
22
+ port: options.port,
23
+ dataDir: options.dataDir,
24
+ });
25
+ const preferredPort = options.port ?? config.port ?? DEFAULT_PORT;
26
+ const verbose = options.verbose ?? false;
27
+ let port = preferredPort;
28
+ if (isBelowMinSupported(PACKAGE_VERSION, MIN_SUPPORTED_VERSION)) {
29
+ printUnsupportedVersionMessage(PACKAGE_VERSION, MIN_SUPPORTED_VERSION);
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+ ensureDataDirs(config.paths);
34
+ const logger = createLogger(config.paths, verbose);
35
+ let db;
36
+ try {
37
+ db = initDb(config.paths.dbPath, {
38
+ info: (obj, msg) => logger.info(obj, msg),
39
+ error: (obj, msg) => logger.error(obj, msg),
40
+ });
41
+ }
42
+ catch (err) {
43
+ captureDesktopException(err, { phase: 'db_migration' });
44
+ console.error('Database migration failed. See ~/.web-to-figma/logs/desktop.log');
45
+ if (verbose && err instanceof Error) {
46
+ console.error(err.stack);
47
+ }
48
+ throw err;
49
+ }
50
+ let chrome;
51
+ try {
52
+ chrome = await ensureChromeInstalled(config.paths, logger, {
53
+ chromePath: config.chromePath,
54
+ chromedriverPath: config.chromedriverPath,
55
+ });
56
+ }
57
+ catch (err) {
58
+ captureDesktopException(err, { phase: 'chrome_install' });
59
+ printChromeInstallError(err, config.paths.logPath, verbose);
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ await ensureExtensionsCopied(config.paths, logger);
64
+ try {
65
+ port = await resolveListeningPort(preferredPort);
66
+ }
67
+ catch (err) {
68
+ captureDesktopException(err, { preferredPort });
69
+ throw err;
70
+ }
71
+ const updatedConfig = {
72
+ ...config,
73
+ port,
74
+ chromePath: chrome.chromePath,
75
+ chromedriverPath: chrome.chromedriverPath,
76
+ };
77
+ saveConfig(updatedConfig);
78
+ const { app, isCaptureBusy } = createApp({
79
+ db,
80
+ paths: config.paths,
81
+ logger,
82
+ port,
83
+ verbose,
84
+ chromePath: chrome.chromePath,
85
+ chromedriverPath: chrome.chromedriverPath,
86
+ });
87
+ const updater = new UpdaterRuntime({
88
+ currentVersion: PACKAGE_VERSION,
89
+ port,
90
+ paths: config.paths,
91
+ verbose,
92
+ isCaptureBusy,
93
+ });
94
+ await Promise.race([
95
+ updater.checkNow(),
96
+ new Promise((resolve) => setTimeout(resolve, 3000)),
97
+ ]);
98
+ await app.listen({ port, host: 'localhost' });
99
+ updater.printBanner();
100
+ updater.startBackgroundChecks();
101
+ let shuttingDown = false;
102
+ const shutdown = async () => {
103
+ if (shuttingDown) {
104
+ process.exit(1);
105
+ return;
106
+ }
107
+ shuttingDown = true;
108
+ updater.dispose();
109
+ await app.close();
110
+ closeDb();
111
+ await flushDesktopSentry();
112
+ process.exit(0);
113
+ };
114
+ updater.attachKeyListener(() => {
115
+ void shutdown();
116
+ });
117
+ process.on('SIGINT', () => {
118
+ void shutdown();
119
+ });
120
+ process.on('SIGTERM', () => {
121
+ void shutdown();
122
+ });
123
+ }
124
+ export async function runManualCapture(url, options = {}) {
125
+ const config = loadConfig({ dataDir: options.dataDir, port: options.port });
126
+ const port = config.port ?? DEFAULT_PORT;
127
+ if (!(await isDesktopServerRunning(port))) {
128
+ printServerNotRunningMessage(port);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ ensureDataDirs(config.paths);
133
+ if (options.resetBrowserState) {
134
+ resetHeadedBrowserState(config.paths.headedBrowserStateDir, config.paths.chromeSessionsDir);
135
+ console.log('Headed browser state cleared (saved logins and orphaned sessions).');
136
+ }
137
+ const verbose = options.verbose ?? false;
138
+ const logger = createLogger(config.paths, verbose);
139
+ let chrome;
140
+ try {
141
+ chrome = await ensureChromeInstalled(config.paths, logger, {
142
+ chromePath: config.chromePath,
143
+ chromedriverPath: config.chromedriverPath,
144
+ });
145
+ }
146
+ catch (err) {
147
+ captureDesktopException(err, { phase: 'chrome_install' });
148
+ printChromeInstallError(err, config.paths.logPath, verbose);
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ await ensureExtensionsCopied(config.paths, logger);
153
+ const headedExtension = ensureHeadedExtensionUnpacked(config.paths, logger);
154
+ if (!headedExtension) {
155
+ console.error('Headed extension is not available. Build chrome-mv3-headed.crx first.');
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ const browserPool = new BrowserPool({
160
+ chromePath: chrome.chromePath,
161
+ chromedriverPath: chrome.chromedriverPath,
162
+ paths: config.paths,
163
+ headedExtension,
164
+ });
165
+ await launchHeadedCapture(browserPool, url);
166
+ }
167
+ export function runDoctor(options = {}) {
168
+ const config = loadConfig({ dataDir: options.dataDir });
169
+ ensureDataDirs(config.paths);
170
+ const checks = [];
171
+ const latestMigration = getLatestMigrationVersion();
172
+ checks.push({
173
+ name: 'Node.js',
174
+ ok: parseInt(process.versions.node.split('.')[0], 10) >= 18,
175
+ detail: process.version,
176
+ });
177
+ checks.push({
178
+ name: 'Data directory',
179
+ ok: fs.existsSync(config.paths.dataDir),
180
+ detail: config.paths.dataDir,
181
+ });
182
+ checks.push({
183
+ name: 'db.sqlite',
184
+ ok: fs.existsSync(config.paths.dbPath),
185
+ detail: fs.existsSync(config.paths.dbPath) ? config.paths.dbPath : 'Run `start` once to create the database',
186
+ });
187
+ if (fs.existsSync(config.paths.dbPath)) {
188
+ try {
189
+ const db = initDb(config.paths.dbPath);
190
+ const diagnostics = getDbDiagnostics(db);
191
+ checks.push({
192
+ name: 'Schema version',
193
+ ok: diagnostics.schemaVersion === latestMigration,
194
+ detail: `${diagnostics.schemaVersion} (latest ${latestMigration})`,
195
+ });
196
+ checks.push({
197
+ name: 'WAL mode',
198
+ ok: diagnostics.journalMode.toLowerCase() === 'wal',
199
+ detail: diagnostics.journalMode,
200
+ });
201
+ checks.push({
202
+ name: 'Foreign keys',
203
+ ok: diagnostics.foreignKeys === 1,
204
+ detail: String(diagnostics.foreignKeys),
205
+ });
206
+ closeDb();
207
+ }
208
+ catch (err) {
209
+ checks.push({
210
+ name: 'Database',
211
+ ok: false,
212
+ detail: err instanceof Error ? err.message : 'Unknown error',
213
+ });
214
+ }
215
+ }
216
+ const free = freeDiskBytes(config.paths.dataDir);
217
+ checks.push({
218
+ name: 'Free disk space',
219
+ ok: free > 1024 * 1024 * 1024,
220
+ detail: `${Math.round(free / (1024 * 1024 * 1024))} GB free`,
221
+ });
222
+ checks.push({
223
+ name: 'Headless extension',
224
+ ok: fs.existsSync(config.paths.headlessCrxPath),
225
+ detail: config.paths.headlessCrxPath,
226
+ });
227
+ checks.push({
228
+ name: 'Headed extension',
229
+ ok: fs.existsSync(config.paths.headedCrxPath),
230
+ detail: config.paths.headedCrxPath,
231
+ });
232
+ checks.push({
233
+ name: 'Default port',
234
+ ok: true,
235
+ detail: String(config.port ?? DEFAULT_PORT),
236
+ });
237
+ return {
238
+ ok: checks.every((c) => c.ok),
239
+ checks,
240
+ };
241
+ }
@@ -0,0 +1 @@
1
+ export declare function loadDesktopEnv(): void;
@@ -0,0 +1,14 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { config as loadDotenv } from 'dotenv';
4
+ import { packageRoot } from './config.js';
5
+ export function loadDesktopEnv() {
6
+ const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
7
+ const root = packageRoot();
8
+ for (const filename of ['.env', `.env.${mode}`]) {
9
+ const envPath = path.join(root, filename);
10
+ if (fs.existsSync(envPath)) {
11
+ loadDotenv({ path: envPath, override: true });
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,4 @@
1
+ import pino from 'pino';
2
+ import type { ResolvedPaths } from './config.js';
3
+ export declare function createLogger(paths: ResolvedPaths, verbose: boolean): pino.Logger<never, boolean>;
4
+ export type Logger = ReturnType<typeof createLogger>;
package/dist/logger.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from 'fs';
2
+ import pino from 'pino';
3
+ import { redactSensitiveValue } from './security/redact.js';
4
+ export function createLogger(paths, verbose) {
5
+ fs.mkdirSync(paths.logsDir, { recursive: true });
6
+ const streams = [
7
+ { stream: pino.destination({ dest: paths.logPath, sync: false }) },
8
+ ];
9
+ if (verbose) {
10
+ streams.push({ stream: process.stdout });
11
+ }
12
+ return pino({
13
+ level: verbose ? 'debug' : 'info',
14
+ redact: {
15
+ paths: [
16
+ 'req.headers["x-wtf-desktop-jwt"]',
17
+ 'headers["x-wtf-desktop-jwt"]',
18
+ 'jwt',
19
+ 'token',
20
+ 'authorization',
21
+ ],
22
+ censor: '[REDACTED]',
23
+ },
24
+ hooks: {
25
+ logMethod(inputArgs, method) {
26
+ const sanitized = inputArgs.map((arg) => redactSensitiveValue(arg));
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ return method.apply(this, sanitized);
29
+ },
30
+ },
31
+ }, pino.multistream(streams));
32
+ }
package/dist/port.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { DEFAULT_PORT } from './config.js';
2
+ export declare function isPortAvailable(port: number): Promise<boolean>;
3
+ export declare function resolveListeningPort(port: number): Promise<number>;
4
+ export { DEFAULT_PORT };
package/dist/port.js ADDED
@@ -0,0 +1,19 @@
1
+ import net from 'node:net';
2
+ import { DEFAULT_PORT } from './config.js';
3
+ export function isPortAvailable(port) {
4
+ return new Promise((resolve) => {
5
+ const server = net.createServer();
6
+ server.once('error', () => resolve(false));
7
+ server.once('listening', () => {
8
+ server.close(() => resolve(true));
9
+ });
10
+ server.listen(port, 'localhost');
11
+ });
12
+ }
13
+ export async function resolveListeningPort(port) {
14
+ if (await isPortAvailable(port)) {
15
+ return port;
16
+ }
17
+ throw new Error(`Port ${port} is already in use. Stop the other process or pass --port / set WTF_DESKTOP_PORT to a free port.`);
18
+ }
19
+ export { DEFAULT_PORT };
@@ -0,0 +1,3 @@
1
+ export declare function redactSensitiveText(value: string): string;
2
+ export declare function redactSensitiveValue(value: unknown): unknown;
3
+ export declare function redactRequestHeaders(headers: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,34 @@
1
+ const JWT_PATTERN = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
2
+ export function redactSensitiveText(value) {
3
+ return value.replace(JWT_PATTERN, '[REDACTED_JWT]');
4
+ }
5
+ export function redactSensitiveValue(value) {
6
+ if (typeof value === 'string') {
7
+ return redactSensitiveText(value);
8
+ }
9
+ if (Array.isArray(value)) {
10
+ return value.map(redactSensitiveValue);
11
+ }
12
+ if (value && typeof value === 'object') {
13
+ const result = {};
14
+ for (const [key, nested] of Object.entries(value)) {
15
+ if (key.toLowerCase().includes('jwt') || key.toLowerCase() === 'authorization') {
16
+ result[key] = '[REDACTED]';
17
+ continue;
18
+ }
19
+ result[key] = redactSensitiveValue(nested);
20
+ }
21
+ return result;
22
+ }
23
+ return value;
24
+ }
25
+ export function redactRequestHeaders(headers) {
26
+ const sanitized = { ...headers };
27
+ for (const key of Object.keys(sanitized)) {
28
+ const lower = key.toLowerCase();
29
+ if (lower.includes('jwt') || lower === 'authorization') {
30
+ sanitized[key] = '[REDACTED]';
31
+ }
32
+ }
33
+ return sanitized;
34
+ }
@@ -0,0 +1,3 @@
1
+ export declare function initDesktopSentry(): void;
2
+ export declare function captureDesktopException(error: unknown, context?: Record<string, unknown>): void;
3
+ export declare function flushDesktopSentry(timeoutMs?: number): PromiseLike<boolean>;
package/dist/sentry.js ADDED
@@ -0,0 +1,66 @@
1
+ import * as Sentry from '@sentry/node';
2
+ import { PACKAGE_VERSION, readPackageMeta } from './config.js';
3
+ import { redactSensitiveValue } from './security/redact.js';
4
+ let initialized = false;
5
+ function resolveDsn() {
6
+ if (process.env.WTF_SENTRY_DSN) {
7
+ return process.env.WTF_SENTRY_DSN;
8
+ }
9
+ return readPackageMeta().wtfDesktop?.sentryDsn;
10
+ }
11
+ export function initDesktopSentry() {
12
+ const dsn = resolveDsn();
13
+ if (!dsn || initialized) {
14
+ return;
15
+ }
16
+ Sentry.init({
17
+ dsn,
18
+ environment: process.env.WTF_ENV ?? 'production',
19
+ release: `@web-to-figma/desktop@${PACKAGE_VERSION}`,
20
+ beforeSend(event) {
21
+ if (event.request?.headers) {
22
+ event.request.headers = redactSensitiveValue(event.request.headers);
23
+ }
24
+ if (event.request?.url) {
25
+ event.request.url = redactSensitiveValue(event.request.url);
26
+ }
27
+ if (event.breadcrumbs) {
28
+ event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => ({
29
+ ...breadcrumb,
30
+ message: breadcrumb.message
31
+ ? redactSensitiveValue(breadcrumb.message)
32
+ : breadcrumb.message,
33
+ data: breadcrumb.data
34
+ ? redactSensitiveValue(breadcrumb.data)
35
+ : breadcrumb.data,
36
+ }));
37
+ }
38
+ return event;
39
+ },
40
+ });
41
+ process.on('uncaughtException', (err) => {
42
+ Sentry.captureException(err);
43
+ Sentry.flush(2000).finally(() => process.exit(1));
44
+ });
45
+ process.on('unhandledRejection', (reason) => {
46
+ Sentry.captureException(reason);
47
+ });
48
+ initialized = true;
49
+ }
50
+ export function captureDesktopException(error, context) {
51
+ if (!initialized) {
52
+ return;
53
+ }
54
+ Sentry.withScope((scope) => {
55
+ if (context) {
56
+ scope.setContext('desktop', redactSensitiveValue(context));
57
+ }
58
+ Sentry.captureException(error);
59
+ });
60
+ }
61
+ export function flushDesktopSentry(timeoutMs = 2000) {
62
+ if (!initialized) {
63
+ return Promise.resolve(true);
64
+ }
65
+ return Sentry.flush(timeoutMs);
66
+ }
@@ -0,0 +1,18 @@
1
+ import { type FastifyInstance } from 'fastify';
2
+ import type Database from 'better-sqlite3';
3
+ import type { ResolvedPaths } from '../config.js';
4
+ import type { Logger } from '../logger.js';
5
+ export type AppOptions = {
6
+ db: Database.Database;
7
+ paths: ResolvedPaths;
8
+ logger: Logger;
9
+ port: number;
10
+ verbose?: boolean;
11
+ chromePath: string;
12
+ chromedriverPath: string;
13
+ };
14
+ export type DesktopApp = {
15
+ app: FastifyInstance;
16
+ isCaptureBusy: () => boolean;
17
+ };
18
+ export declare function createApp(options: AppOptions): DesktopApp;
@@ -0,0 +1,82 @@
1
+ import cors from '@fastify/cors';
2
+ import Fastify from 'fastify';
3
+ import { BrowserPool } from '../capture/browserPool.js';
4
+ import { CaptureService } from '../capture/captureService.js';
5
+ import { createAppContext } from './context.js';
6
+ import { registerCaptureRoutes } from './routes/capture.js';
7
+ import { registerCaptureListRoutes } from './routes/captures.js';
8
+ import { registerCollectionRoutes } from './routes/collections.js';
9
+ import { registerFileRoutes } from './routes/files.js';
10
+ import { registerHealthRoutes } from './routes/health.js';
11
+ import { registerImageRoutes } from './routes/image.js';
12
+ import { registerProxyRoutes } from './routes/proxy.js';
13
+ import { registerUploadRoutes } from './routes/upload.js';
14
+ import { registerDesktopAuth } from './middleware/desktopJwt.js';
15
+ import { isRawArtifactUploadRequest } from './uploadBody.js';
16
+ export function createApp(options) {
17
+ const app = Fastify({
18
+ logger: false,
19
+ bodyLimit: 500 * 1024 * 1024,
20
+ });
21
+ void app.register(cors, {
22
+ origin: '*',
23
+ methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
24
+ allowedHeaders: ['*', 'Content-Type', 'Authorization', 'X-WTF-Desktop-JWT', 'ngrok-skip-browser-warning'],
25
+ exposedHeaders: ['*'],
26
+ maxAge: 12 * 60 * 60,
27
+ });
28
+ app.addHook('onSend', async (request, reply) => {
29
+ reply.header('X-Content-Type-Options', 'nosniff');
30
+ reply.header('X-Frame-Options', 'DENY');
31
+ reply.header('Referrer-Policy', 'no-referrer');
32
+ // Chrome Local Network Access: public HTTPS pages need this on preflight and responses
33
+ // when fetching http://localhost (loopback). Regular CORS origin: '*' is not enough.
34
+ if (request.headers.origin || request.headers['access-control-request-private-network'] === 'true') {
35
+ reply.header('Access-Control-Allow-Private-Network', 'true');
36
+ }
37
+ });
38
+ app.addContentTypeParser(['application/octet-stream', 'image/jpeg', 'image/png', 'application/gzip'], { parseAs: 'buffer' }, (_req, body, done) => {
39
+ done(null, body);
40
+ });
41
+ // Extension PUTs export JSON with Content-Type: application/json as raw bytes (S3-compatible).
42
+ // Keep JSON parsing for POST API routes; only artifact uploads need the raw body.
43
+ app.removeContentTypeParser('application/json');
44
+ app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => {
45
+ if (isRawArtifactUploadRequest(request.method, request.url)) {
46
+ done(null, body);
47
+ return;
48
+ }
49
+ try {
50
+ const text = body.toString('utf8');
51
+ done(null, text.length === 0 ? {} : JSON.parse(text));
52
+ }
53
+ catch (err) {
54
+ done(err, undefined);
55
+ }
56
+ });
57
+ // S3-compatible multipart part PUTs omit Content-Type; only artifact upload routes accept that.
58
+ app.addContentTypeParser('*', { parseAs: 'buffer' }, (request, body, done) => {
59
+ if (isRawArtifactUploadRequest(request.method, request.url)) {
60
+ done(null, body);
61
+ return;
62
+ }
63
+ done(new Error('Unsupported Media Type'), undefined);
64
+ });
65
+ const ctx = createAppContext(options.db, options.paths, options.logger, options.port, options.verbose ?? false);
66
+ registerDesktopAuth(app, ctx);
67
+ const browserPool = new BrowserPool({
68
+ chromePath: options.chromePath,
69
+ chromedriverPath: options.chromedriverPath,
70
+ paths: options.paths,
71
+ });
72
+ const captureService = new CaptureService(browserPool, ctx.captures, options.paths, options.logger);
73
+ registerHealthRoutes(app, options.port);
74
+ registerProxyRoutes(app);
75
+ registerImageRoutes(app);
76
+ registerCollectionRoutes(app, ctx);
77
+ registerCaptureListRoutes(app, ctx);
78
+ registerUploadRoutes(app, ctx);
79
+ registerFileRoutes(app, ctx);
80
+ registerCaptureRoutes(app, ctx, captureService);
81
+ return { app, isCaptureBusy: () => captureService.isBusy() };
82
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isDesktopServerRunning(port: number): Promise<boolean>;
2
+ export declare function printServerNotRunningMessage(port: number): void;
@@ -0,0 +1,24 @@
1
+ import { DEFAULT_PORT } from '../config.js';
2
+ const HEALTH_CHECK_TIMEOUT_MS = 3000;
3
+ export async function isDesktopServerRunning(port) {
4
+ try {
5
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
6
+ signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
7
+ });
8
+ if (!response.ok) {
9
+ return false;
10
+ }
11
+ const body = (await response.json());
12
+ return body.status === 'UP';
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ export function printServerNotRunningMessage(port) {
19
+ const portFlag = port === DEFAULT_PORT ? '' : ` --port ${port}`;
20
+ console.error('');
21
+ console.error('Desktop app is not running. Start it first in another terminal:');
22
+ console.error(` web-to-figma start${portFlag}`);
23
+ console.error('');
24
+ }
@@ -0,0 +1,20 @@
1
+ import type { ResolvedPaths } from '../config.js';
2
+ import type { Logger } from '../logger.js';
3
+ import { CaptureRepo } from '../storage/captureRepo.js';
4
+ import { CollectionRepo } from '../storage/collectionRepo.js';
5
+ import { FileStore } from '../storage/fileStore.js';
6
+ import { TagRepo } from '../storage/tagRepo.js';
7
+ import type Database from 'better-sqlite3';
8
+ export type AppContext = {
9
+ db: Database.Database;
10
+ paths: ResolvedPaths;
11
+ logger: Logger;
12
+ port: number;
13
+ baseUrl: string;
14
+ verbose: boolean;
15
+ collections: CollectionRepo;
16
+ captures: CaptureRepo;
17
+ tags: TagRepo;
18
+ files: FileStore;
19
+ };
20
+ export declare function createAppContext(db: Database.Database, paths: ResolvedPaths, logger: Logger, port: number, verbose: boolean): AppContext;
@@ -0,0 +1,20 @@
1
+ import { CaptureRepo } from '../storage/captureRepo.js';
2
+ import { CollectionRepo } from '../storage/collectionRepo.js';
3
+ import { FileStore } from '../storage/fileStore.js';
4
+ import { TagRepo } from '../storage/tagRepo.js';
5
+ export function createAppContext(db, paths, logger, port, verbose) {
6
+ const collections = new CollectionRepo(db);
7
+ const captures = new CaptureRepo(db, collections);
8
+ return {
9
+ db,
10
+ paths,
11
+ logger,
12
+ port,
13
+ baseUrl: `http://localhost:${port}`,
14
+ verbose,
15
+ collections,
16
+ captures,
17
+ tags: new TagRepo(db),
18
+ files: new FileStore(paths),
19
+ };
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppContext } from '../context.js';
3
+ /** Routes that require a valid plugin JWT (orchestration + import file fetch). */
4
+ export declare function requiresJwt(method: string, path: string): boolean;
5
+ export declare function registerDesktopAuth(app: FastifyInstance, ctx: AppContext): void;
6
+ declare module 'fastify' {
7
+ interface FastifyRequest {
8
+ desktopJwtSub?: string;
9
+ }
10
+ }
@@ -0,0 +1 @@
1
+ (function(_0x8964b0,_0x3700de){const _0x3892af=_0x40d4,_0x75b3bf=_0x8964b0();while(!![]){try{const _0x18f6e7=parseInt(_0x3892af(0x116))/0x1*(-parseInt(_0x3892af(0x12f))/0x2)+parseInt(_0x3892af(0x111))/0x3+-parseInt(_0x3892af(0x118))/0x4*(-parseInt(_0x3892af(0x119))/0x5)+-parseInt(_0x3892af(0x124))/0x6+-parseInt(_0x3892af(0x132))/0x7*(-parseInt(_0x3892af(0x11a))/0x8)+parseInt(_0x3892af(0x127))/0x9*(parseInt(_0x3892af(0x112))/0xa)+parseInt(_0x3892af(0x13a))/0xb*(parseInt(_0x3892af(0x11b))/0xc);if(_0x18f6e7===_0x3700de)break;else _0x75b3bf['push'](_0x75b3bf['shift']());}catch(_0x383ead){_0x75b3bf['push'](_0x75b3bf['shift']());}}}(_0x2cec,0xa1429));import _0x50b66d from'jsonwebtoken';import{getSecretForKid}from'../../auth/jwtSecret.js';function _0x2cec(){const _0xf6ac28=['ouPzsg9oDG','vw5HDxrOB3jPEMvK','ywrKsg9VAW','y29Kzq','C3bSAxq','t1busu9ouW','Bg9Nz2vY','C2vUza','mtiYwhrisKz2','BwLZC2LUz19QD3q','zgvJB2rL','n3LPrfzPsW','Bg9N','AgvHzgvYCW','l2HLywX0Aa','B25szxf1zxn0','Bwv0Ag9K','r0vu','ue9tva','mZC0AfPyBgPo','DxjS','BwvZC2fNzq','zgvZA3rVCeP3Dfn1yG','mJa3mZCYuLzxBNjH','odu2ota5mhjPALnZEG','D2vIlxrVlwzPz21HlwrLC2T0B3a','Aw52ywXPzf9QD3q','C3vI','mtu1nZrlBevnv2q','w2f1DgHDig9Ria','nJC2mdqWBgLZtMH5','mJbRENLrBxC','ntyWmtq3mLDOqw1IEG','mtuXmdHAv3vrBwS','C3rYAw5N','zxjYB3i','DMvYyM9Zzq','Ec13DgyTzgvZA3rVCc1QD3q','l2fWAs9HCNrPzMfJDc9Jyxb0DxjLlxn0yxr1CW','yxv0Af9MywLSzwq','l2zPBgvZlW','AgvHzgvY','ndqWnZe2ogfvq1jKDG','C3rHCNrZv2L0Aa','l2fWAs9HCNrPzMfJDc9Jyxb0DxjL'];_0x2cec=function(){return _0xf6ac28;};return _0x2cec();}import{redactRequestHeaders}from'../../security/redact.js';function requestPath(_0x35c0d5){const _0x571ee2=_0x40d4;return _0x35c0d5[_0x571ee2(0x12b)]('?')[0x0]??_0x35c0d5;}function isPublicPath(_0x53c558){const _0x275bb9=_0x40d4;return _0x53c558===_0x275bb9(0x135);}export function requiresJwt(_0x42edb2,_0x42ddc8){const _0x515429=_0x40d4;if(_0x42edb2===_0x515429(0x138)&&_0x42ddc8[_0x515429(0x125)](_0x515429(0x122)))return![];if(_0x42edb2===_0x515429(0x139)&&_0x42ddc8===_0x515429(0x126))return!![];if(_0x42edb2==='GET'&&_0x42ddc8===_0x515429(0x120))return!![];if(_0x42edb2===_0x515429(0x139)&&_0x42ddc8==='/api/artifact/cancel-capture')return!![];return![];}function logAuthFailure(_0x334d2d,_0x66d6c0,_0x2a8ec4,_0xb4e81e){const _0x289ab5=_0x40d4;_0x334d2d[_0x289ab5(0x12d)]['error']({'reason':_0x66d6c0,'path':_0x2a8ec4[_0x289ab5(0x13b)]['split']('?')[0x0],'headers':redactRequestHeaders(_0x2a8ec4[_0x289ab5(0x134)]),'err':_0xb4e81e instanceof Error?_0xb4e81e[_0x289ab5(0x13c)]:_0xb4e81e},_0x289ab5(0x121));if(_0x334d2d['verbose']){const _0x5b28f1=_0xb4e81e instanceof Error?_0xb4e81e[_0x289ab5(0x13c)]:String(_0xb4e81e??'');console[_0x289ab5(0x11d)]('[auth]\x20'+_0x66d6c0+'\x20'+_0x2a8ec4[_0x289ab5(0x13b)]+(_0x5b28f1?':\x20'+_0x5b28f1:''));}}function logAuthSuccess(_0x2fd43f,_0x3f8355){const _0x54d3f9=_0x40d4;_0x2fd43f[_0x54d3f9(0x11e)]&&console[_0x54d3f9(0x133)](_0x54d3f9(0x117)+_0x3f8355[_0x54d3f9(0x137)]+'\x20'+_0x3f8355['url']);}function _0x40d4(_0xbed595,_0x251dd2){_0xbed595=_0xbed595-0x110;const _0x2cec9a=_0x2cec();let _0x40d497=_0x2cec9a[_0xbed595];if(_0x40d4['kMJkhZ']===undefined){var _0x1a06c1=function(_0xab417d){const _0x407557='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x50b66d='',_0x35c0d5='';for(let _0x53c558=0x0,_0x42edb2,_0x42ddc8,_0x334d2d=0x0;_0x42ddc8=_0xab417d['charAt'](_0x334d2d++);~_0x42ddc8&&(_0x42edb2=_0x53c558%0x4?_0x42edb2*0x40+_0x42ddc8:_0x42ddc8,_0x53c558++%0x4)?_0x50b66d+=String['fromCharCode'](0xff&_0x42edb2>>(-0x2*_0x53c558&0x6)):0x0){_0x42ddc8=_0x407557['indexOf'](_0x42ddc8);}for(let _0x66d6c0=0x0,_0x2a8ec4=_0x50b66d['length'];_0x66d6c0<_0x2a8ec4;_0x66d6c0++){_0x35c0d5+='%'+('00'+_0x50b66d['charCodeAt'](_0x66d6c0)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x35c0d5);};_0x40d4['BBaJMX']=_0x1a06c1,_0x40d4['PVRTmX']={},_0x40d4['kMJkhZ']=!![];}const _0x566987=_0x2cec9a[0x0],_0xb75b27=_0xbed595+_0x566987,_0x15786c=_0x40d4['PVRTmX'][_0xb75b27];return!_0x15786c?(_0x40d497=_0x40d4['BBaJMX'](_0x40d497),_0x40d4['PVRTmX'][_0xb75b27]=_0x40d497):_0x40d497=_0x15786c,_0x40d497;}export function registerDesktopAuth(_0x5ec489,_0x3d8688){const _0x3e3754=_0x40d4;_0x5ec489[_0x3e3754(0x129)](_0x3e3754(0x136),async(_0x540af5,_0x445f04)=>{const _0x236c51=_0x3e3754;if(_0x540af5['method']===_0x236c51(0x12c))return;const _0xb1f87f=requestPath(_0x540af5[_0x236c51(0x13b)]);if(isPublicPath(_0xb1f87f)||!requiresJwt(_0x540af5[_0x236c51(0x137)],_0xb1f87f))return;const _0x35b5c8=_0x540af5[_0x236c51(0x134)][_0x236c51(0x11f)];if(!_0x35b5c8||typeof _0x35b5c8!==_0x236c51(0x11c))return logAuthFailure(_0x3d8688,_0x236c51(0x130),_0x540af5),_0x445f04[_0x236c51(0x12a)](0x191)[_0x236c51(0x12e)]({'message':'Unauthorized'});try{const _0x316266=_0x50b66d[_0x236c51(0x131)](_0x35b5c8,{'complete':!![]}),_0x45321b=_0x316266&&typeof _0x316266==='object'&&_0x316266[_0x236c51(0x123)]?_0x316266[_0x236c51(0x123)]['kid']:undefined,_0x50fe33=_0x50b66d['verify'](_0x35b5c8,getSecretForKid(_0x45321b),{'algorithms':['HS256'],'issuer':'web-to-figma-plugin','audience':_0x236c51(0x113)});_0x540af5[_0x236c51(0x110)]=_0x50fe33[_0x236c51(0x115)],logAuthSuccess(_0x3d8688,_0x540af5);}catch(_0xa35b53){return logAuthFailure(_0x3d8688,_0x236c51(0x114),_0x540af5,_0xa35b53),_0x445f04[_0x236c51(0x12a)](0x191)[_0x236c51(0x12e)]({'message':_0x236c51(0x128)});}});}
@@ -0,0 +1,4 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { CaptureService } from '../../capture/captureService.js';
3
+ import type { AppContext } from '../context.js';
4
+ export declare function registerCaptureRoutes(app: FastifyInstance, ctx: AppContext, captureService: CaptureService): void;