From ef08fa61142ecc31929d3ae16f42f79dd8a21f39 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 10 Feb 2026 00:35:37 +0200 Subject: [PATCH] feat: move to secure OS credential storage so that you never get logged out again --- .vscode/launch.json | 36 +- README.md | 1 + bun.lock | 50 ++- electrobun.config.ts | 23 -- package.json | 8 +- src/bun/api/clients.ts | 112 +++++- src/bun/index.ts | 6 +- src/bun/server.ts | 14 + .../components/options/OptionInput.tsx | 49 +++ .../components/options/OptionSpace.tsx | 89 +++++ .../components/options/SettingsAppForm.tsx | 38 +++ src/mainview/routes/__root.tsx | 2 +- src/mainview/routes/index.tsx | 20 +- src/mainview/routes/settings/accounts.tsx | 320 ++++++------------ src/shared/constants.ts | 1 + 15 files changed, 493 insertions(+), 276 deletions(-) delete mode 100644 electrobun.config.ts create mode 100644 src/mainview/components/options/OptionInput.tsx create mode 100644 src/mainview/components/options/OptionSpace.tsx create mode 100644 src/mainview/components/options/SettingsAppForm.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json index fea718a..4698b37 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,35 +4,29 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "type": "bun", - "internalConsoleOptions": "neverOpen", - "request": "launch", - "name": "Debug File", - "program": "./src/bun/index.ts", - "cwd": "${workspaceFolder}", - "stopOnEntry": false, - "watchMode": true - }, - { - "type": "bun", - "internalConsoleOptions": "neverOpen", - "request": "launch", - "name": "Run File", - "program": "./src/bun/index.ts", - "cwd": "${workspaceFolder}", - "noDebug": true, - "watchMode": false + "name": "Attach to Edge", + "port": 9222, + "request": "attach", + "type": "msedge", + "webRoot": "${workspaceFolder}/src", }, { "type": "bun", "internalConsoleOptions": "neverOpen", "request": "attach", "name": "Attach Bun", - "url": "ws://127.0.0.1:9229/nvnfnsqez8s", + "url": "ws://127.0.0.1:9229/7lt63qegtr8", "localRoot": "${workspaceFolder}", - "stopOnEntry": false + "stopOnEntry": false, + } + ], + "compounds": [ + { + "name": "Attach Debug App", + "configurations": ["Attach Bun", "Attach to Edge"], + "stopAll": true, + "preLaunchTask": "bun: dev" } ] } \ No newline at end of file diff --git a/README.md b/README.md index 101f462..354e079 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Focused on building a simple user experience and intuitive UI. > [!WARNING] > This app is actively in development, it doesn't have most of its critical features implemented yet. +> It will have an opinionated design and will be used as an experiment in discovering a good UX. ## Features diff --git a/bun.lock b/bun.lock index 1f8eac5..2fe72ea 100644 --- a/bun.lock +++ b/bun.lock @@ -9,16 +9,20 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", + "@hackolade/keytar": "^7.9.0-7", "@rcompat/webview": "^0.18.0", "conf": "^15.0.2", "elysia": "^1.4.22", "pathe": "^2.0.3", + "tough-cookie": "^6.0.0", + "tough-cookie-file-store": "^3.3.0", "zod": "^4.3.6", }, "devDependencies": { "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-form": "^1.28.0", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.157.16", @@ -159,6 +163,18 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@hackolade/keytar": ["@hackolade/keytar@7.9.0-7", "", { "dependencies": { "@hackolade/keytar-darwin-arm64": "7.9.0-7", "@hackolade/keytar-darwin-x64": "7.9.0-7", "@hackolade/keytar-linux-arm64": "7.9.0-7", "@hackolade/keytar-linux-x64": "7.9.0-7", "@hackolade/keytar-win32-x64": "7.9.0-7" } }, "sha512-1U4Wfo3dbP63Dcl+SZyHgy3Q+sOdKzvZjuEu01BxDq4A/gtB/1e3Q9HouWUY37xdcTRSG1rD4iYirbQ79RN2iQ=="], + + "@hackolade/keytar-darwin-arm64": ["@hackolade/keytar-darwin-arm64@7.9.0-7", "", {}, "sha512-A4rE3nAnjtJ0JaKfXoYLGDHky0JbIK2AqyZUcpSTIfVo28rBhqbfY8JPqEfgwibInKO1vjDUeizlhSOP0Q5RQA=="], + + "@hackolade/keytar-darwin-x64": ["@hackolade/keytar-darwin-x64@7.9.0-7", "", {}, "sha512-xc/B1MrTD9cfxArBVto2dL9d9noRHgT/ZDZfBlfCEfb2pmbfg83WipDKxuu0nvL2Pzs2Ob26sBbFAxQE5djWIQ=="], + + "@hackolade/keytar-linux-arm64": ["@hackolade/keytar-linux-arm64@7.9.0-7", "", {}, "sha512-G99cXS3li/mnW3qtncLAsDNPpx6Jqut1HnRgJVnO1RNotGdGU6EDcTog4pPHy7TVSwsc3QZ3Jay5wfJ0meXnSQ=="], + + "@hackolade/keytar-linux-x64": ["@hackolade/keytar-linux-x64@7.9.0-7", "", {}, "sha512-Zx2e4aSbt3Ti0727GQMohlMqBOc7KElXOpZeW+F822U67CMckulJO0D4jcRKov5Kg3eO0nqCVT0GAnvLX6xtXw=="], + + "@hackolade/keytar-win32-x64": ["@hackolade/keytar-win32-x64@7.9.0-7", "", {}, "sha512-tsSNLp5N8w7c3cauMxOpO5/ZVnEQ5TRDiAwZAQxI1TRJ3zerS7GmDbUhriZPU22d1qp4q9Fd+nQFhOe4NvJ8Lg=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-O6TRP3g1188gdpZC9yio64KkvWD97QpLjaCFnwTgykTZtzQXZbkyw+lYPVfZ7Ee+m7eLau1/jEJmQcUY9G0sLw=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-gRbyyTjzpFVNmbD+Upn3w4dWV+bCXGJbff3A7leDO/tfNxSz1xIb6Ad/5UKtvEW9kDt/2Uyc3XkFZ6rpafvbfQ=="], @@ -179,6 +195,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -289,12 +307,20 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.28.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA=="], + "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + "@tanstack/react-form": ["@tanstack/react-form@1.28.0", "", { "dependencies": { "@tanstack/form-core": "1.28.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], @@ -319,7 +345,7 @@ "@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="], - "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], @@ -391,6 +417,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -729,6 +757,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -753,6 +783,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -761,10 +793,18 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tough-cookie-file-store": ["tough-cookie-file-store@3.3.0", "", { "dependencies": { "tough-cookie": "^6.0.0" } }, "sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -831,6 +871,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + + "@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -853,8 +897,12 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], diff --git a/electrobun.config.ts b/electrobun.config.ts deleted file mode 100644 index cd2f74f..0000000 --- a/electrobun.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -export default { - app: { - name: "gameflow-deck", - identifier: "simeonradivoev.gameflow-deck.app", - version: "0.0.1", - }, - build: { - // Vite builds to dist/, we copy from there - copy: { - "dist/index.html": "views/mainview/index.html", - "dist/assets": "views/mainview/assets", - }, - mac: { - bundleCEF: false, - }, - linux: { - bundleCEF: true, - }, - win: { - bundleCEF: false, - }, - }, -}; \ No newline at end of file diff --git a/package.json b/package.json index 9d29ca5..3161070 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Game Launcher", "type": "module", "scripts": { - "dev": "bun run build:dev && NODE_ENV=development bun run --inspect=127.0.0.1:9229 --watch ./src/bun/index.ts", + "dev": "NODE_ENV=development bun run build && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' bun run --inspect=127.0.0.1:9229 --watch ./src/bun/index.ts", "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run dev'", "build": "vite build", "build:pro": "NODE_ENV=production bun run build", @@ -14,7 +14,7 @@ "package:auto-prod": "bun run build:pro && NODE_ENV=production bun run ./scripts/package-bun.ts", "package:linux": "bun run build && TARGET=bun-linux-x64 bun run ./scripts/package-bun.ts", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", - "run:build-action": "act --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .github/workflows/build.yml", + "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .github/workflows/build.yml", "hmr": "vite --port 5173" }, "dependencies": { @@ -22,16 +22,20 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", + "@hackolade/keytar": "^7.9.0-7", "@rcompat/webview": "^0.18.0", "conf": "^15.0.2", "elysia": "^1.4.22", "pathe": "^2.0.3", + "tough-cookie": "^6.0.0", + "tough-cookie-file-store": "^3.3.0", "zod": "^4.3.6" }, "devDependencies": { "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-form": "^1.28.0", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.157.16", diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index fcaf899..e16ea15 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -1,7 +1,97 @@ +import z from "zod"; import { config } from "./settings"; -import Elysia from "elysia"; +import Elysia, { status } from "elysia"; +import keytar from '@hackolade/keytar'; +import { loginApiLoginPost } from "../../clients/romm"; +import { CookieJar } from 'tough-cookie'; +import FileCookieStore from 'tough-cookie-file-store'; +import path from 'node:path'; + +const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); +console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); +const jar = new CookieJar(fileCookieStore); +await login(); + +export async function logout () +{ + if (!config.has('rommAddress')) + { + return; + } + const rommAddress = config.get('rommAddress'); + if (rommAddress) + { + console.log("Logging Out of ROMM"); + try + { + await loginApiLoginPost({ + baseUrl: rommAddress, headers: { + 'cookie': await jar.getCookieString(rommAddress) + } + }); + } catch (error) + { + console.error("Failed to logout of ROMM ", error); + } + } +} + +async function login () +{ + if (!config.has('rommAddress') || !config.has('rommUser')) + { + return; + } + const rommAddress = config.get('rommAddress'); + const rommUser = config.get('rommUser'); + if (rommAddress && rommUser) + { + console.log("Logging In to ROMM"); + const password = await keytar.getPassword('romm', 'gameflow'); + const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` }); + loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress)); + } +} export const romm = new Elysia({ prefix: "/romm" }) + .post('/login', async ({ body: { host, username, password } }) => + { + if (config.has('rommAddress') && config.has('rommUser')) + { + await logout(); + const oldRommAddress = config.get('rommAddress'); + if (oldRommAddress) + { + const cookies = await jar.getCookies(oldRommAddress); + cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key)); + } + } + + config.set('rommAddress', host); + config.set('rommUser', username); + + await keytar.setPassword('romm', 'gameflow', password); + await login(); + + return status(200); + }, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) + .get('/login', async () => + { + const credentials = await keytar.getPassword('romm', 'gameflow'); + return { hasPassword: !!credentials }; + }, { response: z.object({ hasPassword: z.boolean() }) }) + .post('/logout', async () => + { + await keytar.deletePassword('romm', 'gameflow'); + await logout(); + const rommAddress = config.get('rommAddress'); + if (rommAddress) + { + const cookies = await jar.getCookies(rommAddress); + cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key)); + } + return status(200); + }) .all("/*", async ({ request, params, set }) => { if (!config.has('rommAddress') && !config.get('rommAddress')) @@ -16,18 +106,32 @@ export const romm = new Elysia({ prefix: "/romm" }) url.port = rommUrl.port; url.protocol = rommUrl.protocol; - // Forward headers (optional: remove host if needed) + // Forward headers (optional: remove host if needed) const headers = new Headers(request.headers); headers.delete('host'); headers.set("accept-encoding", "identity"); + headers.set('cookie', await jar.getCookieString(rommUrl.href)); - const rommResponse = await fetch(url, { + let rommResponse = await fetch(url, { method: request.method, headers, body: await request.arrayBuffer(), redirect: 'manual', // avoid ROMM redirects }); + /* + if (rommResponse.status === 403 && config.has('rommUser')) + { + await login(); + headers.set('cookie', await jar.getCookieString(rommUrl.href)); + rommResponse = await fetch(url, { + method: request.method, + headers, + body: await request.arrayBuffer(), + redirect: 'manual', // avoid ROMM redirects + }); + }*/ + set.status = rommResponse.status; rommResponse.headers.forEach((value, key) => { @@ -35,4 +139,4 @@ export const romm = new Elysia({ prefix: "/romm" }) }); return new Response(rommResponse.body, { status: rommResponse.status }); - }); \ No newline at end of file + }).on('stop', logout); \ No newline at end of file diff --git a/src/bun/index.ts b/src/bun/index.ts index 4356ed2..c3dfbc8 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -11,10 +11,10 @@ if (!Bun.env.PUBLIC_ACCESS) bunServer = RunBunServer(); } -function cleanup () +async function cleanup () { bunServer?.stop(); - api.apiServer.stop(); + await api.apiServer.stop(); process.exit(0); } @@ -25,7 +25,7 @@ try }); webviewWorker.addEventListener('error', console.error); await new Promise(resolve => webviewWorker.addEventListener('close', resolve)); - cleanup(); + await cleanup(); } catch (error) { diff --git a/src/bun/server.ts b/src/bun/server.ts index 2003f3b..48c70d6 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -1,6 +1,7 @@ import { SERVER_PORT } from "../shared/constants"; import path from 'node:path'; import { host } from "./utils"; +import appInfo from '../../package.json'; export function RunBunServer () { @@ -12,6 +13,19 @@ export function RunBunServer () "/": Bun.file("./dist/index.html"), // Serve a file by lazily loading it into memory "/favicon.ico": Bun.file("./dist/favicon.ico"), + "/.well-known/appspecific/com.chrome.devtools.json": new Response( + JSON.stringify({ + name: appInfo.name, + version: appInfo.version, + debuggable: true, + }), + { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }, + } + ) }, fetch: async (req) => { diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx new file mode 100644 index 0000000..1dd78e2 --- /dev/null +++ b/src/mainview/components/options/OptionInput.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react"; +import { twMerge } from "tailwind-merge"; +import { useOptionContext } from "./OptionSpace"; + +export function OptionInput (data: { + name: string; + type: HTMLInputTypeAttribute; + className?: string; + placeholder?: string; + icon?: JSX.Element; + value?: string; + defaultValue?: string; + onBlur?: FocusEventHandler; + onChange?: ChangeEventHandler; +}) +{ + const inputRef = useRef(null); + const option = useOptionContext({ + onOptionEnterPress () + { + inputRef.current?.focus(); + }, + }); + + return ( + + ); +} \ No newline at end of file diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx new file mode 100644 index 0000000..2d4bf32 --- /dev/null +++ b/src/mainview/components/options/OptionSpace.tsx @@ -0,0 +1,89 @@ +import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import classNames from "classnames"; +import { createContext, JSX, useContext, useEffect, useMemo } from "react"; +import { twMerge } from "tailwind-merge"; + +export const OptionContext = createContext( + {} as { + focused: boolean; + focus: (focusDetails?: FocusDetails | undefined) => void; + eventTarget: EventTarget; + }, +); + +export function useOptionContext (params?: { onOptionEnterPress?: () => void; }) +{ + const context = useContext(OptionContext); + useEffect(() => + { + if (params?.onOptionEnterPress) + { + context.eventTarget.addEventListener( + "onEnterPress", + params.onOptionEnterPress, + ); + } + + return () => + { + if (params?.onOptionEnterPress) + { + context.eventTarget.removeEventListener( + "onEnterPress", + params.onOptionEnterPress, + ); + } + }; + }, [context.eventTarget]); + return context; +} + +export function OptionSpace (data: { + id?: string; + className?: string; + focusable?: boolean; + children: JSX.Element; + label?: string | JSX.Element; +}) +{ + const eventTarget = useMemo(() => new EventTarget(), []); + const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ + focusKey: data.id, + focusable: data.focusable !== false, + trackChildren: true, + onEnterPress () + { + eventTarget.dispatchEvent(new CustomEvent("onEnterPress")); + }, + }); + + return ( + +
  • +
    + {typeof data.label === "string" ? ( + + ) : ( + data.label + )} +
    + {data.children} +
  • +
    +
    + ); +} \ No newline at end of file diff --git a/src/mainview/components/options/SettingsAppForm.tsx b/src/mainview/components/options/SettingsAppForm.tsx new file mode 100644 index 0000000..16633ba --- /dev/null +++ b/src/mainview/components/options/SettingsAppForm.tsx @@ -0,0 +1,38 @@ +import { createFormHook, createFormHookContexts } from "@tanstack/react-form"; +import { HTMLInputTypeAttribute, JSX } from "react"; +import { OptionInput } from "./OptionInput"; +import { OptionSpace } from "./OptionSpace"; +import classNames from "classnames"; +import { TriangleAlert } from "lucide-react"; + +// export useFieldContext for use in your custom components +export const { fieldContext, formContext, useFieldContext } = + createFormHookContexts(); + +export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsFormContext } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { FormOption }, + formComponents: {} +}); + +function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; }) +{ + const field = useFieldContext(); + return + {data.label} + {field.getMeta().errors.length > 0 &&
    + {field.state.meta.errors.map(e => e.message).join(',')} +
    } + }> + field.handleChange(e.target.value)} + placeholder={data.placeholder} + className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })} + /> +
    ;; +} \ No newline at end of file diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 9241e11..4aafc88 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -12,7 +12,7 @@ function RootComponent () return (
    - {import.meta.env.DEV && false && + {import.meta.env.DEV && <> diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 88870ee..05bbe7d 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -39,7 +39,7 @@ import { SaveSource } from "../scripts/spatialNavigation"; import LoadingCardList from "../components/LoadingCardList"; import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; -import { ErrorBoundary } from "react-error-boundary"; +import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; import Shortcuts from "../components/Shortcuts"; @@ -157,6 +157,15 @@ function CollectionList (data: { id: string, setBackground: (url: string) => voi ); } +function HomeListError (data: { focused: boolean; }) +{ + const error = useErrorBoundary(); + return
    + + {(error.error as any).detail} +
    ; +} + function HomeList (data: { selectedFilter: keyof typeof filters; }) @@ -176,14 +185,9 @@ function HomeList (data: { return ( -
    +
    - - - Error! Task failed successfully. -
    - }> + }> }> {lists[data.selectedFilter]} diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index ebb66a9..4f03bb3 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -1,171 +1,39 @@ import { FocusContext, - FocusDetails, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; -import { QueriesResults, useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query"; -import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; import classNames from "classnames"; -import { DoorOpen, Key, Link, Lock, User } from "lucide-react"; +import { Cross, Delete, Key, Link, Lock, Save, Trash, User, X } from "lucide-react"; import { - ChangeEventHandler, - createContext, - FocusEventHandler, HTMLInputTypeAttribute, JSX, useCallback, - useContext, useEffect, - useMemo, - useRef, useState, } from "react"; import { client } from "../.."; -import { SettingsType } from "../../../shared/constants"; +import { RPC_URL, SettingsType } from "../../../shared/constants"; import { getCurrentUserApiUsersMeGetOptions, - loginApiLoginPostMutation, - logoutApiLogoutPostMutation, statsApiStatsGetOptions, } from "../../../clients/romm/@tanstack/react-query.gen"; -import { useToasters } from "../../contexts/ToasterContext"; import { UserSchema } from "../../../clients/romm"; import toast from "react-hot-toast"; +import z from "zod"; +import { OptionSpace } from "../../components/options/OptionSpace"; +import { OptionInput } from "../../components/options/OptionInput"; +import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; import { twMerge } from "tailwind-merge"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, }); -const OptionContext = createContext( - {} as { - focused: boolean; - focus: (focusDetails?: FocusDetails | undefined) => void; - eventTarget: EventTarget; - }, -); - -function useOptionContext (params?: { onOptionEnterPress?: () => void; }) -{ - const context = useContext(OptionContext); - useEffect(() => - { - if (params?.onOptionEnterPress) - { - context.eventTarget.addEventListener( - "onEnterPress", - params.onOptionEnterPress, - ); - } - - return () => - { - if (params?.onOptionEnterPress) - { - context.eventTarget.removeEventListener( - "onEnterPress", - params.onOptionEnterPress, - ); - } - }; - }, [context.eventTarget]); - return context; -} - -function OptionSpace (data: { - id?: string; - className?: string; - focusable?: boolean; - children: JSX.Element; - label?: string | JSX.Element; -}) -{ - const eventTarget = useMemo(() => new EventTarget(), []); - const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ - focusKey: data.id, - focusable: data.focusable !== false, - trackChildren: true, - onEnterPress () - { - eventTarget.dispatchEvent(new CustomEvent("onEnterPress")); - }, - }); - - return ( - -
  • - {typeof data.label === "string" ? ( - - ) : ( - data.label - )} - {data.children} -
  • -
    -
    - ); -} - -function OptionInput (data: { - name: string; - type: HTMLInputTypeAttribute; - className?: string; - placeholder?: string; - icon?: JSX.Element; - value?: string; - onBlur?: FocusEventHandler; - onChange?: ChangeEventHandler; -}) -{ - const inputRef = useRef(null); - const option = useOptionContext({ - onOptionEnterPress () - { - inputRef.current?.focus(); - }, - }); - - return ( - - ); -} - type KeysWithValueAssignableTo = { [K in keyof T]: Exclude extends Value ? K : never; }[keyof T]; @@ -185,7 +53,7 @@ function Option (data: { queryKey: ["setting", data.id], queryFn: async () => { - const value = (await client.api.settings({ id: data.id! }).get()).data?.value; + const value = await client.api.settings({ id: data.id! }).get().then(d => d.data?.value); if (!dirty) { setLocalValue(String(value)); @@ -227,72 +95,109 @@ function Option (data: { ); } -function Button (data: { children?: any, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams) +function Button (data: { children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams) { const { ref, focused } = useFocusable({ focusKey: data.type, onEnterPress: data.onAction, - onFocus: data.onFocus + onFocus: data.onFocus, + focusable: !data.disabled }); return ; } -function LoginControls (data: { user: UseQueryResult; }) +function LoginControls (data: { hasPassword: boolean; }) { + const user = useQuery({ + ...getCurrentUserApiUsersMeGetOptions(), + queryKey: ['romm', 'auth', "login"], + refetchOnWindowFocus: false, + retry: 0 + }); + const context = useSettingsFormContext({}); + context.state.canSubmit; const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ - mutationKey: ["romm", "auth", "logout"], mutationFn: () => window.cookieStore.delete({ name: "romm_session" }), + mutationKey: ["romm", "auth", "logout"], mutationFn: () => client.api.romm.logout.post(), onSuccess: async (d, v, r, c) => { c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); } }); return
    - {data.user.isError &&
    + {user.isError &&
    } - {data.user.isSuccess &&
    Logged In As: {data.user.data?.username}
    } - - + } +
    ; } +const dataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); + function RouteComponent () { const { focus } = Route.useSearch(); const { ref, focusKey, focusSelf } = useFocusable({ preferredChildFocusKey: focus }); + + const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => client.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }); + const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => client.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); + const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => client.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); + + + const loginForm = useSettingsForm({ + defaultValues: { + hostname: hostname ?? '', + username: username ?? '', + password: '' + }, + onSubmit: async ({ value }) => + { + await toast.promise(loginMutation.mutateAsync(value), { + loading: "Logging In", + success: "Logged In", + error: e => e?.detail ?? "Error Logging In", + }); + loginForm.reset(); + }, + validators: { + onChange: dataSchema + } + }); + const rommOnline = useQuery({ ...statsApiStatsGetOptions(), refetchInterval: 30000, retry: false, }); - const user = useQuery({ - ...getCurrentUserApiUsersMeGetOptions(), - queryKey: ['romm', 'auth', "login"], - refetchOnWindowFocus: false, - retry: 0 - }); - useEffect(() => { if (focus) @@ -303,7 +208,10 @@ function RouteComponent () const loginMutation = useMutation({ mutationKey: ["romm", "login"], - ...loginApiLoginPostMutation(), + mutationFn: (data: z.infer) => + { + return client.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); + }, onSuccess: (d, v, r, c) => { c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); @@ -331,54 +239,40 @@ function RouteComponent ()

    Romm

    -
    - } - label="Romm Address" - /> -
    - { - e.preventDefault(); - const data = new FormData(e.currentTarget); - toast.promise(loginMutation.mutateAsync({ - auth: `${data.get("username")}:${data.get("password")}`, - }), { - loading: "Logging In", - success: "Logged In", - error: e => e?.detail ?? "Error Logging In", - }); - }} - > - - } - name="username" - type="text" - placeholder="Username" - /> - - - } - name="password" - type="password" - placeholder="Password" - /> - - - - -
    + +
    + { + e.preventDefault(); + e.stopPropagation(); + loginForm.handleSubmit(); + }} + onReset={e => + { + e.preventDefault(); + e.stopPropagation(); + loginForm.reset(); + }} + > + + + + +
    + } type='url' />} /> + + } type="text" />} /> + + } type="password" placeholder={hasPassword ? '*****' : "Password"} />} /> + + + + } /> + + ); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8411b57..e3379fa 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -20,6 +20,7 @@ export type GameMeta = z.infer; export const SettingsSchema = z.object({ rommAddress: z.url().optional(), + rommUser: z.string().default('admin').optional(), disableBlur: z.boolean().default(false), windowSize: z.object({ width: z.number(), height: z.number() }).default({ width: 1280, height: 800 }), windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),