diff --git a/_scripts/webpack.main.config.js b/_scripts/webpack.main.config.js index 24560c32ecc85..70bbe93d4094a 100644 --- a/_scripts/webpack.main.config.js +++ b/_scripts/webpack.main.config.js @@ -55,6 +55,11 @@ const config = { new webpack.DefinePlugin({ 'process.platform': `'${process.platform}'`, 'process.env.IS_ELECTRON_MAIN': true + }), + // `ws` conditionally requires these optional native performance addons and handles + // them missing at runtime, but webpack can't resolve them at bundle time. + new webpack.IgnorePlugin({ + resourceRegExp: /^(bufferutil|utf-8-validate)$/ }) ], output: { diff --git a/package.json b/package.json index 998e381eb09b3..cb888659da5dc 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "googlevideo": "^4.0.4", "marked": "^18.0.6", "process": "^0.11.10", + "qrcode": "^1.5.4", "shaka-player": "^5.1.12", "swiper": "^14.0.5", "terser": "^5.49.0", @@ -71,6 +72,7 @@ "vue-observe-visibility": "^2.0.0-alpha.1", "vue-router": "^5.1.0", "vuex": "^4.1.0", + "ws": "^8.21.0", "youtubei.js": "^17.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2779c5e62a72b..b9e901cd302fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: process: specifier: ^0.11.10 version: 0.11.10 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 shaka-player: specifier: ^5.1.12 version: 5.1.12 @@ -74,6 +77,9 @@ importers: vuex: specifier: ^4.1.0 version: 4.1.0(vue@3.5.39(typescript@6.0.3)) + ws: + specifier: ^8.21.0 + version: 8.21.0 youtubei.js: specifier: ^17.2.0 version: 17.2.0 @@ -1214,36 +1220,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1520,51 +1532,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1967,6 +1989,10 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -2014,6 +2040,9 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2262,6 +2291,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2324,6 +2357,9 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -2834,10 +2870,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -3943,6 +3975,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -4234,6 +4270,11 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -4311,6 +4352,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -4474,6 +4518,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5072,6 +5119,9 @@ packages: webpack-cli: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -5102,6 +5152,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5141,6 +5195,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5161,10 +5218,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -5895,7 +5960,7 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.5 + fs-extra: 11.3.6 minimatch: 9.0.9 plist: 3.1.0 transitivePeerDependencies: @@ -7224,6 +7289,8 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelcase@5.3.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.28.5 @@ -7265,6 +7332,12 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7503,6 +7576,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -7551,6 +7626,8 @@ snapshots: diff-sequences@29.6.3: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -8187,18 +8264,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -8295,7 +8365,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.5 serialize-error: 7.0.1 optional: true @@ -8991,14 +9061,14 @@ snapshots: node-abi@4.31.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-addon-api@7.1.1: optional: true node-api-version@0.2.1: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-gyp@12.4.0: dependencies: @@ -9007,7 +9077,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.16 tinyglobby: 0.2.17 undici: 6.27.0 @@ -9197,6 +9267,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.0.0: {} postcss-calc@10.1.1(postcss@8.5.16): @@ -9463,6 +9535,12 @@ snapshots: dependencies: hookified: 2.2.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -9544,6 +9622,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -9705,6 +9785,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -10191,7 +10273,7 @@ snapshots: dependencies: bluebird: 3.7.2 duplexer2: 0.1.4 - fs-extra: 11.3.5 + fs-extra: 11.3.6 graceful-fs: 4.2.11 node-int64: 0.4.0 @@ -10432,6 +10514,8 @@ snapshots: - postcss - uglify-js + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -10462,6 +10546,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -10491,6 +10581,8 @@ snapshots: xmlbuilder@15.1.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@4.0.0: {} @@ -10504,8 +10596,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/src/constants.js b/src/constants.js index 030b7a2da926f..a3eeb6d7c44f9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -47,7 +47,14 @@ const IpcChannels = { WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder', OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', - OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result' + OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result', + + REMOTE_CONTROL_START: 'remote-control-start', + REMOTE_CONTROL_STOP: 'remote-control-stop', + REMOTE_CONTROL_COMMAND: 'remote-control-command', + REMOTE_CONTROL_SEARCH_REQUEST: 'remote-control-search-request', + REMOTE_CONTROL_SEARCH_RESULT: 'remote-control-search-result', + REMOTE_CONTROL_STATE: 'remote-control-state' } const DBActions = { diff --git a/src/main/index.js b/src/main/index.js index a02effdb9a2cb..a2ac801a6de2b 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -28,6 +28,7 @@ import packageDetails from '../../package.json' import { handleOpenInExternalPlayer } from './externalPlayer' import { generatePoToken } from './poTokenGenerator' import { isFreeTubeUrl } from './utils' +import * as remoteControlServer from './remoteControlServer' const brotliDecompressAsync = promisify(brotliDecompress) @@ -1299,6 +1300,44 @@ function runApp() { session.defaultSession.closeAllConnections() }) + // #region remote control + + ipcMain.handle(IpcChannels.REMOTE_CONTROL_START, (event) => { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return + } + + return remoteControlServer.start(event.sender) + }) + + ipcMain.on(IpcChannels.REMOTE_CONTROL_STOP, (event) => { + if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) { + return + } + + remoteControlServer.stop() + }) + + // Only the window that started the server may report state or resolve searches for it, + // so other (possibly private) windows' playback never gets broadcast to connected remotes. + ipcMain.on(IpcChannels.REMOTE_CONTROL_STATE, (event, state) => { + if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) { + return + } + + remoteControlServer.broadcastState(state) + }) + + ipcMain.on(IpcChannels.REMOTE_CONTROL_SEARCH_RESULT, (event, payload) => { + if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) { + return + } + + remoteControlServer.resolveSearch(payload) + }) + + // #endregion remote control + // #region navigation history const NAV_HISTORY_DISPLAY_LIMIT = 15 @@ -2141,6 +2180,8 @@ function runApp() { return } + remoteControlServer.stop() + await Promise.allSettled([ baseHandlers.compactAllDatastores(), session.defaultSession.clearCache(), diff --git a/src/main/remoteControlPage.js b/src/main/remoteControlPage.js new file mode 100644 index 0000000000000..abb1d87433f09 --- /dev/null +++ b/src/main/remoteControlPage.js @@ -0,0 +1,537 @@ +/** + * Self-contained mobile remote-control page for FreeTube. + * No external resources are loaded (no CDN scripts/styles/fonts), so it + * works fully offline over the LAN and satisfies a strict same-origin CSP. + */ +export function getRemoteControlPageHtml(nonce) { + return ` + + + + +FreeTube Remote + + + +
+

FreeTube Remote

+ Connecting… +
+ +
Missing or invalid access link. Open FreeTube > Settings > Remote Control and scan the QR code again.
+ +
+
+
+ +
Nothing playing
+
+
+ + + + +
+
+ + +
+
+ 0:00 + + 0:00 +
+
+ +
+
+ + +
+
+
+
+
+ + + +` +} diff --git a/src/main/remoteControlServer.js b/src/main/remoteControlServer.js new file mode 100644 index 0000000000000..eecf3bb771db4 --- /dev/null +++ b/src/main/remoteControlServer.js @@ -0,0 +1,450 @@ +import { createServer } from 'http' +import { randomBytes, randomUUID, timingSafeEqual } from 'crypto' +import { networkInterfaces } from 'os' +import { WebSocketServer } from 'ws' + +import { IpcChannels } from '../constants' +import { getRemoteControlPageHtml } from './remoteControlPage' + +const DEFAULT_PORT = 9482 +const REQUEST_TIMEOUT_MS = 15_000 +const HEARTBEAT_INTERVAL_MS = 30_000 +const MAX_CONCURRENT_SEARCHES_PER_CONNECTION = 3 +const MAX_TOTAL_CONNECTIONS = 8 +const MAX_TOTAL_PENDING_SEARCHES = 16 +const MAX_SEARCH_RESULTS = 20 +const VIDEO_ID_REGEX = /^[\w-]{6,32}$/ + +let httpServer = null +let wss = null +let heartbeatInterval = null +let currentToken = null +let targetWebContents = null +let currentPort = null +/** promise for an in-flight start() call, used to make concurrent start() calls idempotent instead of racing */ +let startPromise = null +/** @type {Map} */ +const pendingSearchRequests = new Map() + +/** + * Constant-time-ish token comparison to avoid leaking token contents via response timing. + * @param {string} a + * @param {string} b + */ +function tokensMatch(a, b) { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + + if (bufA.length !== bufB.length) { + // still perform a compare of equal length to avoid an early-return timing signal + timingSafeEqual(bufA, bufA) + return false + } + + return timingSafeEqual(bufA, bufB) +} + +/** + * @param {string} requestUrl + * @param {string} host + */ +function parseUrl(requestUrl, host) { + try { + return new URL(requestUrl, `http://${host}`) + } catch { + return null + } +} + +/** + * @returns {string[]} the machine's non-internal IPv4 addresses, used to build a LAN-reachable URL + */ +function getLocalNetworkAddresses() { + const interfaces = networkInterfaces() + const addresses = [] + + for (const ifaceList of Object.values(interfaces)) { + for (const iface of ifaceList ?? []) { + if (iface.family === 'IPv4' && !iface.internal) { + addresses.push(iface.address) + } + } + } + + return addresses +} + +/** + * @returns {boolean} whether the remote control server is currently listening + */ +export function isRunning() { + return httpServer !== null +} + +/** + * Only the window that started the server is allowed to report state or resolve + * searches for it, so another window's playback is never exposed to connected remotes. + * @param {import('electron').WebContents} webContents + * @returns {boolean} + */ +export function isOwner(webContents) { + return targetWebContents !== null && targetWebContents === webContents +} + +/** + * @param {import('electron').WebContents} webContents + * @param {number} [preferredPort] + * @returns {Promise<{ url: string, addresses: string[], port: number }>} + */ +export function start(webContents, preferredPort = DEFAULT_PORT) { + // Makes concurrent start() calls idempotent instead of racing two `listen()` attempts + // against each other and corrupting the module-level server/heartbeat state. + if (startPromise !== null) { + return startPromise + } + + if (httpServer !== null) { + stop() + } + + startPromise = new Promise((resolve, reject) => { + targetWebContents = webContents + currentToken = randomBytes(24).toString('hex') + + const server = createServer((req, res) => handleHttpRequest(req, res)) + httpServer = server + + let started = false + + server.on('error', (err) => { + if (!started) { + httpServer = null + currentToken = null + targetWebContents = null + reject(err) + } else { + console.error('[remote-control] server error', err) + stop() + } + }) + + server.listen(preferredPort, '0.0.0.0', () => { + started = true + currentPort = preferredPort + + wss = new WebSocketServer({ + server, + path: '/ws', + maxPayload: 8 * 1024, + verifyClient: ({ req }) => verifyWsClient(req) + }) + + wss.on('connection', (ws) => setupConnection(ws)) + + heartbeatInterval = setInterval(() => { + wss?.clients.forEach((ws) => { + if (ws.isAlive === false) { + ws.terminate() + return + } + + ws.isAlive = false + ws.ping() + }) + }, HEARTBEAT_INTERVAL_MS) + + webContents.once('destroyed', () => stop()) + + resolve({ + url: buildConnectionUrl(), + addresses: getLocalNetworkAddresses(), + port: currentPort + }) + }) + }).finally(() => { + startPromise = null + }) + + return startPromise +} + +/** + * @returns {string} the URL a phone should open, including the auth token + */ +function buildConnectionUrl() { + const addresses = getLocalNetworkAddresses() + const primaryAddress = addresses[0] ?? '127.0.0.1' + return `http://${primaryAddress}:${currentPort}/?t=${currentToken}` +} + +export function stop() { + if (heartbeatInterval) { + clearInterval(heartbeatInterval) + heartbeatInterval = null + } + + for (const { timeout } of pendingSearchRequests.values()) { + clearTimeout(timeout) + } + pendingSearchRequests.clear() + + if (wss) { + wss.clients.forEach((ws) => ws.terminate()) + wss.close() + wss = null + } + + if (httpServer) { + httpServer.close() + httpServer = null + } + + currentToken = null + targetWebContents = null + currentPort = null +} + +/** + * @param {import('http').IncomingMessage} req + */ +function verifyWsClient(req) { + if (!currentToken) { + return false + } + + if ((wss?.clients.size ?? 0) >= MAX_TOTAL_CONNECTIONS) { + return false + } + + const url = parseUrl(req.url, req.headers.host ?? 'localhost') + const token = url?.searchParams.get('t') + + if (!token || !tokensMatch(token, currentToken)) { + return false + } + + // Browser clients send `Origin`; reject cross-origin pages trying to reuse a leaked token. + // Non-browser clients (that don't send Origin) are still allowed through, gated by the token itself. + const origin = req.headers.origin + if (origin && origin !== `http://${req.headers.host}`) { + return false + } + + return true +} + +/** + * @param {import('http').IncomingMessage} req + * @param {import('http').ServerResponse} res + */ +function handleHttpRequest(req, res) { + if (req.method !== 'GET') { + res.writeHead(405).end() + return + } + + const url = parseUrl(req.url, req.headers.host ?? 'localhost') + + if (url === null || url.pathname !== '/') { + res.writeHead(404).end() + return + } + + const token = url.searchParams.get('t') + if (!currentToken || !token || !tokensMatch(token, currentToken)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Forbidden') + return + } + + const nonce = randomBytes(16).toString('base64') + + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src https: data:; connect-src 'self'; frame-ancestors 'none'`, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer' + }).end(getRemoteControlPageHtml(nonce)) +} + +/** + * Wires up heartbeat tracking and message handling for a newly connected client. + * @param {import('ws').WebSocket} ws + */ +function setupConnection(ws) { + ws.isAlive = true + ws.pendingSearchCount = 0 + + ws.on('pong', () => { + ws.isAlive = true + }) + + ws.on('message', (data) => handleClientMessage(ws, data)) + + ws.on('close', () => { + for (const [requestId, entry] of pendingSearchRequests) { + if (entry.ws === ws) { + clearTimeout(entry.timeout) + pendingSearchRequests.delete(requestId) + } + } + }) + + send(ws, { type: 'connected' }) +} + +/** + * @param {import('ws').WebSocket} ws + * @param {object} payload + */ +function send(ws, payload) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(payload)) + } +} + +/** + * Parses and routes an incoming WS message to the matching command handler. + * @param {import('ws').WebSocket} ws + * @param {import('ws').RawData} data + */ +function handleClientMessage(ws, data) { + if (!targetWebContents || targetWebContents.isDestroyed()) { + return + } + + let message + try { + message = JSON.parse(data.toString()) + } catch { + return + } + + if (typeof message?.type !== 'string') { + return + } + + switch (message.type) { + case 'play': + case 'pause': + case 'fullscreen': + targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: message.type }) + break + + case 'volume': { + const value = Number(message.value) + if (Number.isFinite(value) && value >= 0 && value <= 100) { + targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'volume', value }) + } + break + } + + case 'seek': { + const value = Number(message.value) + if (Number.isFinite(value) && value >= 0) { + targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'seek', value }) + } + break + } + + case 'open': { + const videoId = message.videoId + if (typeof videoId === 'string' && VIDEO_ID_REGEX.test(videoId)) { + targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'open', value: videoId }) + } + break + } + + case 'search': + handleSearchMessage(ws, message) + break + } +} + +/** + * @param {import('ws').WebSocket} ws + * @param {{ query?: string, requestId?: string }} message + */ +function handleSearchMessage(ws, message) { + const query = typeof message.query === 'string' ? message.query.trim().slice(0, 100) : '' + const clientRequestId = typeof message.requestId === 'string' ? message.requestId.slice(0, 100) : null + + if (!query || !clientRequestId) { + return + } + + if (ws.pendingSearchCount >= MAX_CONCURRENT_SEARCHES_PER_CONNECTION) { + send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Too many pending searches' }) + return + } + + if (pendingSearchRequests.size >= MAX_TOTAL_PENDING_SEARCHES) { + send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Server is busy, try again shortly' }) + return + } + + ws.pendingSearchCount++ + + const serverRequestId = randomUUID() + + const timeout = setTimeout(() => { + pendingSearchRequests.delete(serverRequestId) + ws.pendingSearchCount = Math.max(0, ws.pendingSearchCount - 1) + send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Search timed out' }) + }, REQUEST_TIMEOUT_MS) + + pendingSearchRequests.set(serverRequestId, { ws, clientRequestId, timeout }) + + targetWebContents.send(IpcChannels.REMOTE_CONTROL_SEARCH_REQUEST, { requestId: serverRequestId, query }) +} + +/** + * Called from the main process IPC handler once the renderer replies to a search request. + * @param {{ requestId: string, results?: any[], error?: string }} payload + */ +export function resolveSearch({ requestId, results, error }) { + const entry = pendingSearchRequests.get(requestId) + if (!entry) { + return + } + + clearTimeout(entry.timeout) + pendingSearchRequests.delete(requestId) + entry.ws.pendingSearchCount = Math.max(0, entry.ws.pendingSearchCount - 1) + + if (error) { + send(entry.ws, { type: 'searchError', requestId: entry.clientRequestId, message: String(error).slice(0, 200) }) + } else { + send(entry.ws, { type: 'searchResults', requestId: entry.clientRequestId, results: sanitizeSearchResults(results) }) + } +} + +/** + * Defense-in-depth: even though only trusted renderer code calls this today, don't trust its + * shape blindly before forwarding it to a network client — cap the size and strip unknown fields. + * @param {any[]} results + */ +function sanitizeSearchResults(results) { + if (!Array.isArray(results)) { + return [] + } + + return results.slice(0, MAX_SEARCH_RESULTS).map(item => ({ + videoId: typeof item?.videoId === 'string' ? item.videoId.slice(0, 32) : '', + title: typeof item?.title === 'string' ? item.title.slice(0, 300) : '', + author: typeof item?.author === 'string' ? item.author.slice(0, 200) : '', + thumbnail: typeof item?.thumbnail === 'string' && item.thumbnail.startsWith('https://') ? item.thumbnail.slice(0, 500) : '' + })).filter(item => VIDEO_ID_REGEX.test(item.videoId)) +} + +/** + * Called from the main process IPC handler whenever the renderer reports new playback state. + * @param {object} state + */ +export function broadcastState(state) { + if (!wss) { + return + } + + const payload = { type: 'state', ...state } + wss.clients.forEach((ws) => send(ws, payload)) +} diff --git a/src/preload/interface.js b/src/preload/interface.js index 15107ec862afe..76df024a3ddad 100644 --- a/src/preload/interface.js +++ b/src/preload/interface.js @@ -321,5 +321,50 @@ export default { ipcRenderer.on(IpcChannels.SYNC_SUBSCRIPTION_CACHE, (_, { event, data }) => { handler(event, data) }) + }, + + /** + * @returns {Promise<{ url: string, addresses: string[], port: number }>} + */ + startRemoteControlServer: () => { + return ipcRenderer.invoke(IpcChannels.REMOTE_CONTROL_START) + }, + + stopRemoteControlServer: () => { + ipcRenderer.send(IpcChannels.REMOTE_CONTROL_STOP) + }, + + /** + * @param {(command: { action: string, value?: string|number }) => void} handler + */ + handleRemoteControlCommand: (handler) => { + ipcRenderer.on(IpcChannels.REMOTE_CONTROL_COMMAND, (_, command) => { + handler(command) + }) + }, + + /** + * @param {(requestId: string, query: string) => void} handler + */ + handleRemoteControlSearchRequest: (handler) => { + ipcRenderer.on(IpcChannels.REMOTE_CONTROL_SEARCH_REQUEST, (_, { requestId, query }) => { + handler(requestId, query) + }) + }, + + /** + * @param {string} requestId + * @param {any[] | undefined} results + * @param {string | undefined} error + */ + sendRemoteControlSearchResult: (requestId, results, error) => { + ipcRenderer.send(IpcChannels.REMOTE_CONTROL_SEARCH_RESULT, { requestId, results, error }) + }, + + /** + * @param {object} state + */ + sendRemoteControlState: (state) => { + ipcRenderer.send(IpcChannels.REMOTE_CONTROL_STATE, state) } } diff --git a/src/renderer/App.vue b/src/renderer/App.vue index e02bdffd7a389..ba1859769bd33 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -130,6 +130,7 @@ import packageDetails from '../../package.json' import { openExternalLink, openInternalPath, showToast } from './helpers/utils' import { translateWindowTitle } from './helpers/strings' import { loadLocale } from './i18n/index' +import { initRemoteControl } from './helpers/remote-control' const route = useRoute() const router = useRouter() @@ -194,6 +195,7 @@ onMounted(async () => { document.addEventListener('auxclick', handleAuxClick) enableOpenUrl() store.dispatch('getExternalPlayerCmdArgumentsData') + initRemoteControl(router) } dataReady.value = true diff --git a/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css new file mode 100644 index 0000000000000..e8bc8af6ff133 --- /dev/null +++ b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css @@ -0,0 +1,33 @@ +.remoteControlWarning { + padding-block: 12px; + padding-inline: 4%; + background-color: var(--accent-color); + color: var(--text-with-accent-color); + font-weight: bold; +} + +.warning-icon { + font-size: large; +} + +.qrWrapper { + display: flex; + justify-content: center; + padding-block: 12px; +} + +.qrImage { + inline-size: 220px; + block-size: 220px; + background-color: #fff; + border-radius: 4px; +} + +.connectionUrl { + overflow-wrap: break-word; + font-family: monospace; +} + +.errorMessage { + color: var(--error-color, #d92929); +} diff --git a/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue new file mode 100644 index 0000000000000..702592877f030 --- /dev/null +++ b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue @@ -0,0 +1,116 @@ + + + + +