From bce56f29faf9e880aa43b97fefb3dcc271ccbba1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 15:30:02 -0400 Subject: [PATCH 1/6] Add `server` command to the entrypoint This allows cmux to run as a web server. Run `node dist/main.js server` to start on port 3000. --- bun.lock | 118 +++++++++- package.json | 6 +- src/browser/api.ts | 266 ++++++++++++++++++++++ src/main-desktop.ts | 540 +++++++++++++++++++++++++++++++++++++++++++ src/main-server.ts | 219 ++++++++++++++++++ src/main.ts | 541 +------------------------------------------- src/main.tsx | 4 + tsconfig.main.json | 9 +- 8 files changed, 1163 insertions(+), 540 deletions(-) create mode 100644 src/browser/api.ts create mode 100644 src/main-desktop.ts create mode 100644 src/main-server.ts diff --git a/bun.lock b/bun.lock index 5bac150a7..9662a0896 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "@types/bun": "^1.2.23", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", + "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", @@ -69,6 +70,7 @@ "@vitejs/plugin-react": "^4.0.0", "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^8.2.0", + "cors": "^2.8.5", "dotenv": "^17.2.3", "electron": "^38.2.1", "electron-builder": "^24.6.0", @@ -77,6 +79,7 @@ "eslint": "^9.36.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "express": "^5.1.0", "jest": "^30.1.3", "playwright": "^1.56.0", "prettier": "^3.6.2", @@ -88,6 +91,7 @@ "vite": "^4.4.0", "vite-plugin-svgr": "^4.5.0", "vite-plugin-top-level-await": "^1.6.0", + "ws": "^8.18.3", }, }, }, @@ -612,10 +616,14 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -690,6 +698,10 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/express": ["@types/express@5.0.3", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], @@ -700,6 +712,8 @@ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="], + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], @@ -724,6 +738,8 @@ "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -736,6 +752,10 @@ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="], "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], @@ -744,6 +764,10 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], + + "@types/serve-static": ["@types/serve-static@1.15.9", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -854,6 +878,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -966,6 +992,8 @@ "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -994,6 +1022,8 @@ "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -1082,12 +1112,22 @@ "config-file-ts": ["config-file-ts@0.2.6", "", { "dependencies": { "glob": "^10.3.10", "typescript": "^5.3.3" } }, "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w=="], + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="], "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], @@ -1220,6 +1260,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], @@ -1272,6 +1314,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "electron": ["electron@38.3.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-Wij4AzX4SAV0X/ktq+NrWrp5piTCSS8F6YWh1KAcG+QRtNzyns9XLKERP68nFHIwfprhxF2YCN2uj7nx9DaeJw=="], @@ -1294,6 +1338,8 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1358,6 +1404,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1372,6 +1420,8 @@ "expect-playwright": ["expect-playwright@0.8.0", "", {}, "sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg=="], + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -1402,6 +1452,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + "find-cache-dir": ["find-cache-dir@3.3.2", "", { "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", "pkg-dir": "^4.1.0" } }, "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig=="], "find-file-up": ["find-file-up@0.1.3", "", { "dependencies": { "fs-exists-sync": "^0.1.0", "resolve-dir": "^0.1.0" } }, "sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A=="], @@ -1426,6 +1478,10 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fromentries": ["fromentries@1.3.2", "", {}, "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], @@ -1554,6 +1610,8 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], @@ -1592,6 +1650,8 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -1648,6 +1708,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1920,8 +1982,12 @@ "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoizerific": ["memoizerific@1.11.3", "", { "dependencies": { "map-or-similar": "^1.5.0" } }, "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -1990,9 +2056,9 @@ "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -2022,6 +2088,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -2056,6 +2124,8 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -2100,6 +2170,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -2112,6 +2184,8 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -2174,6 +2248,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -2184,6 +2260,8 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="], @@ -2192,6 +2270,10 @@ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="], @@ -2294,6 +2376,8 @@ "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], @@ -2302,7 +2386,7 @@ "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -2320,8 +2404,12 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -2332,6 +2420,8 @@ "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -2382,6 +2472,8 @@ "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "storybook": ["storybook@8.6.14", "", { "dependencies": { "@storybook/core": "8.6.14" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": { "sb": "./bin/index.cjs", "storybook": "./bin/index.cjs", "getstorybook": "./bin/index.cjs" } }, "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw=="], @@ -2462,6 +2554,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -2488,6 +2582,8 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2532,6 +2628,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], @@ -2556,6 +2654,8 @@ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -2878,6 +2978,8 @@ "foreground-child/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "glob/foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -2908,6 +3010,8 @@ "htmlparser2/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "istanbul-lib-processinfo/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -3062,12 +3166,16 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "read-config-file/dotenv": ["dotenv@9.0.2", "", {}, "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg=="], "readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3096,6 +3204,8 @@ "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -3358,6 +3468,8 @@ "find-process/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "istanbul-lib-report/make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], diff --git a/package.json b/package.json index bf82e642c..1e73a3e48 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@types/bun": "^1.2.23", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", + "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", @@ -98,6 +99,7 @@ "@vitejs/plugin-react": "^4.0.0", "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^8.2.0", + "cors": "^2.8.5", "dotenv": "^17.2.3", "electron": "^38.2.1", "electron-builder": "^24.6.0", @@ -106,6 +108,7 @@ "eslint": "^9.36.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "express": "^5.1.0", "jest": "^30.1.3", "playwright": "^1.56.0", "prettier": "^3.6.2", @@ -116,7 +119,8 @@ "typescript-eslint": "^8.45.0", "vite": "^4.4.0", "vite-plugin-svgr": "^4.5.0", - "vite-plugin-top-level-await": "^1.6.0" + "vite-plugin-top-level-await": "^1.6.0", + "ws": "^8.18.3" }, "build": { "appId": "com.cmux.app", diff --git a/src/browser/api.ts b/src/browser/api.ts new file mode 100644 index 000000000..3728ccff5 --- /dev/null +++ b/src/browser/api.ts @@ -0,0 +1,266 @@ +/** + * Browser API client. Used when running cmux in server mode. + */ +import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants"; +import type { IPCApi } from "@/types/ipc"; + +const API_BASE = window.location.origin; +const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://"); + +interface InvokeResponse { + success: boolean; + data?: T; + error?: string; +} + +// Helper function to invoke IPC handlers via HTTP +async function invokeIPC(channel: string, ...args: unknown[]): Promise { + const response = await fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ args }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result: InvokeResponse = await response.json(); + + if (!result.success) { + throw new Error(result.error || "Unknown error"); + } + + return result.data as T; +} + +// WebSocket connection manager +class WebSocketManager { + private ws: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private messageHandlers = new Map void>>(); + private channelWorkspaceIds = new Map(); // Track workspaceId for each channel + private isConnecting = false; + private shouldReconnect = true; + + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { + return; + } + + this.isConnecting = true; + this.ws = new WebSocket(`${WS_BASE}/ws`); + + this.ws.onopen = () => { + console.log("WebSocket connected"); + this.isConnecting = false; + + // Resubscribe to all channels with their workspace IDs + for (const channel of this.messageHandlers.keys()) { + const workspaceId = this.channelWorkspaceIds.get(channel); + this.subscribe(channel, workspaceId); + } + }; + + this.ws.onmessage = (event) => { + try { + const { channel, args } = JSON.parse(event.data); + const handlers = this.messageHandlers.get(channel); + if (handlers) { + handlers.forEach((handler) => handler(args[0])); + } + } catch (error) { + console.error("Error handling WebSocket message:", error); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.isConnecting = false; + }; + + this.ws.onclose = () => { + console.log("WebSocket disconnected"); + this.isConnecting = false; + this.ws = null; + + // Attempt to reconnect after a delay + if (this.shouldReconnect) { + this.reconnectTimer = setTimeout(() => this.connect(), 2000); + } + }; + } + + subscribe(channel: string, workspaceId?: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + console.log( + `[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId}` + ); + this.ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:chat", + workspaceId, + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) { + this.ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:metadata", + }) + ); + } + } + } + + unsubscribe(channel: string, workspaceId?: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + this.ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:chat", + workspaceId, + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) { + this.ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:metadata", + }) + ); + } + } + } + + on(channel: string, handler: (data: unknown) => void, workspaceId?: string): () => void { + if (!this.messageHandlers.has(channel)) { + this.messageHandlers.set(channel, new Set()); + // Store workspaceId for this channel (needed for reconnection) + if (workspaceId) { + this.channelWorkspaceIds.set(channel, workspaceId); + } + this.connect(); + this.subscribe(channel, workspaceId); + } + + const handlers = this.messageHandlers.get(channel)!; + handlers.add(handler); + + // Return unsubscribe function + return () => { + handlers.delete(handler); + if (handlers.size === 0) { + this.messageHandlers.delete(channel); + this.channelWorkspaceIds.delete(channel); + this.unsubscribe(channel, workspaceId); + } + }; + } + + disconnect(): void { + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } +} + +const wsManager = new WebSocketManager(); + +// Create the Web API implementation +const webApi: IPCApi = { + dialog: { + selectDirectory: async () => { + // TODO: Implement remote directory selection for mobile + // For now, return hardcoded path for testing + return "/home/kyle/projects/coder/cmux"; + }, + }, + providers: { + setProviderConfig: (provider, keyPath, value) => + invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST), + }, + projects: { + create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath), + remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath), + list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST), + listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), + secrets: { + get: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath), + update: (projectPath, secrets) => + invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets), + }, + }, + workspace: { + list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST), + create: (projectPath, branchName, trunkBranch) => + invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch), + remove: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options), + rename: (workspaceId, newName) => + invokeIPC(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName), + fork: (sourceWorkspaceId, newName) => + invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName), + sendMessage: (workspaceId, message, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), + resumeStream: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), + interruptStream: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), + truncateHistory: (workspaceId, percentage) => + invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), + replaceChatHistory: (workspaceId, summaryMessage) => + invokeIPC(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage), + getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId), + executeBash: (workspaceId, script, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), + openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), + + onChat: (workspaceId, callback) => { + const channel = getChatChannel(workspaceId); + return wsManager.on(channel, callback as (data: unknown) => void, workspaceId); + }, + + onMetadata: (callback) => { + return wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, callback as (data: unknown) => void); + }, + }, + window: { + setTitle: (title) => { + document.title = title; + return Promise.resolve(); + }, + }, + update: { + check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK), + download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD), + install: () => { + // Install is a one-way call that doesn't wait for response + invokeIPC(IPC_CHANNELS.UPDATE_INSTALL); + }, + onStatus: (callback) => { + return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); + }, + }, +}; + +if (typeof window["api"] === "undefined") { + // @ts-ignore + window["api"] = webApi; +} + +window.addEventListener("beforeunload", () => { + wsManager.disconnect(); +}); diff --git a/src/main-desktop.ts b/src/main-desktop.ts new file mode 100644 index 000000000..58ed794dc --- /dev/null +++ b/src/main-desktop.ts @@ -0,0 +1,540 @@ +// Enable source map support for better error stack traces in production +import "source-map-support/register"; +import "disposablestack/auto"; + +import type { MenuItemConstructorOptions } from "electron"; +import { + app, + BrowserWindow, + ipcMain as electronIpcMain, + Menu, + shell, + dialog, + screen, +} from "electron"; +import * as fs from "fs"; +import * as path from "path"; +import type { Config } from "./config"; +import type { IpcMain } from "./services/ipcMain"; +import { VERSION } from "./version"; +import type { loadTokenizerModules } from "./utils/main/tokenizer"; +import { IPC_CHANNELS } from "./constants/ipc-constants"; +import { log } from "./services/log"; +import { parseDebugUpdater } from "./utils/env"; + +// React DevTools for development profiling +// Using require() instead of import since it's dev-only and conditionally loaded +interface Extension { + name: string; + id: string; +} + +type ExtensionInstaller = ( + ext: { id: string }, + options?: { loadExtensionOptions?: { allowFileAccess?: boolean } } +) => Promise; + +let installExtension: ExtensionInstaller | null = null; +let REACT_DEVELOPER_TOOLS: { id: string } | null = null; + +if (!app.isPackaged) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const devtools = require("electron-devtools-installer") as { + default: ExtensionInstaller; + REACT_DEVELOPER_TOOLS: { id: string }; + }; + installExtension = devtools.default; + REACT_DEVELOPER_TOOLS = devtools.REACT_DEVELOPER_TOOLS; + } catch (error) { + console.log("React DevTools not available:", error); + } +} + +// IMPORTANT: Lazy-load heavy dependencies to maintain fast startup time +// +// To keep startup time under 4s, avoid importing AI SDK packages at the top level. +// These files MUST use dynamic import(): +// - main.ts, config.ts, preload.ts (startup-critical) +// +// ✅ GOOD: const { createAnthropic } = await import("@ai-sdk/anthropic"); +// ❌ BAD: import { createAnthropic } from "@ai-sdk/anthropic"; +// +// Enforcement: scripts/check_eager_imports.sh validates this in CI +// +// Lazy-load Config and IpcMain to avoid loading heavy AI SDK dependencies at startup +// These will be loaded on-demand when createWindow() is called +let config: Config | null = null; +let ipcMain: IpcMain | null = null; +let loadTokenizerModulesFn: typeof loadTokenizerModules | null = null; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let updaterService: typeof import("./services/updater").UpdaterService.prototype | null = null; +const isE2ETest = process.env.CMUX_E2E === "1"; +const forceDistLoad = process.env.CMUX_E2E_LOAD_DIST === "1"; + +if (isE2ETest) { + // For e2e tests, use a test-specific userData directory + // Note: We can't use config.rootDir here because config isn't loaded yet + // However, we must respect CMUX_TEST_ROOT to maintain test isolation + const testRoot = process.env.CMUX_TEST_ROOT ?? path.join(process.env.HOME ?? "~", ".cmux"); + const e2eUserData = path.join(testRoot, "user-data"); + try { + fs.mkdirSync(e2eUserData, { recursive: true }); + app.setPath("userData", e2eUserData); + console.log("Using test userData directory:", e2eUserData); + } catch (error) { + console.warn("Failed to prepare test userData directory:", error); + } +} + +const devServerPort = process.env.CMUX_DEVSERVER_PORT ?? "5173"; + +console.log( + `Cmux starting - version: ${(VERSION as { git?: string; buildTime?: string }).git ?? "(dev)"} (built: ${(VERSION as { git?: string; buildTime?: string }).buildTime ?? "dev-mode"})` +); +console.log("Main process starting..."); + +// Debug: abort immediately if CMUX_DEBUG_START_TIME is set +// This is used to measure baseline startup time without full initialization +if (process.env.CMUX_DEBUG_START_TIME === "1") { + console.log("CMUX_DEBUG_START_TIME is set - aborting immediately"); + process.exit(0); +} + +// Global error handlers for better error reporting +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); + console.error("Stack:", error.stack); + + // Show error dialog in production + if (app.isPackaged) { + dialog.showErrorBox( + "Application Error", + `An unexpected error occurred:\n\n${error.message}\n\nStack trace:\n${error.stack ?? "No stack trace available"}` + ); + } +}); + +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise); + console.error("Reason:", reason); + + if (app.isPackaged) { + const message = reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + dialog.showErrorBox( + "Unhandled Promise Rejection", + `An unhandled promise rejection occurred:\n\n${message}\n\nStack trace:\n${stack ?? "No stack trace available"}` + ); + } +}); + +// Single instance lock +const gotTheLock = app.requestSingleInstanceLock(); +console.log("Single instance lock acquired:", gotTheLock); + +if (!gotTheLock) { + // Another instance is already running, quit this one + console.log("Another instance is already running, quitting..."); + app.quit(); +} else { + // This is the primary instance + console.log("This is the primary instance"); + app.on("second-instance", () => { + // Someone tried to run a second instance, focus our window instead + console.log("Second instance attempted to start"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} + +let mainWindow: BrowserWindow | null = null; +let splashWindow: BrowserWindow | null = null; + +/** + * Format timestamp as HH:MM:SS.mmm for readable logging + */ +function timestamp(): string { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + const ms = String(now.getMilliseconds()).padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${ms}`; +} + +function createMenu() { + const template: MenuItemConstructorOptions[] = [ + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + // Reload without Ctrl+R shortcut (reserved for Code Review refresh) + { + label: "Reload", + click: (_item, focusedWindow) => { + if (focusedWindow && "reload" in focusedWindow) { + (focusedWindow as BrowserWindow).reload(); + } + }, + }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [{ role: "minimize" }, { role: "close" }], + }, + ]; + + if (process.platform === "darwin") { + template.unshift({ + label: app.getName(), + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services", submenu: [] }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} + +/** + * Create and show splash screen - instant visual feedback (<100ms) + * + * Shows a lightweight native window with static HTML while services load. + * No IPC, no React, no heavy dependencies - just immediate user feedback. + */ +async function showSplashScreen() { + const startTime = Date.now(); + console.log(`[${timestamp()}] Showing splash screen...`); + + splashWindow = new BrowserWindow({ + width: 400, + height: 300, + frame: false, + transparent: false, + backgroundColor: "#1f1f1f", // Match splash HTML background (hsl(0 0% 12%)) - prevents white flash + alwaysOnTop: true, + center: true, + resizable: false, + show: false, // Don't show until HTML is loaded + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + // Wait for splash HTML to load + await splashWindow.loadFile(path.join(__dirname, "splash.html")); + + // Wait for the window to actually be shown and rendered before continuing + // This ensures the splash is visible before we block the event loop with heavy work + await new Promise((resolve) => { + splashWindow!.once("show", () => { + const loadTime = Date.now() - startTime; + console.log(`[${timestamp()}] Splash screen shown (${loadTime}ms)`); + // Give one more event loop tick for the window to actually paint + setImmediate(resolve); + }); + splashWindow!.show(); + }); + + splashWindow.on("closed", () => { + console.log(`[${timestamp()}] Splash screen closed event`); + splashWindow = null; + }); +} + +/** + * Close splash screen + */ +function closeSplashScreen() { + if (splashWindow) { + console.log(`[${timestamp()}] Closing splash screen...`); + splashWindow.close(); + splashWindow = null; + } +} + +/** + * Load backend services (Config, IpcMain, AI SDK, tokenizer) + * + * Heavy initialization (~100ms) happens here while splash is visible. + * Note: Spinner may freeze briefly during this phase. This is acceptable since + * the splash still provides visual feedback that the app is loading. + */ +async function loadServices(): Promise { + if (config && ipcMain && loadTokenizerModulesFn) return; // Already loaded + + const startTime = Date.now(); + console.log(`[${timestamp()}] Loading services...`); + + /* eslint-disable no-restricted-syntax */ + // Dynamic imports are justified here for performance: + // - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.) + // - These are large modules (~100ms load time) that would block splash from appearing + // - Loading happens once, then cached + const [ + { Config: ConfigClass }, + { IpcMain: IpcMainClass }, + { loadTokenizerModules: loadTokenizerFn }, + { UpdaterService: UpdaterServiceClass }, + ] = await Promise.all([ + import("./config"), + import("./services/ipcMain"), + import("./utils/main/tokenizer"), + import("./services/updater"), + ]); + /* eslint-enable no-restricted-syntax */ + config = new ConfigClass(); + ipcMain = new IpcMainClass(config); + loadTokenizerModulesFn = loadTokenizerFn; + + // Initialize updater service in packaged builds or when DEBUG_UPDATER is set + const debugConfig = parseDebugUpdater(process.env.DEBUG_UPDATER); + + if (app.isPackaged || debugConfig.enabled) { + updaterService = new UpdaterServiceClass(); + const debugInfo = debugConfig.fakeVersion + ? `debug with fake version ${debugConfig.fakeVersion}` + : `debug enabled`; + console.log( + `[${timestamp()}] Updater service initialized (packaged: ${app.isPackaged}, ${debugConfig.enabled ? debugInfo : ""})` + ); + } else { + console.log( + `[${timestamp()}] Updater service disabled in dev mode (set DEBUG_UPDATER=1 or DEBUG_UPDATER= to enable)` + ); + } + + const loadTime = Date.now() - startTime; + console.log(`[${timestamp()}] Services loaded in ${loadTime}ms`); +} + +function createWindow() { + if (!ipcMain) { + throw new Error("Services must be loaded before creating window"); + } + + // Calculate window size based on screen dimensions (80% of available space) + const primaryDisplay = screen.getPrimaryDisplay(); + const { width: screenWidth, height: screenHeight } = primaryDisplay.workArea; + + const windowWidth = Math.max(1200, Math.floor(screenWidth * 0.8)); + const windowHeight = Math.max(800, Math.floor(screenHeight * 0.8)); + + mainWindow = new BrowserWindow({ + width: windowWidth, + height: windowHeight, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, "preload.js"), + }, + title: "cmux - coder multiplexer", + // Hide menu bar on Linux by default (like VS Code) + // User can press Alt to toggle it + autoHideMenuBar: process.platform === "linux", + show: false, // Don't show until ready-to-show event + }); + + // Register IPC handlers with the main window + ipcMain.register(electronIpcMain, mainWindow); + + // Register updater IPC handlers (available in both dev and prod) + electronIpcMain.handle(IPC_CHANNELS.UPDATE_CHECK, () => { + // Note: log interface already includes timestamp and file location + log.debug(`UPDATE_CHECK called (updaterService: ${updaterService ? "available" : "null"})`); + if (!updaterService) { + // Send "idle" status if updater not initialized (dev mode without DEBUG_UPDATER) + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, { + type: "idle" as const, + }); + } + return; + } + log.debug("Calling updaterService.checkForUpdates()"); + updaterService.checkForUpdates(); + }); + + electronIpcMain.handle(IPC_CHANNELS.UPDATE_DOWNLOAD, async () => { + if (!updaterService) throw new Error("Updater not available in development"); + await updaterService.downloadUpdate(); + }); + + electronIpcMain.handle(IPC_CHANNELS.UPDATE_INSTALL, () => { + if (!updaterService) throw new Error("Updater not available in development"); + updaterService.installUpdate(); + }); + + // Handle status subscription requests + // Note: React StrictMode in dev causes components to mount twice, resulting in duplicate calls + electronIpcMain.on(IPC_CHANNELS.UPDATE_STATUS_SUBSCRIBE, () => { + log.debug("UPDATE_STATUS_SUBSCRIBE called"); + if (!mainWindow) return; + const status = updaterService ? updaterService.getStatus() : { type: "idle" }; + log.debug("Sending current status to renderer:", status); + mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, status); + }); + + // Set up updater service with the main window (only in production) + if (updaterService) { + updaterService.setMainWindow(mainWindow); + // Note: Checks are initiated by frontend to respect telemetry preference + } + + // Show window once it's ready and close splash + mainWindow.once("ready-to-show", () => { + console.log(`[${timestamp()}] Main window ready to show`); + mainWindow?.show(); + closeSplashScreen(); + }); + + // Open all external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + + mainWindow.webContents.on("will-navigate", (event, url) => { + const currentOrigin = new URL(mainWindow!.webContents.getURL()).origin; + const targetOrigin = new URL(url).origin; + // Prevent navigation away from app origin, open externally instead + if (targetOrigin !== currentOrigin) { + event.preventDefault(); + void shell.openExternal(url); + } + }); + + // Load from dev server in development, built files in production + // app.isPackaged is true when running from a built .app/.exe, false in development + if ((isE2ETest && !forceDistLoad) || (!app.isPackaged && !forceDistLoad)) { + // Development mode: load from vite dev server + const devHost = process.env.CMUX_DEVSERVER_HOST ?? "127.0.0.1"; + void mainWindow.loadURL(`http://${devHost}:${devServerPort}`); + if (!isE2ETest) { + mainWindow.webContents.once("did-finish-load", () => { + mainWindow?.webContents.openDevTools(); + }); + } + } else { + // Production mode: load built files + void mainWindow.loadFile(path.join(__dirname, "index.html")); + } + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +// Only setup app handlers if we got the lock +if (gotTheLock) { + void app.whenReady().then(async () => { + try { + console.log("App ready, creating window..."); + + // Install React DevTools in development + if (!app.isPackaged && installExtension && REACT_DEVELOPER_TOOLS) { + try { + const extension = await installExtension(REACT_DEVELOPER_TOOLS, { + loadExtensionOptions: { allowFileAccess: true }, + }); + console.log(`✅ React DevTools installed: ${extension.name} (id: ${extension.id})`); + } catch (err) { + console.log("❌ Error installing React DevTools:", err); + } + } + + createMenu(); + + // Three-phase startup: + // 1. Show splash immediately (<100ms) and wait for it to load + // 2. Load services while splash visible (fast - ~100ms) + // 3. Create window and start loading content (splash stays visible) + // 4. When window ready-to-show: close splash, show main window + // + // Skip splash in E2E tests to avoid app.firstWindow() grabbing the wrong window + if (!isE2ETest) { + await showSplashScreen(); // Wait for splash to actually load + } + await loadServices(); + createWindow(); + // Note: splash closes in ready-to-show event handler + + // Start loading tokenizer modules in background after window is created + // This ensures accurate token counts for first API calls (especially in e2e tests) + // Loading happens asynchronously and won't block the UI + if (loadTokenizerModulesFn) { + void loadTokenizerModulesFn().then(() => { + console.log(`[${timestamp()}] Tokenizer modules loaded`); + }); + } + // No need to auto-start workspaces anymore - they start on demand + } catch (error) { + console.error(`[${timestamp()}] Startup failed:`, error); + + closeSplashScreen(); + + // Show error dialog to user + const errorMessage = + error instanceof Error ? `${error.message}\n\n${error.stack ?? ""}` : String(error); + + dialog.showErrorBox( + "Startup Failed", + `The application failed to start:\n\n${errorMessage}\n\nPlease check the console for details.` + ); + + // Quit after showing error + app.quit(); + } + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + app.on("activate", () => { + // Only create window if app is ready and no window exists + // This prevents "Cannot create BrowserWindow before app is ready" error + if (app.isReady() && mainWindow === null) { + void (async () => { + await showSplashScreen(); + await loadServices(); + createWindow(); + })(); + } + }); +} diff --git a/src/main-server.ts b/src/main-server.ts new file mode 100644 index 000000000..b2b2ebf50 --- /dev/null +++ b/src/main-server.ts @@ -0,0 +1,219 @@ +/** + * HTTP/WebSocket Server for cmux + * Allows accessing cmux backend from mobile devices + */ +import { Config } from "./config"; +import { IPC_CHANNELS } from "@/constants/ipc-constants"; +import { IpcMain } from "./services/ipcMain"; +import cors from "cors"; +import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron"; +import express from "express"; +import * as http from "http"; +import * as path from "path"; +import { WebSocket, WebSocketServer } from "ws"; + +// Mock Electron's ipcMain for HTTP +class HttpIpcMainAdapter { + private handlers = new Map Promise>(); + private listeners = new Map void>>(); + + constructor(private app: express.Application) {} + + handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise): void { + this.handlers.set(channel, handler); + + // Create HTTP endpoint for this handler + this.app.post(`/ipc/${encodeURIComponent(channel)}`, async (req, res) => { + try { + const args = req.body.args || []; + const result = await handler(null, ...args); + + // If handler returns an error result object, unwrap it and send as error response + // This ensures webApi.ts will throw with the proper error message + if ( + result && + typeof result === "object" && + "success" in result && + result.success === false + ) { + const errorMessage = + "error" in result && typeof result.error === "string" ? result.error : "Unknown error"; + // Return 200 with error structure so webApi can throw with the detailed message + res.json({ success: false, error: errorMessage }); + return; + } + + res.json({ success: true, data: result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error in handler ${channel}:`, error); + res.json({ success: false, error: message }); + } + }); + } + + on(channel: string, handler: (event: unknown, ...args: unknown[]) => void): void { + if (!this.listeners.has(channel)) { + this.listeners.set(channel, []); + } + this.listeners.get(channel)!.push(handler); + } + + send(channel: string, ...args: unknown[]): void { + const handlers = this.listeners.get(channel); + if (handlers) { + handlers.forEach((handler) => handler(null, ...args)); + } + } +} + +type Clients = Map; metadataSubscription: boolean }>; + +// Mock BrowserWindow for events +class MockBrowserWindow { + constructor(private clients: Clients) {} + + webContents = { + send: (channel: string, ...args: unknown[]) => { + // Broadcast to all WebSocket clients + const message = JSON.stringify({ channel, args }); + this.clients.forEach((clientInfo, client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + // Only send to clients subscribed to this channel + if (channel === IPC_CHANNELS.WORKSPACE_METADATA && clientInfo.metadataSubscription) { + client.send(message); + } else if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + // Extract workspace ID from channel + const workspaceId = channel.replace(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX, ""); + if (clientInfo.chatSubscriptions.has(workspaceId)) { + client.send(message); + } + } else { + // Send other channels to all clients + client.send(message); + } + }); + }, + }; +} + +const app = express(); + +// Enable CORS for all routes +app.use(cors()); +app.use(express.json({ limit: "50mb" })); + +// Initialize config and IPC service +const config = new Config(); +const ipcMainService = new IpcMain(config); + +// Track WebSocket clients and their subscriptions +const clients: Clients = new Map(); + +const mockWindow = new MockBrowserWindow(clients); +const httpIpcMain = new HttpIpcMainAdapter(app); + +// Register IPC handlers +ipcMainService.register( + httpIpcMain as unknown as ElectronIpcMain, + mockWindow as unknown as BrowserWindow +); + +// Serve static files from dist directory (built renderer) +app.use(express.static(path.join(__dirname, "."))); + +// Health check endpoint +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +// Fallback to index.html for SPA routes (use middleware instead of deprecated wildcard) +app.use((req, res, next) => { + if (!req.path.startsWith("/ipc") && !req.path.startsWith("/ws")) { + res.sendFile(path.join(__dirname, ".")); + } else { + next(); + } +}); + +// Create HTTP server +const server = http.createServer(app); + +// Create WebSocket server +const wss = new WebSocketServer({ server, path: "/ws" }); + +wss.on("connection", (ws) => { + console.log("Client connected"); + + // Initialize client tracking + clients.set(ws, { + chatSubscriptions: new Set(), + metadataSubscription: false, + }); + + ws.on("message", (data) => { + try { + const message = JSON.parse(data.toString()); + const { type, channel, workspaceId, args } = message; + + const clientInfo = clients.get(ws); + if (!clientInfo) return; + + if (type === "subscribe") { + if (channel === "workspace:chat") { + console.log(`[WS] Client subscribed to workspace chat: ${workspaceId}`); + clientInfo.chatSubscriptions.add(workspaceId); + console.log( + `[WS] Subscription added. Current subscriptions:`, + Array.from(clientInfo.chatSubscriptions) + ); + + // Send subscription acknowledgment through IPC system + console.log(`[WS] Triggering workspace:chat:subscribe handler for ${workspaceId}`); + httpIpcMain.send("workspace:chat:subscribe", workspaceId); + } else if (channel === "workspace:metadata") { + console.log("[WS] Client subscribed to workspace metadata"); + clientInfo.metadataSubscription = true; + + // Send subscription acknowledgment + httpIpcMain.send("workspace:metadata:subscribe"); + } + } else if (type === "unsubscribe") { + if (channel === "workspace:chat") { + console.log(`Client unsubscribed from workspace chat: ${workspaceId}`); + clientInfo.chatSubscriptions.delete(workspaceId); + + // Send unsubscription acknowledgment + httpIpcMain.send("workspace:chat:unsubscribe", workspaceId); + } else if (channel === "workspace:metadata") { + console.log("Client unsubscribed from workspace metadata"); + clientInfo.metadataSubscription = false; + + // Send unsubscription acknowledgment + httpIpcMain.send("workspace:metadata:unsubscribe"); + } + } else if (type === "invoke") { + // Handle direct IPC invocations over WebSocket (for streaming responses) + // This is not currently used but could be useful for future enhancements + console.log(`WebSocket invoke: ${channel}`); + } + } catch (error) { + console.error("Error handling WebSocket message:", error); + } + }); + + ws.on("close", () => { + console.log("Client disconnected"); + clients.delete(ws); + }); + + ws.on("error", (error) => { + console.error("WebSocket error:", error); + }); +}); + +server.listen(3000, () => { + console.log("Server is running on port 3000"); +}); diff --git a/src/main.ts b/src/main.ts index 58ed794dc..30bddd19f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,540 +1,11 @@ -// Enable source map support for better error stack traces in production -import "source-map-support/register"; -import "disposablestack/auto"; - -import type { MenuItemConstructorOptions } from "electron"; -import { - app, - BrowserWindow, - ipcMain as electronIpcMain, - Menu, - shell, - dialog, - screen, -} from "electron"; -import * as fs from "fs"; -import * as path from "path"; -import type { Config } from "./config"; -import type { IpcMain } from "./services/ipcMain"; -import { VERSION } from "./version"; -import type { loadTokenizerModules } from "./utils/main/tokenizer"; -import { IPC_CHANNELS } from "./constants/ipc-constants"; -import { log } from "./services/log"; -import { parseDebugUpdater } from "./utils/env"; - -// React DevTools for development profiling -// Using require() instead of import since it's dev-only and conditionally loaded -interface Extension { - name: string; - id: string; -} - -type ExtensionInstaller = ( - ext: { id: string }, - options?: { loadExtensionOptions?: { allowFileAccess?: boolean } } -) => Promise; - -let installExtension: ExtensionInstaller | null = null; -let REACT_DEVELOPER_TOOLS: { id: string } | null = null; - -if (!app.isPackaged) { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const devtools = require("electron-devtools-installer") as { - default: ExtensionInstaller; - REACT_DEVELOPER_TOOLS: { id: string }; - }; - installExtension = devtools.default; - REACT_DEVELOPER_TOOLS = devtools.REACT_DEVELOPER_TOOLS; - } catch (error) { - console.log("React DevTools not available:", error); - } -} - -// IMPORTANT: Lazy-load heavy dependencies to maintain fast startup time -// -// To keep startup time under 4s, avoid importing AI SDK packages at the top level. -// These files MUST use dynamic import(): -// - main.ts, config.ts, preload.ts (startup-critical) -// -// ✅ GOOD: const { createAnthropic } = await import("@ai-sdk/anthropic"); -// ❌ BAD: import { createAnthropic } from "@ai-sdk/anthropic"; -// -// Enforcement: scripts/check_eager_imports.sh validates this in CI -// -// Lazy-load Config and IpcMain to avoid loading heavy AI SDK dependencies at startup -// These will be loaded on-demand when createWindow() is called -let config: Config | null = null; -let ipcMain: IpcMain | null = null; -let loadTokenizerModulesFn: typeof loadTokenizerModules | null = null; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -let updaterService: typeof import("./services/updater").UpdaterService.prototype | null = null; -const isE2ETest = process.env.CMUX_E2E === "1"; -const forceDistLoad = process.env.CMUX_E2E_LOAD_DIST === "1"; - -if (isE2ETest) { - // For e2e tests, use a test-specific userData directory - // Note: We can't use config.rootDir here because config isn't loaded yet - // However, we must respect CMUX_TEST_ROOT to maintain test isolation - const testRoot = process.env.CMUX_TEST_ROOT ?? path.join(process.env.HOME ?? "~", ".cmux"); - const e2eUserData = path.join(testRoot, "user-data"); - try { - fs.mkdirSync(e2eUserData, { recursive: true }); - app.setPath("userData", e2eUserData); - console.log("Using test userData directory:", e2eUserData); - } catch (error) { - console.warn("Failed to prepare test userData directory:", error); - } -} - -const devServerPort = process.env.CMUX_DEVSERVER_PORT ?? "5173"; - -console.log( - `Cmux starting - version: ${(VERSION as { git?: string; buildTime?: string }).git ?? "(dev)"} (built: ${(VERSION as { git?: string; buildTime?: string }).buildTime ?? "dev-mode"})` -); -console.log("Main process starting..."); - -// Debug: abort immediately if CMUX_DEBUG_START_TIME is set -// This is used to measure baseline startup time without full initialization -if (process.env.CMUX_DEBUG_START_TIME === "1") { - console.log("CMUX_DEBUG_START_TIME is set - aborting immediately"); - process.exit(0); -} - -// Global error handlers for better error reporting -process.on("uncaughtException", (error) => { - console.error("Uncaught Exception:", error); - console.error("Stack:", error.stack); - - // Show error dialog in production - if (app.isPackaged) { - dialog.showErrorBox( - "Application Error", - `An unexpected error occurred:\n\n${error.message}\n\nStack trace:\n${error.stack ?? "No stack trace available"}` - ); - } -}); - -process.on("unhandledRejection", (reason, promise) => { - console.error("Unhandled Rejection at:", promise); - console.error("Reason:", reason); - - if (app.isPackaged) { - const message = reason instanceof Error ? reason.message : String(reason); - const stack = reason instanceof Error ? reason.stack : undefined; - dialog.showErrorBox( - "Unhandled Promise Rejection", - `An unhandled promise rejection occurred:\n\n${message}\n\nStack trace:\n${stack ?? "No stack trace available"}` - ); - } -}); - -// Single instance lock -const gotTheLock = app.requestSingleInstanceLock(); -console.log("Single instance lock acquired:", gotTheLock); - -if (!gotTheLock) { - // Another instance is already running, quit this one - console.log("Another instance is already running, quitting..."); - app.quit(); -} else { - // This is the primary instance - console.log("This is the primary instance"); - app.on("second-instance", () => { - // Someone tried to run a second instance, focus our window instead - console.log("Second instance attempted to start"); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - }); -} - -let mainWindow: BrowserWindow | null = null; -let splashWindow: BrowserWindow | null = null; - -/** - * Format timestamp as HH:MM:SS.mmm for readable logging - */ -function timestamp(): string { - const now = new Date(); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - const ms = String(now.getMilliseconds()).padStart(3, "0"); - return `${hours}:${minutes}:${seconds}.${ms}`; -} - -function createMenu() { - const template: MenuItemConstructorOptions[] = [ - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - // Reload without Ctrl+R shortcut (reserved for Code Review refresh) - { - label: "Reload", - click: (_item, focusedWindow) => { - if (focusedWindow && "reload" in focusedWindow) { - (focusedWindow as BrowserWindow).reload(); - } - }, - }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "Window", - submenu: [{ role: "minimize" }, { role: "close" }], - }, - ]; - - if (process.platform === "darwin") { - template.unshift({ - label: app.getName(), - submenu: [ - { role: "about" }, - { type: "separator" }, - { role: "services", submenu: [] }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - } - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); -} - -/** - * Create and show splash screen - instant visual feedback (<100ms) - * - * Shows a lightweight native window with static HTML while services load. - * No IPC, no React, no heavy dependencies - just immediate user feedback. - */ -async function showSplashScreen() { - const startTime = Date.now(); - console.log(`[${timestamp()}] Showing splash screen...`); - - splashWindow = new BrowserWindow({ - width: 400, - height: 300, - frame: false, - transparent: false, - backgroundColor: "#1f1f1f", // Match splash HTML background (hsl(0 0% 12%)) - prevents white flash - alwaysOnTop: true, - center: true, - resizable: false, - show: false, // Don't show until HTML is loaded - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - - // Wait for splash HTML to load - await splashWindow.loadFile(path.join(__dirname, "splash.html")); - - // Wait for the window to actually be shown and rendered before continuing - // This ensures the splash is visible before we block the event loop with heavy work - await new Promise((resolve) => { - splashWindow!.once("show", () => { - const loadTime = Date.now() - startTime; - console.log(`[${timestamp()}] Splash screen shown (${loadTime}ms)`); - // Give one more event loop tick for the window to actually paint - setImmediate(resolve); - }); - splashWindow!.show(); - }); - - splashWindow.on("closed", () => { - console.log(`[${timestamp()}] Splash screen closed event`); - splashWindow = null; - }); -} - -/** - * Close splash screen - */ -function closeSplashScreen() { - if (splashWindow) { - console.log(`[${timestamp()}] Closing splash screen...`); - splashWindow.close(); - splashWindow = null; - } -} - /** - * Load backend services (Config, IpcMain, AI SDK, tokenizer) - * - * Heavy initialization (~100ms) happens here while splash is visible. - * Note: Spinner may freeze briefly during this phase. This is acceptable since - * the splash still provides visual feedback that the app is loading. + * The main CLI entrypoint for cmux. */ -async function loadServices(): Promise { - if (config && ipcMain && loadTokenizerModulesFn) return; // Already loaded - - const startTime = Date.now(); - console.log(`[${timestamp()}] Loading services...`); - - /* eslint-disable no-restricted-syntax */ - // Dynamic imports are justified here for performance: - // - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.) - // - These are large modules (~100ms load time) that would block splash from appearing - // - Loading happens once, then cached - const [ - { Config: ConfigClass }, - { IpcMain: IpcMainClass }, - { loadTokenizerModules: loadTokenizerFn }, - { UpdaterService: UpdaterServiceClass }, - ] = await Promise.all([ - import("./config"), - import("./services/ipcMain"), - import("./utils/main/tokenizer"), - import("./services/updater"), - ]); - /* eslint-enable no-restricted-syntax */ - config = new ConfigClass(); - ipcMain = new IpcMainClass(config); - loadTokenizerModulesFn = loadTokenizerFn; - - // Initialize updater service in packaged builds or when DEBUG_UPDATER is set - const debugConfig = parseDebugUpdater(process.env.DEBUG_UPDATER); - - if (app.isPackaged || debugConfig.enabled) { - updaterService = new UpdaterServiceClass(); - const debugInfo = debugConfig.fakeVersion - ? `debug with fake version ${debugConfig.fakeVersion}` - : `debug enabled`; - console.log( - `[${timestamp()}] Updater service initialized (packaged: ${app.isPackaged}, ${debugConfig.enabled ? debugInfo : ""})` - ); - } else { - console.log( - `[${timestamp()}] Updater service disabled in dev mode (set DEBUG_UPDATER=1 or DEBUG_UPDATER= to enable)` - ); - } - - const loadTime = Date.now() - startTime; - console.log(`[${timestamp()}] Services loaded in ${loadTime}ms`); -} - -function createWindow() { - if (!ipcMain) { - throw new Error("Services must be loaded before creating window"); - } - - // Calculate window size based on screen dimensions (80% of available space) - const primaryDisplay = screen.getPrimaryDisplay(); - const { width: screenWidth, height: screenHeight } = primaryDisplay.workArea; - - const windowWidth = Math.max(1200, Math.floor(screenWidth * 0.8)); - const windowHeight = Math.max(800, Math.floor(screenHeight * 0.8)); - - mainWindow = new BrowserWindow({ - width: windowWidth, - height: windowHeight, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: path.join(__dirname, "preload.js"), - }, - title: "cmux - coder multiplexer", - // Hide menu bar on Linux by default (like VS Code) - // User can press Alt to toggle it - autoHideMenuBar: process.platform === "linux", - show: false, // Don't show until ready-to-show event - }); - - // Register IPC handlers with the main window - ipcMain.register(electronIpcMain, mainWindow); - - // Register updater IPC handlers (available in both dev and prod) - electronIpcMain.handle(IPC_CHANNELS.UPDATE_CHECK, () => { - // Note: log interface already includes timestamp and file location - log.debug(`UPDATE_CHECK called (updaterService: ${updaterService ? "available" : "null"})`); - if (!updaterService) { - // Send "idle" status if updater not initialized (dev mode without DEBUG_UPDATER) - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, { - type: "idle" as const, - }); - } - return; - } - log.debug("Calling updaterService.checkForUpdates()"); - updaterService.checkForUpdates(); - }); - - electronIpcMain.handle(IPC_CHANNELS.UPDATE_DOWNLOAD, async () => { - if (!updaterService) throw new Error("Updater not available in development"); - await updaterService.downloadUpdate(); - }); - electronIpcMain.handle(IPC_CHANNELS.UPDATE_INSTALL, () => { - if (!updaterService) throw new Error("Updater not available in development"); - updaterService.installUpdate(); - }); +const isServer = process.argv.length > 2 && process.argv[2] === "server"; - // Handle status subscription requests - // Note: React StrictMode in dev causes components to mount twice, resulting in duplicate calls - electronIpcMain.on(IPC_CHANNELS.UPDATE_STATUS_SUBSCRIBE, () => { - log.debug("UPDATE_STATUS_SUBSCRIBE called"); - if (!mainWindow) return; - const status = updaterService ? updaterService.getStatus() : { type: "idle" }; - log.debug("Sending current status to renderer:", status); - mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, status); - }); - - // Set up updater service with the main window (only in production) - if (updaterService) { - updaterService.setMainWindow(mainWindow); - // Note: Checks are initiated by frontend to respect telemetry preference - } - - // Show window once it's ready and close splash - mainWindow.once("ready-to-show", () => { - console.log(`[${timestamp()}] Main window ready to show`); - mainWindow?.show(); - closeSplashScreen(); - }); - - // Open all external links in default browser - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); - return { action: "deny" }; - }); - - mainWindow.webContents.on("will-navigate", (event, url) => { - const currentOrigin = new URL(mainWindow!.webContents.getURL()).origin; - const targetOrigin = new URL(url).origin; - // Prevent navigation away from app origin, open externally instead - if (targetOrigin !== currentOrigin) { - event.preventDefault(); - void shell.openExternal(url); - } - }); - - // Load from dev server in development, built files in production - // app.isPackaged is true when running from a built .app/.exe, false in development - if ((isE2ETest && !forceDistLoad) || (!app.isPackaged && !forceDistLoad)) { - // Development mode: load from vite dev server - const devHost = process.env.CMUX_DEVSERVER_HOST ?? "127.0.0.1"; - void mainWindow.loadURL(`http://${devHost}:${devServerPort}`); - if (!isE2ETest) { - mainWindow.webContents.once("did-finish-load", () => { - mainWindow?.webContents.openDevTools(); - }); - } - } else { - // Production mode: load built files - void mainWindow.loadFile(path.join(__dirname, "index.html")); - } - - mainWindow.on("closed", () => { - mainWindow = null; - }); -} - -// Only setup app handlers if we got the lock -if (gotTheLock) { - void app.whenReady().then(async () => { - try { - console.log("App ready, creating window..."); - - // Install React DevTools in development - if (!app.isPackaged && installExtension && REACT_DEVELOPER_TOOLS) { - try { - const extension = await installExtension(REACT_DEVELOPER_TOOLS, { - loadExtensionOptions: { allowFileAccess: true }, - }); - console.log(`✅ React DevTools installed: ${extension.name} (id: ${extension.id})`); - } catch (err) { - console.log("❌ Error installing React DevTools:", err); - } - } - - createMenu(); - - // Three-phase startup: - // 1. Show splash immediately (<100ms) and wait for it to load - // 2. Load services while splash visible (fast - ~100ms) - // 3. Create window and start loading content (splash stays visible) - // 4. When window ready-to-show: close splash, show main window - // - // Skip splash in E2E tests to avoid app.firstWindow() grabbing the wrong window - if (!isE2ETest) { - await showSplashScreen(); // Wait for splash to actually load - } - await loadServices(); - createWindow(); - // Note: splash closes in ready-to-show event handler - - // Start loading tokenizer modules in background after window is created - // This ensures accurate token counts for first API calls (especially in e2e tests) - // Loading happens asynchronously and won't block the UI - if (loadTokenizerModulesFn) { - void loadTokenizerModulesFn().then(() => { - console.log(`[${timestamp()}] Tokenizer modules loaded`); - }); - } - // No need to auto-start workspaces anymore - they start on demand - } catch (error) { - console.error(`[${timestamp()}] Startup failed:`, error); - - closeSplashScreen(); - - // Show error dialog to user - const errorMessage = - error instanceof Error ? `${error.message}\n\n${error.stack ?? ""}` : String(error); - - dialog.showErrorBox( - "Startup Failed", - `The application failed to start:\n\n${errorMessage}\n\nPlease check the console for details.` - ); - - // Quit after showing error - app.quit(); - } - }); - - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - app.on("activate", () => { - // Only create window if app is ready and no window exists - // This prevents "Cannot create BrowserWindow before app is ready" error - if (app.isReady() && mainWindow === null) { - void (async () => { - await showSplashScreen(); - await loadServices(); - createWindow(); - })(); - } - }); +if (isServer) { + require("./main-server"); +} else { + require("./main-desktop"); } diff --git a/src/main.tsx b/src/main.tsx index bdd70af2d..fba387313 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { initTelemetry, trackAppStarted } from "./telemetry"; +// Shims the `window.api` object with the browser API. +// This occurs if we are not running in Electron. +import "./browser/api"; + // Initialize telemetry on app startup initTelemetry(); trackAppStarted(); diff --git a/tsconfig.main.json b/tsconfig.main.json index d913052f7..064488c22 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -6,6 +6,13 @@ "noEmit": false, "sourceMap": true }, - "include": ["src/main.ts", "src/constants/**/*", "src/types/**/*.d.ts"], + "include": [ + "src/main.ts", + "src/main-server.ts", + "src/main-desktop.ts", + "src/constants/**/*", + "src/web/**/*", + "src/types/**/*.d.ts" + ], "exclude": ["src/App.tsx", "src/main.tsx"] } From 2c3ebe6605e65312f6297ccf50a38ab5d6b8a8c8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 15:50:42 -0400 Subject: [PATCH 2/6] Add directory selection modal --- src/App.tsx | 2 + src/browser/api.ts | 22 ++++- src/components/DirectorySelectModal.tsx | 122 ++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 src/components/DirectorySelectModal.tsx diff --git a/src/App.tsx b/src/App.tsx index 45c382397..2370b60c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import type { WorkspaceSelection } from "./components/ProjectSidebar"; import type { FrontendWorkspaceMetadata } from "./types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; import NewWorkspaceModal from "./components/NewWorkspaceModal"; +import { DirectorySelectModal } from "./components/DirectorySelectModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; @@ -870,6 +871,7 @@ function AppInner() { onAdd={handleCreateWorkspace} /> )} + ); diff --git a/src/browser/api.ts b/src/browser/api.ts index 3728ccff5..f408f41d1 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -178,14 +178,26 @@ class WebSocketManager { const wsManager = new WebSocketManager(); +// Directory selection via custom event (for browser mode) +interface DirectorySelectEvent extends CustomEvent { + detail: { + resolve: (path: string | null) => void; + }; +} + +function requestDirectorySelection(): Promise { + return new Promise((resolve) => { + const event = new CustomEvent("directory-select-request", { + detail: { resolve }, + }) as DirectorySelectEvent; + window.dispatchEvent(event); + }); +} + // Create the Web API implementation const webApi: IPCApi = { dialog: { - selectDirectory: async () => { - // TODO: Implement remote directory selection for mobile - // For now, return hardcoded path for testing - return "/home/kyle/projects/coder/cmux"; - }, + selectDirectory: requestDirectorySelection, }, providers: { setProviderConfig: (provider, keyPath, value) => diff --git a/src/components/DirectorySelectModal.tsx b/src/components/DirectorySelectModal.tsx new file mode 100644 index 000000000..503142ba3 --- /dev/null +++ b/src/components/DirectorySelectModal.tsx @@ -0,0 +1,122 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import styled from "@emotion/styled"; +import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; + +const InputField = styled.input` + width: 100%; + padding: 8px 12px; + background: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #fff; + font-size: 14px; + font-family: var(--font-monospace); + margin-bottom: 20px; + + &:focus { + outline: none; + border-color: #007acc; + } + + &::placeholder { + color: #888; + } +`; + +const ErrorText = styled.div` + color: var(--color-error); + font-size: 12px; + margin-top: -12px; + margin-bottom: 12px; +`; + +/** + * Self-contained directory selection modal for browser mode. + * + * Listens for 'directory-select-request' custom events and displays + * a modal for the user to enter a directory path. The promise from + * the event is resolved with the selected path or null if cancelled. + */ +export const DirectorySelectModal: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + const [path, setPath] = useState(""); + const [error, setError] = useState(""); + const resolveRef = useRef<((path: string | null) => void) | null>(null); + + // Listen for directory selection requests + useEffect(() => { + const handleDirectorySelectRequest = (e: Event) => { + const customEvent = e as CustomEvent<{ + resolve: (path: string | null) => void; + }>; + + resolveRef.current = customEvent.detail.resolve; + setPath(""); + setError(""); + setIsOpen(true); + }; + + window.addEventListener("directory-select-request", handleDirectorySelectRequest); + return () => { + window.removeEventListener("directory-select-request", handleDirectorySelectRequest); + }; + }, []); + + const handleCancel = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(null); + resolveRef.current = null; + } + setIsOpen(false); + }, []); + + const handleSelect = useCallback(() => { + const trimmedPath = path.trim(); + if (!trimmedPath) { + setError("Please enter a directory path"); + return; + } + + if (resolveRef.current) { + resolveRef.current(trimmedPath); + resolveRef.current = null; + } + setIsOpen(false); + }, [path]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSelect(); + } + }, + [handleSelect] + ); + + return ( + + { + setPath(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="/home/user/projects/my-project" + autoFocus + /> + {error && {error}} + + Cancel + Select + + + ); +}; From 6da7aec23564ba9f9a3155661ea7c0c4a8abb8a3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 17:17:08 -0400 Subject: [PATCH 3/6] Add types --- bun.lock | 6 ++++++ package.json | 2 ++ 2 files changed, 8 insertions(+) diff --git a/bun.lock b/bun.lock index 9662a0896..80edff981 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "@storybook/test-runner": "^0.23.0", "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", + "@types/cors": "^2.8.19", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", "@types/express": "^5.0.3", @@ -64,6 +65,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/write-file-atomic": "^4.0.3", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "@typescript/native-preview": "^7.0.0-dev.20251014.1", @@ -624,6 +626,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -782,6 +786,8 @@ "@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], diff --git a/package.json b/package.json index 1e73a3e48..20341bd8f 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@storybook/test-runner": "^0.23.0", "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", + "@types/cors": "^2.8.19", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", "@types/express": "^5.0.3", @@ -93,6 +94,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/write-file-atomic": "^4.0.3", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "@typescript/native-preview": "^7.0.0-dev.20251014.1", From 7abc4f8858da330122886456b612c6445dd55224 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 17:55:52 -0400 Subject: [PATCH 4/6] Fix linting errors --- src/browser/api.ts | 19 ++++++++++--------- src/main-desktop.ts | 16 +++++++++------- src/main-server.ts | 34 ++++++++++++++++++++++++++-------- src/main.ts | 2 ++ 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/browser/api.ts b/src/browser/api.ts index f408f41d1..e0b0e8648 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -27,10 +27,10 @@ async function invokeIPC(channel: string, ...args: unknown[]): Promise { throw new Error(`HTTP error! status: ${response.status}`); } - const result: InvokeResponse = await response.json(); + const result = (await response.json()) as InvokeResponse; if (!result.success) { - throw new Error(result.error || "Unknown error"); + throw new Error(result.error ?? "Unknown error"); } return result.data as T; @@ -66,9 +66,10 @@ class WebSocketManager { this.ws.onmessage = (event) => { try { - const { channel, args } = JSON.parse(event.data); + const parsed = JSON.parse(event.data as string) as { channel: string; args: unknown[] }; + const { channel, args } = parsed; const handlers = this.messageHandlers.get(channel); - if (handlers) { + if (handlers && args.length > 0) { handlers.forEach((handler) => handler(args[0])); } } catch (error) { @@ -97,7 +98,7 @@ class WebSocketManager { if (this.ws?.readyState === WebSocket.OPEN) { if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { console.log( - `[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId}` + `[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}` ); this.ws.send( JSON.stringify({ @@ -260,7 +261,7 @@ const webApi: IPCApi = { download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD), install: () => { // Install is a one-way call that doesn't wait for response - invokeIPC(IPC_CHANNELS.UPDATE_INSTALL); + void invokeIPC(IPC_CHANNELS.UPDATE_INSTALL); }, onStatus: (callback) => { return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); @@ -268,9 +269,9 @@ const webApi: IPCApi = { }, }; -if (typeof window["api"] === "undefined") { - // @ts-ignore - window["api"] = webApi; +if (typeof window.api === "undefined") { + // @ts-expect-error - Assigning to window.api which is not in TypeScript types + window.api = webApi; } window.addEventListener("beforeunload", () => { diff --git a/src/main-desktop.ts b/src/main-desktop.ts index 58ed794dc..774a00968 100644 --- a/src/main-desktop.ts +++ b/src/main-desktop.ts @@ -78,13 +78,15 @@ if (isE2ETest) { // However, we must respect CMUX_TEST_ROOT to maintain test isolation const testRoot = process.env.CMUX_TEST_ROOT ?? path.join(process.env.HOME ?? "~", ".cmux"); const e2eUserData = path.join(testRoot, "user-data"); - try { - fs.mkdirSync(e2eUserData, { recursive: true }); - app.setPath("userData", e2eUserData); - console.log("Using test userData directory:", e2eUserData); - } catch (error) { - console.warn("Failed to prepare test userData directory:", error); - } + void (async () => { + try { + await fs.promises.mkdir(e2eUserData, { recursive: true }); + app.setPath("userData", e2eUserData); + console.log("Using test userData directory:", e2eUserData); + } catch (error) { + console.warn("Failed to prepare test userData directory:", error); + } + })(); } const devServerPort = process.env.CMUX_DEVSERVER_PORT ?? "5173"; diff --git a/src/main-server.ts b/src/main-server.ts index b2b2ebf50..75bfe86a4 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -10,6 +10,7 @@ import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron"; import express from "express"; import * as http from "http"; import * as path from "path"; +import type { RawData } from "ws"; import { WebSocket, WebSocketServer } from "ws"; // Mock Electron's ipcMain for HTTP @@ -17,7 +18,7 @@ class HttpIpcMainAdapter { private handlers = new Map Promise>(); private listeners = new Map void>>(); - constructor(private app: express.Application) {} + constructor(private readonly app: express.Application) {} handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise): void { this.handlers.set(channel, handler); @@ -25,7 +26,8 @@ class HttpIpcMainAdapter { // Create HTTP endpoint for this handler this.app.post(`/ipc/${encodeURIComponent(channel)}`, async (req, res) => { try { - const args = req.body.args || []; + const body = req.body as { args?: unknown[] }; + const args: unknown[] = body.args ?? []; const result = await handler(null, ...args); // If handler returns an error result object, unwrap it and send as error response @@ -71,7 +73,7 @@ type Clients = Map; metadataSubscrip // Mock BrowserWindow for events class MockBrowserWindow { - constructor(private clients: Clients) {} + constructor(private readonly clients: Clients) {} webContents = { send: (channel: string, ...args: unknown[]) => { @@ -153,16 +155,32 @@ wss.on("connection", (ws) => { metadataSubscription: false, }); - ws.on("message", (data) => { + ws.on("message", (rawData: RawData) => { try { - const message = JSON.parse(data.toString()); - const { type, channel, workspaceId, args } = message; + // WebSocket data can be Buffer, ArrayBuffer, or string - convert to string + let dataStr: string; + if (typeof rawData === "string") { + dataStr = rawData; + } else if (Buffer.isBuffer(rawData)) { + dataStr = rawData.toString("utf-8"); + } else if (rawData instanceof ArrayBuffer) { + dataStr = Buffer.from(rawData).toString("utf-8"); + } else { + // Array of Buffers + dataStr = Buffer.concat(rawData as Buffer[]).toString("utf-8"); + } + const message = JSON.parse(dataStr) as { + type: string; + channel: string; + workspaceId?: string; + }; + const { type, channel, workspaceId } = message; const clientInfo = clients.get(ws); if (!clientInfo) return; if (type === "subscribe") { - if (channel === "workspace:chat") { + if (channel === "workspace:chat" && workspaceId) { console.log(`[WS] Client subscribed to workspace chat: ${workspaceId}`); clientInfo.chatSubscriptions.add(workspaceId); console.log( @@ -181,7 +199,7 @@ wss.on("connection", (ws) => { httpIpcMain.send("workspace:metadata:subscribe"); } } else if (type === "unsubscribe") { - if (channel === "workspace:chat") { + if (channel === "workspace:chat" && workspaceId) { console.log(`Client unsubscribed from workspace chat: ${workspaceId}`); clientInfo.chatSubscriptions.delete(workspaceId); diff --git a/src/main.ts b/src/main.ts index 30bddd19f..603e0dced 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,9 @@ const isServer = process.argv.length > 2 && process.argv[2] === "server"; if (isServer) { + // eslint-disable-next-line @typescript-eslint/no-require-imports require("./main-server"); } else { + // eslint-disable-next-line @typescript-eslint/no-require-imports require("./main-desktop"); } From bce20e27d0770faca6661ad578bf95d2af0e3429 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 18:10:07 -0400 Subject: [PATCH 5/6] Fix e2e tests --- src/main-desktop.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main-desktop.ts b/src/main-desktop.ts index 774a00968..58ed794dc 100644 --- a/src/main-desktop.ts +++ b/src/main-desktop.ts @@ -78,15 +78,13 @@ if (isE2ETest) { // However, we must respect CMUX_TEST_ROOT to maintain test isolation const testRoot = process.env.CMUX_TEST_ROOT ?? path.join(process.env.HOME ?? "~", ".cmux"); const e2eUserData = path.join(testRoot, "user-data"); - void (async () => { - try { - await fs.promises.mkdir(e2eUserData, { recursive: true }); - app.setPath("userData", e2eUserData); - console.log("Using test userData directory:", e2eUserData); - } catch (error) { - console.warn("Failed to prepare test userData directory:", error); - } - })(); + try { + fs.mkdirSync(e2eUserData, { recursive: true }); + app.setPath("userData", e2eUserData); + console.log("Using test userData directory:", e2eUserData); + } catch (error) { + console.warn("Failed to prepare test userData directory:", error); + } } const devServerPort = process.env.CMUX_DEVSERVER_PORT ?? "5173"; From 4885f4f05beb80b6cddcc75d6d966be41359063e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 20 Oct 2025 18:13:44 -0400 Subject: [PATCH 6/6] Fix eslint config for main --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 41855ff3d..65045f056 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -329,7 +329,7 @@ export default defineConfig([ "src/config.ts", "src/debug/**/*.ts", "src/git.ts", - "src/main.ts", + "src/main-desktop.ts", "src/config.test.ts", "src/services/gitService.ts", "src/services/log.ts",