diff --git a/locales/en.json b/locales/en.json index 4394639a..b75e202e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -93,9 +93,19 @@ }, "actions": { "signIn": "Sign In", + "signInWithQr": "Sign in with QR code", + "cancelQrLogin": "Cancel QR login", "connecting": "Connecting...", "restoringSession": "Restoring Session..." }, + "qr": { + "title": "Scan to sign in", + "preparing": "Preparing QR login", + "preparingDescription": "Requesting a sign-in code...", + "description": "Scan with your phone, then approve the sign-in request. OpenNOW will continue automatically.", + "alt": "Login QR code", + "expired": "QR login expired. Start a new QR login to try again." + }, "accounts": { "activeAccount": "Active account", "addAccount": "Add account", diff --git a/opennow-stable/bun.lock b/opennow-stable/bun.lock index 5c22e1e7..995f001a 100644 --- a/opennow-stable/bun.lock +++ b/opennow-stable/bun.lock @@ -8,6 +8,7 @@ "discord-rpc": "^4.0.1", "electron-updater": "^6.8.3", "lucide-react": "^1.17.0", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "ws": "^8.21.0", @@ -15,6 +16,7 @@ "devDependencies": { "@types/discord-rpc": "^4.0.10", "@types/node": "^22.19.17", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", @@ -304,6 +306,8 @@ "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -406,6 +410,8 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -468,6 +474,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], @@ -486,6 +494,8 @@ "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="], @@ -564,6 +574,8 @@ "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "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-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -692,6 +704,8 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], @@ -794,10 +808,16 @@ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -814,6 +834,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], @@ -836,6 +858,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], @@ -856,6 +880,8 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], @@ -992,6 +1018,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1054,6 +1082,8 @@ "@types/plist/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@types/qrcode/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@types/responselike/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "@types/ws/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], @@ -1114,8 +1144,12 @@ "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "react-scan/@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], @@ -1138,6 +1172,8 @@ "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1194,6 +1230,12 @@ "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], @@ -1340,6 +1382,8 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "cacache/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 55cd0385..3c9a6973 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -11,6 +11,7 @@ "discord-rpc": "^4.0.1", "electron-updater": "^6.8.3", "lucide-react": "^1.17.0", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "ws": "^8.21.0" @@ -18,6 +19,7 @@ "devDependencies": { "@types/discord-rpc": "^4.0.10", "@types/node": "^22.19.17", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", @@ -64,6 +66,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -739,7 +742,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -761,7 +763,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -778,7 +779,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -793,7 +793,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2464,12 +2463,23 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2601,6 +2611,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2626,7 +2637,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2636,7 +2646,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3046,6 +3055,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3317,6 +3327,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001785", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", @@ -3474,7 +3493,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3487,7 +3505,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3562,8 +3579,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -3622,6 +3638,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3740,6 +3765,12 @@ "license": "MIT", "optional": true }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -3822,6 +3853,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -4223,7 +4255,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -4244,7 +4275,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4275,19 +4305,8 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4576,6 +4595,19 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4697,7 +4729,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5150,7 +5181,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5346,6 +5376,18 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -5710,7 +5752,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -6065,6 +6106,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -6078,6 +6146,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -6085,6 +6162,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6186,6 +6272,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -6222,7 +6317,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -6240,7 +6334,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -6251,6 +6344,7 @@ "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6337,6 +6431,89 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6355,6 +6532,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6364,6 +6542,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6476,12 +6655,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -6551,7 +6735,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6584,6 +6767,7 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6711,6 +6895,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6907,7 +7097,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6938,7 +7127,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7020,7 +7208,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -7823,6 +8010,7 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8426,6 +8614,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/opennow-stable/package.json b/opennow-stable/package.json index e80eeffe..bcb81c0e 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -31,6 +31,7 @@ "discord-rpc": "^4.0.1", "electron-updater": "^6.8.3", "lucide-react": "^1.17.0", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "ws": "^8.21.0" @@ -38,6 +39,7 @@ "devDependencies": { "@types/discord-rpc": "^4.0.10", "@types/node": "^22.19.17", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index 66f683cb..01eb132d 100644 --- a/opennow-stable/src/main/gfn/auth.ts +++ b/opennow-stable/src/main/gfn/auth.ts @@ -9,6 +9,11 @@ import { shell } from "electron"; import type { AuthLoginRequest, + AuthDeviceLoginAttemptRequest, + AuthDeviceLoginChallenge, + AuthDeviceLoginPollRequest, + AuthDeviceLoginPollResult, + AuthDeviceLoginStartRequest, AuthSession, AuthSessionResult, AuthTokens, @@ -30,10 +35,14 @@ const TOKEN_ENDPOINT = "https://login.nvidia.com/token"; const CLIENT_TOKEN_ENDPOINT = "https://login.nvidia.com/client_token"; const USERINFO_ENDPOINT = "https://login.nvidia.com/userinfo"; const AUTH_ENDPOINT = "https://login.nvidia.com/authorize"; +const DEVICE_AUTHORIZE_ENDPOINT = "https://login.nvidia.com/device/authorize"; const CLIENT_ID = "ZU7sPN-miLujMD95LfOQ453IB0AtjM8sMyvgJ9wCXEQ"; +const STEAM_DECK_CLIENT_ID = "q61ddeJrVt7O90Nl-P-N7I36yctih4Ml6FyXLrb6j-U"; const SCOPES = "openid consent email tk_client age"; const DEFAULT_IDP_ID = "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg"; +const STEAM_DECK_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64; Steam Deck) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; const REDIRECT_PORTS = [2259, 6460, 7119, 8870, 9096]; const TOKEN_REFRESH_WINDOW_MS = 10 * 60 * 1000; @@ -73,6 +82,20 @@ interface ClientTokenResponse { expires_in?: number; } +interface DeviceAuthorizationResponse { + device_code?: string; + user_code?: string; + verification_uri?: string; + verification_uri_complete?: string; + expires_in?: number; + interval?: number; +} + +interface DeviceTokenErrorResponse { + error?: string; + error_description?: string; +} + interface ServerInfoResponse { requestStatus?: { serverId?: string; @@ -83,6 +106,12 @@ interface ServerInfoResponse { }>; } +interface DeviceLoginAttempt { + provider: LoginProvider; + deviceCode: string; + expiresAt: number; +} + function defaultProvider(): LoginProvider { return { idpId: DEFAULT_IDP_ID, @@ -164,6 +193,36 @@ function generatePkce(): { verifier: string; challenge: string } { return { verifier, challenge }; } +function buildAuthHeadersForClient( + authClientId = CLIENT_ID, + options: { + bearerToken?: string; + accept?: string; + contentType?: string; + includeReferer?: boolean; + } = {}, +): Record { + if (authClientId !== STEAM_DECK_CLIENT_ID) { + return buildNvidiaAuthHeaders(options); + } + + const headers: Record = { + Accept: options.accept ?? "application/json, text/plain, */*", + Origin: "https://play.geforcenow.com", + Referer: "https://play.geforcenow.com/", + "User-Agent": STEAM_DECK_USER_AGENT, + }; + + if (options.bearerToken !== undefined) { + headers.Authorization = `Bearer ${options.bearerToken}`; + } + if (options.contentType) { + headers["Content-Type"] = options.contentType; + } + + return headers; +} + function buildAuthUrl(provider: LoginProvider, challenge: string, port: number): string { const redirectUri = `http://localhost:${port}`; const nonce = randomBytes(16).toString("hex"); @@ -250,7 +309,7 @@ async function exchangeAuthorizationCode(code: string, verifier: string, port: n const response = await fetch(TOKEN_ENDPOINT, { method: "POST", - headers: buildNvidiaAuthHeaders({ + headers: buildAuthHeadersForClient(CLIENT_ID, { contentType: "application/x-www-form-urlencoded; charset=UTF-8", includeReferer: true, }), @@ -268,19 +327,114 @@ async function exchangeAuthorizationCode(code: string, verifier: string, port: n refreshToken: payload.refresh_token, idToken: payload.id_token, expiresAt: toExpiresAt(payload.expires_in), + authClientId: CLIENT_ID, }; } -async function refreshAuthTokens(refreshToken: string): Promise { +async function requestDeviceAuthorization( + provider: LoginProvider, +): Promise> { + const deviceId = generateDeviceId(); + const body = new URLSearchParams({ + client_id: STEAM_DECK_CLIENT_ID, + scope: SCOPES, + device_id: deviceId, + display_name: "OpenNOW", + idp_id: provider.idpId, + }); + + const response = await fetch(DEVICE_AUTHORIZE_ENDPOINT, { + method: "POST", + headers: { + ...buildAuthHeadersForClient(STEAM_DECK_CLIENT_ID, { + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + }), + "x-device-id": deviceId, + "nv-client-id": STEAM_DECK_CLIENT_ID, + "nv-client-streamer": "WEBRTC", + "nv-client-type": "BROWSER", + "nv-client-platform-name": "browser", + "nv-browser-type": "CHROME", + "nv-device-os": "STEAMOS", + "nv-device-type": "CONSOLE", + "nv-device-model": "STEAMDECK", + "nv-device-make": "VALVE", + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Device authorization failed (${response.status}): ${text.slice(0, 400)}`); + } + + const payload = (await response.json()) as DeviceAuthorizationResponse; + if ( + !payload.device_code || + !payload.user_code || + !payload.verification_uri || + !payload.verification_uri_complete + ) { + throw new Error("Device authorization response did not include QR login data"); + } + + return { + deviceCode: payload.device_code, + userCode: payload.user_code, + verificationUri: payload.verification_uri, + verificationUriComplete: payload.verification_uri_complete, + expiresAt: toExpiresAt(payload.expires_in, 600), + intervalSeconds: Math.max(1, payload.interval ?? 5), + }; +} + +async function exchangeDeviceCode(deviceCode: string): Promise { + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: STEAM_DECK_CLIENT_ID, + }); + + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: buildAuthHeadersForClient(STEAM_DECK_CLIENT_ID, { + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + }), + body, + }); + + const payload = (await response.json().catch(() => null)) as TokenResponse | DeviceTokenErrorResponse | null; + if (!response.ok) { + return payload && typeof payload === "object" + ? payload as DeviceTokenErrorResponse + : { error: "device_token_exchange_failed", error_description: `Device token exchange failed (${response.status})` }; + } + + const tokenPayload = payload as TokenResponse | null; + if (!tokenPayload?.access_token) { + return { error: "invalid_token_response", error_description: "Device token response did not include access_token" }; + } + + return { + accessToken: tokenPayload.access_token, + refreshToken: tokenPayload.refresh_token, + idToken: tokenPayload.id_token, + expiresAt: toExpiresAt(tokenPayload.expires_in), + authClientId: STEAM_DECK_CLIENT_ID, + clientToken: tokenPayload.client_token, + }; +} + +async function refreshAuthTokens(refreshToken: string, authClientId = CLIENT_ID): Promise { const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, - client_id: CLIENT_ID, + client_id: authClientId, }); const response = await fetch(TOKEN_ENDPOINT, { method: "POST", - headers: buildNvidiaAuthHeaders({ + headers: buildAuthHeadersForClient(authClientId, { contentType: "application/x-www-form-urlencoded; charset=UTF-8", }), body, @@ -297,16 +451,17 @@ async function refreshAuthTokens(refreshToken: string): Promise { refreshToken: payload.refresh_token ?? refreshToken, idToken: payload.id_token, expiresAt: toExpiresAt(payload.expires_in), + authClientId, }; } -async function requestClientToken(accessToken: string): Promise<{ +async function requestClientToken(accessToken: string, authClientId = CLIENT_ID): Promise<{ token: string; expiresAt: number; lifetimeMs: number; }> { const response = await fetch(CLIENT_TOKEN_ENDPOINT, { - headers: buildNvidiaAuthHeaders({ bearerToken: accessToken }), + headers: buildAuthHeadersForClient(authClientId, { bearerToken: accessToken }), }); if (!response.ok) { @@ -323,17 +478,17 @@ async function requestClientToken(accessToken: string): Promise<{ }; } -async function refreshWithClientToken(clientToken: string, userId: string): Promise { +async function refreshWithClientToken(clientToken: string, userId: string, authClientId = CLIENT_ID): Promise { const body = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:client_token", client_token: clientToken, - client_id: CLIENT_ID, + client_id: authClientId, sub: userId, }); const response = await fetch(TOKEN_ENDPOINT, { method: "POST", - headers: buildNvidiaAuthHeaders({ + headers: buildAuthHeadersForClient(authClientId, { contentType: "application/x-www-form-urlencoded; charset=UTF-8", }), body, @@ -353,6 +508,7 @@ function mergeTokenSnapshot(base: AuthTokens, refreshed: TokenResponse): AuthTok refreshToken: refreshed.refresh_token ?? base.refreshToken, idToken: refreshed.id_token, expiresAt: toExpiresAt(refreshed.expires_in), + authClientId: base.authClientId ?? CLIENT_ID, clientToken: refreshed.client_token ?? base.clientToken, clientTokenExpiresAt: base.clientTokenExpiresAt, clientTokenLifetimeMs: base.clientTokenLifetimeMs, @@ -391,7 +547,7 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { } const response = await fetch(USERINFO_ENDPOINT, { - headers: buildNvidiaAuthHeaders({ + headers: buildAuthHeadersForClient(tokens.authClientId, { bearerToken: tokens.accessToken, accept: "application/json", }), @@ -427,6 +583,8 @@ export class AuthService { private selectedProvider: LoginProvider = defaultProvider(); private cachedSubscription: SubscriptionInfo | null = null; private cachedVpcId: string | null = null; + private deviceLoginAttempts = new Map(); + private pendingDeviceLoginSessions = new Map(); constructor(private readonly statePath: string) {} @@ -509,7 +667,7 @@ export class AuthService { return tokens; } - const clientToken = await requestClientToken(tokens.accessToken); + const clientToken = await requestClientToken(tokens.accessToken, tokens.authClientId); return { ...tokens, clientToken: clientToken.token, @@ -688,6 +846,61 @@ export class AuthService { return this.getSession()?.provider ?? this.selectedProvider; } + private async selectLoginProvider(providerIdpId?: string): Promise { + const providers = await this.getProviders(); + const selected = + providers.find((provider) => provider.idpId === providerIdpId) ?? + this.selectedProvider ?? + providers[0] ?? + defaultProvider(); + this.selectedProvider = normalizeProvider(selected); + return this.selectedProvider; + } + + private async buildLoginSession(initialTokens: AuthTokens, provider: LoginProvider): Promise { + const user = await fetchUserInfo(initialTokens); + console.debug("auth: fetched user info during login", { userId: user.userId, email: user.email, avatarUrl: user.avatarUrl }); + let tokens = initialTokens; + try { + tokens = await this.ensureClientToken(initialTokens, user.userId); + } catch (error) { + console.warn("Unable to fetch client token after login. Falling back to OAuth token only:", error); + } + + return { + provider: normalizeProvider(provider), + tokens, + user, + }; + } + + private async saveLoginSession(session: AuthSession): Promise { + this.sessions.set(session.user.userId, session); + this.activeUserId = session.user.userId; + this.selectedProvider = session.provider; + this.clearSubscriptionCache(); + this.clearVpcCache(); + + // Fetch real membership tier from MES subscription API + // (JWT does not contain gfn_tier, so fetchUserInfo always falls back to "FREE") + await this.enrichUserTier(); + + await this.persist(); + return this.getSession() as AuthSession; + } + + private pruneExpiredDeviceLogins(now = Date.now(), skipAttemptId?: string): void { + for (const [attemptId, attempt] of this.deviceLoginAttempts) { + if (attemptId === skipAttemptId) { + continue; + } + if (attempt.expiresAt <= now) { + this.deviceLoginAttempts.delete(attemptId); + this.pendingDeviceLoginSessions.delete(attemptId); + } + } + } + async getRegions(explicitToken?: string): Promise { const provider = this.getSelectedProvider(); const base = provider.streamingServiceUrl.endsWith("/") @@ -734,50 +947,94 @@ export class AuthService { } async login(input: AuthLoginRequest): Promise { - const providers = await this.getProviders(); - const selected = - providers.find((provider) => provider.idpId === input.providerIdpId) ?? - this.selectedProvider ?? - providers[0] ?? - defaultProvider(); - - this.selectedProvider = normalizeProvider(selected); + const provider = await this.selectLoginProvider(input.providerIdpId); const { verifier, challenge } = generatePkce(); const port = await findAvailablePort(); - const authUrl = buildAuthUrl(this.selectedProvider, challenge, port); + const authUrl = buildAuthUrl(provider, challenge, port); const codePromise = waitForAuthorizationCode(port, 120000); await shell.openExternal(authUrl); const code = await codePromise; const initialTokens = await exchangeAuthorizationCode(code, verifier, port); - const user = await fetchUserInfo(initialTokens); - console.debug("auth: fetched user info during login", { userId: user.userId, email: user.email, avatarUrl: user.avatarUrl }); - let tokens = initialTokens; - try { - tokens = await this.ensureClientToken(initialTokens, user.userId); - } catch (error) { - console.warn("Unable to fetch client token after login. Falling back to OAuth token only:", error); + const session = await this.buildLoginSession(initialTokens, provider); + return this.saveLoginSession(session); + } + + async startDeviceLogin(input: AuthDeviceLoginStartRequest): Promise { + this.pruneExpiredDeviceLogins(); + const provider = await this.selectLoginProvider(input.providerIdpId); + const challenge = await requestDeviceAuthorization(provider); + const attemptId = randomBytes(16).toString("hex"); + this.deviceLoginAttempts.set(attemptId, { + provider, + deviceCode: challenge.deviceCode, + expiresAt: challenge.expiresAt, + }); + return { ...challenge, attemptId }; + } + + async pollDeviceLogin(input: AuthDeviceLoginPollRequest): Promise { + this.pruneExpiredDeviceLogins(); + if (!input.attemptId || !input.deviceCode) { + return { status: "error", error: "Missing device code" }; } - const nextSession: AuthSession = { - provider: this.selectedProvider, - tokens, - user, - }; - this.sessions.set(user.userId, nextSession); - this.activeUserId = user.userId; - this.selectedProvider = nextSession.provider; - this.clearSubscriptionCache(); - this.clearVpcCache(); + const attempt = this.deviceLoginAttempts.get(input.attemptId); + if (!attempt || attempt.deviceCode !== input.deviceCode) { + return { status: "expired", error: "QR login was cancelled or expired" }; + } + if (Date.now() >= attempt.expiresAt) { + this.cancelDeviceLogin(input); + return { status: "expired", error: "QR login expired" }; + } - // Fetch real membership tier from MES subscription API - // (JWT does not contain gfn_tier, so fetchUserInfo always falls back to "FREE") - await this.enrichUserTier(); + const result = await exchangeDeviceCode(input.deviceCode); + if (!this.deviceLoginAttempts.has(input.attemptId)) { + return { status: "expired", error: "QR login was cancelled" }; + } - await this.persist(); - return this.getSession() as AuthSession; + if ("accessToken" in result) { + const session = await this.buildLoginSession(result, attempt.provider); + if (!this.deviceLoginAttempts.has(input.attemptId)) { + return { status: "expired", error: "QR login was cancelled" }; + } + this.pendingDeviceLoginSessions.set(input.attemptId, session); + return { status: "authorized" }; + } + + switch (result.error) { + case "authorization_pending": + return { status: "pending", error: result.error_description }; + case "slow_down": + return { status: "slow_down", error: result.error_description }; + case "expired_token": + this.cancelDeviceLogin(input); + return { status: "expired", error: result.error_description ?? "QR login expired" }; + case "access_denied": + this.cancelDeviceLogin(input); + return { status: "access_denied", error: result.error_description ?? "QR login was denied" }; + default: + this.cancelDeviceLogin(input); + return { status: "error", error: result.error_description ?? result.error ?? "QR login failed" }; + } + } + + async completeDeviceLogin(input: AuthDeviceLoginAttemptRequest): Promise { + this.pruneExpiredDeviceLogins(Date.now(), input.attemptId); + const session = this.pendingDeviceLoginSessions.get(input.attemptId); + if (!session || !this.deviceLoginAttempts.has(input.attemptId)) { + throw new Error("QR login is no longer active"); + } + + this.cancelDeviceLogin(input); + return this.saveLoginSession(session); + } + + cancelDeviceLogin(input: AuthDeviceLoginAttemptRequest): void { + this.deviceLoginAttempts.delete(input.attemptId); + this.pendingDeviceLoginSessions.delete(input.attemptId); } async logout(): Promise { @@ -1053,7 +1310,7 @@ export class AuthService { if (tokens.clientToken) { try { - const refreshedFromClientToken = await refreshWithClientToken(tokens.clientToken, userId); + const refreshedFromClientToken = await refreshWithClientToken(tokens.clientToken, userId, tokens.authClientId); let refreshedTokens = mergeTokenSnapshot(tokens, refreshedFromClientToken); refreshedTokens = await this.ensureClientToken(refreshedTokens, userId); return applyRefreshedTokens(refreshedTokens, "client_token"); @@ -1066,7 +1323,7 @@ export class AuthService { if (tokens.refreshToken) { try { - const refreshedOAuth = await refreshAuthTokens(tokens.refreshToken); + const refreshedOAuth = await refreshAuthTokens(tokens.refreshToken, tokens.authClientId); let refreshedTokens: AuthTokens = { ...tokens, ...refreshedOAuth, @@ -1074,6 +1331,7 @@ export class AuthService { clientToken: tokens.clientToken, clientTokenExpiresAt: tokens.clientTokenExpiresAt, clientTokenLifetimeMs: tokens.clientTokenLifetimeMs, + authClientId: refreshedOAuth.authClientId ?? tokens.authClientId, }; refreshedTokens = await this.ensureClientToken(refreshedTokens, userId); return applyRefreshedTokens(refreshedTokens, "refresh_token"); diff --git a/opennow-stable/src/main/ipc/accountCatalogHandlers.ts b/opennow-stable/src/main/ipc/accountCatalogHandlers.ts index 3423921d..b17acad8 100644 --- a/opennow-stable/src/main/ipc/accountCatalogHandlers.ts +++ b/opennow-stable/src/main/ipc/accountCatalogHandlers.ts @@ -2,6 +2,9 @@ import type { IpcMain } from "electron"; import { IPC_CHANNELS } from "@shared/ipc"; import type { AuthLoginRequest, + AuthDeviceLoginAttemptRequest, + AuthDeviceLoginPollRequest, + AuthDeviceLoginStartRequest, AuthSessionRequest, CatalogBrowseRequest, GamesFetchRequest, @@ -66,6 +69,34 @@ export function registerAccountCatalogIpcHandlers( }, ); + ipcMain.handle( + IPC_CHANNELS.AUTH_DEVICE_LOGIN_START, + async (_event, payload: AuthDeviceLoginStartRequest) => { + return authService.startDeviceLogin(payload); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.AUTH_DEVICE_LOGIN_POLL, + async (_event, payload: AuthDeviceLoginPollRequest) => { + return authService.pollDeviceLogin(payload); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.AUTH_DEVICE_LOGIN_COMPLETE, + async (_event, payload: AuthDeviceLoginAttemptRequest) => { + return authService.completeDeviceLogin(payload); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.AUTH_DEVICE_LOGIN_CANCEL, + async (_event, payload: AuthDeviceLoginAttemptRequest) => { + authService.cancelDeviceLogin(payload); + }, + ); + ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async () => { await authService.logout(); }); diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 90580651..4e78756f 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -3,6 +3,9 @@ import electron from "electron"; import { IPC_CHANNELS } from "@shared/ipc"; import type { AuthLoginRequest, + AuthDeviceLoginAttemptRequest, + AuthDeviceLoginPollRequest, + AuthDeviceLoginStartRequest, AuthSession, AuthSessionRequest, GamesFetchRequest, @@ -67,6 +70,14 @@ const api: OpenNowApi = { getLoginProviders: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_PROVIDERS), getRegions: (input: RegionsFetchRequest = {}) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_REGIONS, input), login: (input: AuthLoginRequest) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGIN, input), + startDeviceLogin: (input: AuthDeviceLoginStartRequest) => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_DEVICE_LOGIN_START, input), + pollDeviceLogin: (input: AuthDeviceLoginPollRequest) => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_DEVICE_LOGIN_POLL, input), + completeDeviceLogin: (input: AuthDeviceLoginAttemptRequest) => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_DEVICE_LOGIN_COMPLETE, input), + cancelDeviceLogin: (input: AuthDeviceLoginAttemptRequest) => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_DEVICE_LOGIN_CANCEL, input), logout: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT), logoutAll: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT_ALL), getSavedAccounts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_SAVED_ACCOUNTS), diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 23957683..dae4bc7d 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import type { ActiveSessionInfo, + AuthDeviceLoginChallenge, AuthSession, CatalogBrowseResult, CatalogFilterGroup, @@ -205,7 +206,9 @@ export function App(): JSX.Element { const [providers, setProviders] = useState([]); const [providerIdpId, setProviderIdpId] = useState(""); const [isLoggingIn, setIsLoggingIn] = useState(false); + const [activeLoginMode, setActiveLoginMode] = useState<"oauth" | "qr" | null>(null); const [loginError, setLoginError] = useState(null); + const [qrLoginChallenge, setQrLoginChallenge] = useState(null); const [isInitializing, setIsInitializing] = useState(true); const [startupStatusMessage, setStartupStatusMessage] = useState(() => t("auth.status.restoringSavedSession")); const [startupRefreshNotice, setStartupRefreshNotice] = useState<{ @@ -1518,7 +1521,12 @@ export function App(): JSX.Element { // Login handler const handleLogin = useCallback(async () => { setIsLoggingIn(true); + setActiveLoginMode("oauth"); setLoginError(null); + if (qrLoginChallenge) { + void window.openNow.cancelDeviceLogin({ attemptId: qrLoginChallenge.attemptId }); + } + setQrLoginChallenge(null); try { const session = await window.openNow.login({ providerIdpId: providerIdpId || undefined }); setAuthSession(session); @@ -1529,8 +1537,104 @@ export function App(): JSX.Element { setLoginError(error instanceof Error ? error.message : t("errors.loginFailed")); } finally { setIsLoggingIn(false); + setActiveLoginMode(null); + } + }, [loadSessionRuntimeData, providerIdpId, qrLoginChallenge, refreshSavedAccounts, t]); + + const qrLoginAttemptRef = useRef(0); + const completingQrLoginRef = useRef(false); + + const handleCancelQrLogin = useCallback(() => { + if (completingQrLoginRef.current) { + return; + } + qrLoginAttemptRef.current += 1; + if (qrLoginChallenge) { + void window.openNow.cancelDeviceLogin({ attemptId: qrLoginChallenge.attemptId }); + } + setQrLoginChallenge(null); + setIsLoggingIn(false); + setActiveLoginMode(null); + setLoginError(null); + }, [qrLoginChallenge]); + + const handleQrLogin = useCallback(async () => { + const attemptId = qrLoginAttemptRef.current + 1; + qrLoginAttemptRef.current = attemptId; + completingQrLoginRef.current = false; + setIsLoggingIn(true); + setActiveLoginMode("qr"); + setLoginError(null); + if (qrLoginChallenge) { + void window.openNow.cancelDeviceLogin({ attemptId: qrLoginChallenge.attemptId }); + } + setQrLoginChallenge(null); + + try { + const challenge = await window.openNow.startDeviceLogin({ providerIdpId: providerIdpId || undefined }); + if (qrLoginAttemptRef.current !== attemptId) { + void window.openNow.cancelDeviceLogin({ attemptId: challenge.attemptId }); + return; + } + + setQrLoginChallenge(challenge); + let intervalSeconds = Math.max(1, challenge.intervalSeconds); + + while (Date.now() < challenge.expiresAt) { + await sleep(intervalSeconds * 1000); + if (qrLoginAttemptRef.current !== attemptId) { + return; + } + + const result = await window.openNow.pollDeviceLogin({ + attemptId: challenge.attemptId, + deviceCode: challenge.deviceCode, + }); + if (qrLoginAttemptRef.current !== attemptId) { + return; + } + + if (result.status === "authorized") { + completingQrLoginRef.current = true; + setQrLoginChallenge(null); + setActiveLoginMode(null); + const session = await window.openNow.completeDeviceLogin({ attemptId: challenge.attemptId }); + if (qrLoginAttemptRef.current !== attemptId) { + return; + } + setAuthSession(session); + setProviderIdpId(session.provider.idpId); + await refreshSavedAccounts(); + await loadSessionRuntimeData(session); + return; + } + + if (result.status === "pending") { + continue; + } + + if (result.status === "slow_down") { + intervalSeconds += 5; + continue; + } + + throw new Error(result.error ?? t("errors.loginFailed")); + } + + throw new Error(t("auth.qr.expired")); + } catch (error) { + if (qrLoginAttemptRef.current === attemptId) { + setLoginError(error instanceof Error ? error.message : t("errors.loginFailed")); + } + } finally { + if (qrLoginAttemptRef.current === attemptId) { + setQrLoginChallenge(null); + setIsLoggingIn(false); + setActiveLoginMode(null); + completingQrLoginRef.current = false; + } } - }, [loadSessionRuntimeData, providerIdpId, refreshSavedAccounts, t]); + }, [loadSessionRuntimeData, providerIdpId, qrLoginChallenge, refreshSavedAccounts, t]); const handleSwitchAccount = useCallback(async (userId: string) => { try { @@ -3427,10 +3531,14 @@ export function App(): JSX.Element { selectedProviderId={providerIdpId} onProviderChange={setProviderIdpId} onLogin={handleLogin} + onQrLogin={handleQrLogin} + onCancelQrLogin={handleCancelQrLogin} isLoading={isLoggingIn} error={loginError} isInitializing={isInitializing} statusMessage={startupStatusMessage} + qrLoginChallenge={qrLoginChallenge} + isQrLoginPending={activeLoginMode === "qr" && !qrLoginChallenge} /> ); diff --git a/opennow-stable/src/renderer/src/components/LoginScreen.tsx b/opennow-stable/src/renderer/src/components/LoginScreen.tsx index 36604514..67e8a01b 100644 --- a/opennow-stable/src/renderer/src/components/LoginScreen.tsx +++ b/opennow-stable/src/renderer/src/components/LoginScreen.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect } from "react"; import type { JSX } from "react"; -import { LogIn, ChevronDown } from "lucide-react"; -import type { LoginProvider } from "@shared/gfn"; +import QRCode from "qrcode"; +import { LogIn, ChevronDown, QrCode } from "lucide-react"; +import type { AuthDeviceLoginChallenge, LoginProvider } from "@shared/gfn"; import { useTranslation } from "../i18n"; import { OpenNowLogoMark } from "./OpenNowLogoMark"; @@ -10,10 +11,14 @@ export interface LoginScreenProps { selectedProviderId: string; onProviderChange: (id: string) => void; onLogin: () => void; + onQrLogin: () => void; + onCancelQrLogin: () => void; isLoading: boolean; error: string | null; isInitializing?: boolean; statusMessage?: string; + qrLoginChallenge?: AuthDeviceLoginChallenge | null; + isQrLoginPending?: boolean; } export function LoginScreen({ @@ -21,18 +26,24 @@ export function LoginScreen({ selectedProviderId, onProviderChange, onLogin, + onQrLogin, + onCancelQrLogin, isLoading, error, isInitializing = false, statusMessage, + qrLoginChallenge, + isQrLoginPending = false, }: LoginScreenProps): JSX.Element { const { t } = useTranslation(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(null); const dropdownRef = useRef(null); const selectedProvider = providers.find((p) => p.idpId === selectedProviderId); const title = isInitializing ? t("auth.title.restoringSession") : t("auth.title.signIn"); const subtitle = isInitializing ? t("auth.subtitle.checkingSavedAccounts") : t("app.description"); + const isQrLoginActive = Boolean(qrLoginChallenge) || isQrLoginPending; useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -44,6 +55,39 @@ export function LoginScreen({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + let cancelled = false; + setQrCodeDataUrl(null); + + if (!qrLoginChallenge) { + return () => { + cancelled = true; + }; + } + + QRCode.toDataURL(qrLoginChallenge.verificationUriComplete, { + errorCorrectionLevel: "M", + margin: 1, + scale: 8, + color: { + dark: "#07111f", + light: "#ffffff", + }, + }).then((dataUrl) => { + if (!cancelled) { + setQrCodeDataUrl(dataUrl); + } + }).catch(() => { + if (!cancelled) { + setQrCodeDataUrl(null); + } + }); + + return () => { + cancelled = true; + }; + }, [qrLoginChallenge]); + const handleProviderSelect = (providerId: string) => { onProviderChange(providerId); setIsDropdownOpen(false); @@ -93,7 +137,7 @@ export function LoginScreen({ + {isQrLoginActive && ( +
+
+ {qrLoginChallenge && qrCodeDataUrl ? ( + {t("auth.qr.alt")} + ) : ( + + )} +
+
+
+ {qrLoginChallenge ? t("auth.qr.title") : t("auth.qr.preparing")} +
+

+ {qrLoginChallenge ? t("auth.qr.description") : t("auth.qr.preparingDescription")} +

+ {qrLoginChallenge && {qrLoginChallenge.userCode}} +
+ +
+ )} + +
+ + +

{t("app.tagline")}

diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 0244c4d6..d9cb6307 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1796,6 +1796,74 @@ body, color: var(--accent); } +.login-qr-panel { + display: grid; + grid-template-columns: 118px 1fr; + gap: 14px; + align-items: center; + margin: 0 0 18px; + padding: 14px; + background: rgba(21, 21, 24, 0.92); + border: 1px solid var(--panel-border-solid); + border-radius: var(--r-md); +} + +.login-qr-code { + width: 118px; + height: 118px; + display: grid; + place-items: center; + padding: 8px; + background: #fff; + border-radius: 14px; +} + +.login-qr-code img { + width: 100%; + height: 100%; + display: block; +} + +.login-qr-copy { + min-width: 0; +} + +.login-qr-title { + margin-bottom: 5px; + color: var(--ink); + font-size: 0.9rem; + font-weight: 700; +} + +.login-qr-copy p { + margin: 0 0 9px; + color: var(--ink-muted); + font-size: 0.78rem; + line-height: 1.4; +} + +.login-qr-copy code { + display: inline-block; + padding: 5px 8px; + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.28); + border-radius: 8px; + color: var(--accent); + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.login-qr-panel .login-secondary-button { + grid-column: 1 / -1; +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + /* Login button */ .login-button { width: 100%; @@ -1840,6 +1908,40 @@ body, cursor: wait; } +.login-secondary-button { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--panel-border-solid); + border-radius: var(--r-sm); + color: var(--ink); + font-size: 0.9rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; + transition: border-color var(--t-normal), background var(--t-normal), opacity var(--t-normal); +} + +.login-secondary-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(var(--accent-rgb), 0.35); +} + +.login-secondary-button:focus { + outline: none; + box-shadow: 0 0 0 2px var(--accent-surface); +} + +.login-secondary-button:disabled, +.login-secondary-button.disabled { + opacity: 0.5; + cursor: not-allowed; +} + .login-spinner { width: 16px; height: 16px; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 40536331..a92e842a 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -343,6 +343,7 @@ export interface AuthTokens { refreshToken?: string; idToken?: string; expiresAt: number; + authClientId?: string; clientToken?: string; clientTokenExpiresAt?: number; clientTokenLifetimeMs?: number; @@ -433,6 +434,44 @@ export interface AuthLoginRequest { providerIdpId?: string; } +export interface AuthDeviceLoginStartRequest { + providerIdpId?: string; +} + +export interface AuthDeviceLoginChallenge { + attemptId: string; + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresAt: number; + intervalSeconds: number; +} + +export interface AuthDeviceLoginPollRequest { + attemptId: string; + deviceCode: string; +} + +export interface AuthDeviceLoginAttemptRequest { + attemptId: string; +} + +export type AuthDeviceLoginPollStatus = + | "pending" + | "slow_down" + | "expired" + | "access_denied" + | "authorized" + | "error"; + +export interface AuthDeviceLoginPollResult { + status: AuthDeviceLoginPollStatus; + session?: AuthSession; + error?: string; + intervalSeconds?: number; +} + export interface AuthSessionRequest { forceRefresh?: boolean; } @@ -1042,6 +1081,10 @@ export interface OpenNowApi { getLoginProviders(): Promise; getRegions(input?: RegionsFetchRequest): Promise; login(input: AuthLoginRequest): Promise; + startDeviceLogin(input: AuthDeviceLoginStartRequest): Promise; + pollDeviceLogin(input: AuthDeviceLoginPollRequest): Promise; + completeDeviceLogin(input: AuthDeviceLoginAttemptRequest): Promise; + cancelDeviceLogin(input: AuthDeviceLoginAttemptRequest): Promise; logout(): Promise; logoutAll(): Promise; getSavedAccounts(): Promise; diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index a5abbe7e..2d6b51cf 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -3,6 +3,10 @@ export const IPC_CHANNELS = { AUTH_GET_PROVIDERS: "auth:get-providers", AUTH_GET_REGIONS: "auth:get-regions", AUTH_LOGIN: "auth:login", + AUTH_DEVICE_LOGIN_START: "auth:device-login-start", + AUTH_DEVICE_LOGIN_POLL: "auth:device-login-poll", + AUTH_DEVICE_LOGIN_COMPLETE: "auth:device-login-complete", + AUTH_DEVICE_LOGIN_CANCEL: "auth:device-login-cancel", AUTH_LOGOUT: "auth:logout", AUTH_LOGOUT_ALL: "auth:logout-all", AUTH_GET_SAVED_ACCOUNTS: "auth:get-saved-accounts",