From 80689ea23bef9957aca1a0568fbfcb8c4eebed0c Mon Sep 17 00:00:00 2001 From: Renan Giuliasse <48266134+renangiuliasse@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:44:56 -0300 Subject: [PATCH 01/17] feat. wayland keybind support (#1127) Co-authored-by: KarboX <48266134+KarboXXX@users.noreply.github.com> --- .vscode/launch.json | 18 +++ electron-builder.ts | 37 ++++++ package.json | 1 + pnpm-lock.yaml | 188 +++++++++++++++++++++++++++---- src/common/commandDefinitions.ts | 28 +++++ src/common/handleCommands.ts | 86 ++++++++++++++ src/dbus.ts | 158 ++++++++++++++++++++++++++ src/discord/window.ts | 19 ++-- src/main.ts | 13 ++- 9 files changed, 515 insertions(+), 33 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/common/commandDefinitions.ts create mode 100644 src/common/handleCommands.ts create mode 100644 src/dbus.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7846fe0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": ["/**"], + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "args": ["."], + "outputCapture": "std", + "outFiles": ["${workspaceFolder}/**/*.js"] + } + ] +} diff --git a/electron-builder.ts b/electron-builder.ts index 06b45c9..f961b5c 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -1,6 +1,24 @@ import type { Configuration } from "electron-builder"; import { applyAppImageSandboxFix } from "./scripts/build/sandboxFix.mjs"; +import { ACTION_FRIENDLY_NAMES, EXCLUDED_FROM_SHORTCUTS, ValidActions } from "./src/common/commandDefinitions"; + +const desktopActions = (exec: "AppRun" | "/opt/Legcord/legcord") => + Object.fromEntries( + (Object.values(ValidActions) as ValidActions[]) + .filter((action) => !EXCLUDED_FROM_SHORTCUTS.includes(action)) + .map((action) => [ + action, + { + Name: ACTION_FRIENDLY_NAMES[action], + Exec: `${exec} --${action} %U`, + }, + ]), + ); + +const availableActions = (Object.values(ValidActions) as ValidActions[]) + .filter((action) => !EXCLUDED_FROM_SHORTCUTS.includes(action)) + .join(";"); export const config: Configuration = { appId: "app.legcord.Legcord", @@ -43,6 +61,19 @@ export const config: Configuration = { }, }, + appImage: { + desktop: { + entry: { + Actions: availableActions, + }, + desktopActions: desktopActions("AppRun"), + }, + }, + + pacman: { + depends: ["gtk3", "libnotify", "nss", "libxss", "libxtst", "xdg-utils", "at-spi2-core", "libsecret"], + }, + nsis: { oneClick: false, allowToChangeInstallationDirectory: true, @@ -71,6 +102,12 @@ export const config: Configuration = { category: "Network", icon: "build/icon.icns", depends: ["libgbm-dev", "libasound2", "libnspr4", "libnss3"], + desktop: { + entry: { + Actions: availableActions, + }, + desktopActions: desktopActions("/opt/Legcord/legcord"), + }, }, files: [ diff --git a/package.json b/package.json index 6e47de8..6cdbb0c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "xml-formatter": "^3.7.0" }, "dependencies": { + "@jellybrick/dbus-next": "^0.11.1", "adm-zip": "^0.6.0", "arrpc": "github:Legcord/arrpc#efe7589762470d32b9ba10d529be5acc23cd0e19", "electron-context-menu": "^4.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de1012..7d7dba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@jellybrick/dbus-next': + specifier: ^0.11.1 + version: 0.11.1 adm-zip: specifier: ^0.6.0 version: 0.6.0 @@ -71,7 +74,7 @@ importers: version: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) babel-preset-solid: specifier: ^1.9.12 - version: 1.9.12(@babel/core@8.0.0-rc.6)(solid-js@1.9.14) + version: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) electron: specifier: 43.1.1 version: 43.1.1 @@ -113,6 +116,10 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.0': + resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/code-frame@8.0.0-rc.6': resolution: {integrity: sha512-Ru0EdYEptXbJGAGDMsenx+RcelHazuj8spqi7l0geXEPXv0X7qVisqWX+2pMaEZsWhS3Q6Um7oITwhnLe0cHJQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -137,6 +144,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@8.0.0-rc.6': resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -163,6 +174,10 @@ packages: resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.0': + resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-globals@8.0.0-rc.6': resolution: {integrity: sha512-ZF5FsxE4y7Eb6DiEbsLW3Gz3e73U/Uq3/ZotWf/moxv0DnZziXSMnW/tcXJF8b1mQE8XJnwPNSQOksKcKTz8kQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -217,6 +232,10 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@8.0.0-rc.6': resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -229,6 +248,10 @@ packages: resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -255,6 +278,11 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/plugin-syntax-jsx@7.29.7': resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} @@ -289,6 +317,10 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/template@8.0.0-rc.6': resolution: {integrity: sha512-CZFjZblLiIUEFghbB3sKs3rpYrwo65rLIklw/gNpwBm326cU6TH/xzvJZlZ+H9HpDi00eDXqNJR8CtEKW3oc/A==} engines: {node: ^22.18.0 || >=24.11.0} @@ -301,6 +333,10 @@ packages: resolution: {integrity: sha512-/txuBViGaE9gpM8MSGShru1O2Bzp3zb3m/XKZNUsNv2SyNn++lKDDIWkiIQ/345j2FBy7CHiYTZuKhewHWyefw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@8.0.4': + resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -309,6 +345,10 @@ packages: resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@biomejs/biome@2.4.16': resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} engines: {node: '>=14.21.3'} @@ -559,6 +599,10 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jellybrick/dbus-next@0.11.1': + resolution: {integrity: sha512-MEN8boAd/XN3nTaRULNh/QDWPi1LzDXLLl83EV6AXsdWpZhBzagl8CjBC85g8769JMDhJFytVX8RKyVjUk9nAw==} + engines: {node: '>=12.20.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -610,6 +654,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1048,6 +1095,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + app-builder-bin@5.0.0-alpha.12: resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} @@ -1506,6 +1556,13 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1771,6 +1828,9 @@ packages: resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} engines: {node: '>=0.10.0'} + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -2125,6 +2185,10 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2446,6 +2510,9 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -2610,6 +2677,10 @@ packages: resolution: {integrity: sha512-+8qTc3zv2UcJ1v9IsSIce37Dl4MQG14Cp7tWrwmy202UaI1wqRukw5QMX1JHsV+DX64yw77EgGsj2s5wGvuMbQ==} engines: {node: '>= 16'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xml-parser-xo@4.1.6: resolution: {integrity: sha512-mXbSNSM+rIwndoxSwmFa83bOdeP8G/cOGr7XvxA67X7ebCzoAuVez9JYDCDub3zRDNBZxIbfjuXLSsamr7NfwQ==} engines: {node: '>= 20'} @@ -2657,6 +2728,11 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.0': + dependencies: + '@babel/helper-validator-identifier': 8.0.4 + js-tokens: 10.0.0 + '@babel/code-frame@8.0.0-rc.6': dependencies: '@babel/helper-validator-identifier': 8.0.0-rc.6 @@ -2688,14 +2764,14 @@ snapshots: '@babel/core@8.0.0-rc.6': dependencies: - '@babel/code-frame': 8.0.0-rc.6 - '@babel/generator': 8.0.0-rc.6 + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 '@babel/helper-compilation-targets': 8.0.0-rc.6 '@babel/helpers': 8.0.0-rc.6 - '@babel/parser': 8.0.0-rc.6 - '@babel/template': 8.0.0-rc.6 - '@babel/traverse': 8.0.0-rc.6 - '@babel/types': 8.0.0-rc.6 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 '@types/gensync': 1.0.5 convert-source-map: 2.0.0 find-up-simple: 1.0.1 @@ -2713,10 +2789,19 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/generator@8.0.0-rc.6': dependencies: '@babel/parser': 8.0.0-rc.6 - '@babel/types': 8.0.0-rc.6 + '@babel/types': 8.0.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -2757,6 +2842,8 @@ snapshots: '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.0': {} + '@babel/helper-globals@8.0.0-rc.6': {} '@babel/helper-member-expression-to-functions@7.29.7': @@ -2819,12 +2906,16 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-string-parser@8.0.0-rc.6': {} '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@8.0.0-rc.6': {} + '@babel/helper-validator-identifier@8.0.4': {} + '@babel/helper-validator-option@7.29.7': {} '@babel/helper-validator-option@8.0.0-rc.6': {} @@ -2836,8 +2927,8 @@ snapshots: '@babel/helpers@8.0.0-rc.6': dependencies: - '@babel/template': 8.0.0-rc.6 - '@babel/types': 8.0.0-rc.6 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 '@babel/parser@7.29.7': dependencies: @@ -2845,18 +2936,17 @@ snapshots: '@babel/parser@8.0.0-rc.6': dependencies: - '@babel/types': 8.0.0-rc.6 + '@babel/types': 8.0.4 + + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.0-rc.6)': - dependencies: - '@babel/core': 8.0.0-rc.6 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -2898,6 +2988,12 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@babel/template@8.0.0-rc.6': dependencies: '@babel/code-frame': 8.0.0-rc.6 @@ -2926,6 +3022,16 @@ snapshots: '@babel/types': 8.0.0-rc.6 obug: 2.1.1 + '@babel/traverse@8.0.4': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-globals': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + obug: 2.1.1 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -2936,6 +3042,11 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.6 '@babel/helper-validator-identifier': 8.0.0-rc.6 + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@biomejs/biome@2.4.16': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.16 @@ -3156,6 +3267,11 @@ snapshots: dependencies: minipass: 7.1.3 + '@jellybrick/dbus-next@0.11.1': + dependencies: + fast-xml-builder: 1.3.0 + fast-xml-parser: 5.10.1 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3237,6 +3353,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nodable/entities@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3599,6 +3717,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + anynum@1.0.1: {} + app-builder-bin@5.0.0-alpha.12: {} app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): @@ -3690,19 +3810,19 @@ snapshots: - supports-color optional: true - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@8.0.0-rc.6): + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: - '@babel/core': 8.0.0-rc.6 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@8.0.0-rc.6) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@8.0.0-rc.6)(solid-js@1.9.14): + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14): dependencies: - '@babel/core': 8.0.0-rc.6 - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@8.0.0-rc.6) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.14 @@ -4192,6 +4312,20 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4494,6 +4628,8 @@ snapshots: is-plain-object@3.0.1: {} + is-unsafe@2.0.0: {} + isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -4789,6 +4925,8 @@ snapshots: path-exists@5.0.0: {} + path-expression-matcher@1.6.2: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5129,6 +5267,10 @@ snapshots: strip-json-comments@2.0.1: optional: true + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -5282,6 +5424,8 @@ snapshots: dependencies: xml-parser-xo: 4.1.6 + xml-naming@0.3.0: {} + xml-parser-xo@4.1.6: {} xmlbuilder@15.1.1: {} diff --git a/src/common/commandDefinitions.ts b/src/common/commandDefinitions.ts new file mode 100644 index 0000000..a301db9 --- /dev/null +++ b/src/common/commandDefinitions.ts @@ -0,0 +1,28 @@ +// static command definitions to retrieve at build-time without calling electron-dev indirectly + +export enum ValidActions { + mute = "mute", + deafen = "deafen", + leaveCall = "leave", + openSettings = "opensettings", + help = "help", +} + +export const actionDescriptions: Record = { + [ValidActions.mute]: "Toggle microphone mute", + [ValidActions.deafen]: "Toggle deafen (mute audio input/output)", + [ValidActions.leaveCall]: "Leave the current voice call", + [ValidActions.openSettings]: "Open the settings panel", + [ValidActions.help]: "Shows this help message", +}; + +export const ACTION_FRIENDLY_NAMES: Record = { + [ValidActions.mute]: "Toggle Mute", + [ValidActions.deafen]: "Toggle Deafen", + [ValidActions.leaveCall]: "Leave Call", + [ValidActions.openSettings]: "Open Settings", + [ValidActions.help]: "ignore (help)", +}; + +// we don't need a 'show help' shortcut do we? be fr +export const EXCLUDED_FROM_SHORTCUTS: ValidActions[] = [ValidActions.help]; diff --git a/src/common/handleCommands.ts b/src/common/handleCommands.ts new file mode 100644 index 0000000..c9cd3f1 --- /dev/null +++ b/src/common/handleCommands.ts @@ -0,0 +1,86 @@ +import { actionDescriptions, ValidActions } from "./commandDefinitions"; +import { deafenToggle, leaveCall, muteToggle, openSettings } from "./keybindActions"; + +export function isValidAction(value: string): value is ValidActions { + return Object.values(ValidActions).some((action) => value.includes(action)); +} + +function findValidAction(str: string): ValidActions | undefined { + return Object.values(ValidActions).find((action) => str.includes(action)); +} + +function sanitizeArguments(args: string[]): string[] { + return args.filter((arg) => arg.startsWith("--")).map((arg) => arg.replace("--", "")); +} + +/** + * Did the user pass any valid argument? + * + * @export + * @param {string[]} args List of arguments to validate + * @return {*} {boolean} + */ +export function passedValidArgument(args: string[]): boolean { + if (args.find((arg) => findValidAction(arg))) return true; + return false; +} + +export function handleAction(action: ValidActions): void { + switch (action) { + case ValidActions.mute: + muteToggle(); + break; + case ValidActions.deafen: + deafenToggle(); + break; + case ValidActions.leaveCall: + leaveCall(); + break; + case ValidActions.openSettings: + openSettings(); + break; + case ValidActions.help: + showHelpMessage(); + break; + default: { + // be completly sure we exaust every action possible at compile-time. + const exhaustiveCheck: never = action; + throw new Error(`Unhandled action: ${exhaustiveCheck}`); + } + } +} + +/** + * Handles command line arguments and applies actions accordingly, without spawning a new Legcord instance. + * + * @export + * @param {string[]} args List of all arguments to treat as possible commands + */ +export function handleCommands(args: string[]): void { + const sanitazed_args = sanitizeArguments(args); + const handledActions = new Set(); + + sanitazed_args.forEach((arg) => { + if (!arg) return; + + if (!isValidAction(arg)) return; + + const action = findValidAction(arg); + if (!action || handledActions.has(action)) return; + + console.log(`valid action: ${arg}`); + handledActions.add(action); + handleAction(action); + }); +} + +function showHelpMessage(): void { + const entries = Object.entries(actionDescriptions); + const width = Math.max(...entries.map(([cmd]) => cmd.length)); + + console.log("\nAvailable commands (ignore '--' if over dbus):\n"); + for (const [cmd, description] of entries) { + console.log(` --${cmd.padEnd(width)} ${description}`); + } + console.log(""); +} diff --git a/src/dbus.ts b/src/dbus.ts new file mode 100644 index 0000000..4276b37 --- /dev/null +++ b/src/dbus.ts @@ -0,0 +1,158 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { interface as dbusInterface, type ProxyInterface, sessionBus, Variant } from "@jellybrick/dbus-next"; +import { ACTION_FRIENDLY_NAMES, EXCLUDED_FROM_SHORTCUTS, ValidActions } from "./common/commandDefinitions"; +import { handleAction, isValidAction } from "./common/handleCommands"; + +const { Interface } = dbusInterface; + +export const DBUS_INTERFACE_NAME = "app.legcord.Legcord"; +export const DBUS_ADDRESS = "/app/legcord/Legcord"; + +const FREEDESKTOP_PORTAL_NAME = "org.freedesktop.portal.Desktop"; +const FREEDESKTOP_PORTAL_ADDRESS = "/org/freedesktop/portal/desktop"; + +// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html +type Shortcuts = [string, { description: Variant }]; +interface GlobalShortcuts extends ProxyInterface { + CreateSession(options: { handle_token: Variant; session_handle_token: Variant }): Promise; + + ConfigureShortcuts(session_handle: string, parent_window: string, options: object): Promise; + + BindShortcuts( + session_handle: string, + shortcuts: Shortcuts[], + parent_window: string, + options: { + handle_token: Variant; + }, + ): Promise; +} + +interface PropertiesInterface extends ProxyInterface { + Get(interface_name: string, property_name: string): Promise; + Set(interface_name: string, property_name: string, value: Variant): void; +} + +interface RegistryInterface extends ProxyInterface { + Register(app_id: string, options: object): void; +} + +// original way of doing it (requires extensive babel plugins and typescript decorators) +// class LegcordInterface extends Interface { +// @method({ inSignature: "s", outSignature: "", noReply: true, disabled: false }) +// TriggerAction(action: string): void { +// currentHandler(action); +// } +// } + +// THIS is nescessary only so we don't have to use babel +// plugins all over rolldown.config.ts in every file, and slow down build time. +// https://acrisci.github.io/doc/node-dbus-next/ +class LegcordInterface extends Interface { + constructor() { + super(DBUS_INTERFACE_NAME); + this.$methods = { + TriggerAction: { + name: "TriggerAction", + disabled: false, + noReply: true, + inSignature: "s", + outSignature: "", + inSignatureTree: [{ type: "s", child: [] }], + outSignatureTree: [], + fn: (action: string) => { + if (!isValidAction(action)) { + console.warn("Received unsupported action over DBus:", action); + return; + } + handleAction(action); + }, + }, + }; + } +} + +const bus = sessionBus(); +var legcordInterface = new LegcordInterface(); + +function ensureDesktopFile(): void { + const dir = join(homedir(), ".local/share/applications"); + const path = join(dir, `${DBUS_INTERFACE_NAME}.desktop`); + if (existsSync(path)) return; + + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `[Desktop Entry]\nType=Application\nName=Legcord\nExec=${process.execPath}\nNoDisplay=true\n`); +} + +function registerAppId(): Promise { + ensureDesktopFile(); // we need a desktop file at XDG_PATH so we can associate with legcord's app_id + return bus.getProxyObject("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop").then((portalObj) => { + const registry: RegistryInterface = portalObj.getInterface("org.freedesktop.host.portal.Registry"); + return registry.Register(DBUS_INTERFACE_NAME, {}); + }); +} + +export async function setupGlobalShortcuts() { + const portalObj = await bus.getProxyObject(FREEDESKTOP_PORTAL_NAME, FREEDESKTOP_PORTAL_ADDRESS); + + const properties: PropertiesInterface = portalObj.getInterface("org.freedesktop.DBus.Properties"); + const globalShortcuts: GlobalShortcuts = portalObj.getInterface("org.freedesktop.portal.GlobalShortcuts"); + + const freedesktopVersion = (await properties.Get("org.freedesktop.portal.GlobalShortcuts", "version")) + .value as number; + console.debug(`Connected with freedesktop portal version ${freedesktopVersion}`); + + await registerAppId(); + console.debug(`Registered ${DBUS_INTERFACE_NAME} in freedesktop Registry.`); + + function awaitResponse(requestPath: string): Promise> { + return bus.getProxyObject(FREEDESKTOP_PORTAL_NAME, requestPath).then( + (requestObj) => + new Promise>((resolve, reject) => { + const requestIface = requestObj.getInterface("org.freedesktop.portal.Request"); + + requestIface.once("Response", (responseCode: number, results: Record) => { + if (responseCode === 0) resolve(results); + else reject(new Error(`Request failed with code ${responseCode}`)); + }); + }), + ); + } + + const sessionRequestPath = await globalShortcuts.CreateSession({ + handle_token: new Variant("s", "legcord_session"), + session_handle_token: new Variant("s", "legcord_shortcuts"), + }); + + const sessionResult = (await awaitResponse(sessionRequestPath)) as { session_handle: Variant }; // real response signature + + const actionList: [string, { description: Variant }][] = (Object.values(ValidActions) as ValidActions[]) + .filter((action) => !EXCLUDED_FROM_SHORTCUTS.includes(action)) + .map((action) => [action, { description: new Variant("s", ACTION_FRIENDLY_NAMES[action]) }]); + + const sessionHandle = sessionResult.session_handle.value; + const bindRequestPath = await globalShortcuts.BindShortcuts(sessionHandle, actionList, "", { + handle_token: new Variant("s", "legcord_bind"), + }); + + await awaitResponse(bindRequestPath); + + globalShortcuts.on("Activated", (activatedSession: string, shortcutId: string) => { + if (activatedSession !== sessionHandle || !isValidAction(shortcutId)) return; + handleAction(shortcutId); + }); +} + +export async function startDbusService(): Promise { + await bus.requestName(DBUS_INTERFACE_NAME); + bus.export(DBUS_ADDRESS, legcordInterface); + console.info(`Registered DBus service at ${DBUS_INTERFACE_NAME} ${DBUS_ADDRESS}`); + + // console.debug(legcordInterface) +} + +export function disconnectDbusService(): void { + bus.disconnect(); +} diff --git a/src/discord/window.ts b/src/discord/window.ts index 2d8b0d2..f9f39f4 100644 --- a/src/discord/window.ts +++ b/src/discord/window.ts @@ -17,9 +17,11 @@ import contextMenu from "electron-context-menu"; import { firstRun, getConfig, setConfig } from "../common/config.js"; import { navigateTo } from "../common/dom.js"; import { forceQuit, setForceQuit } from "../common/forceQuit.js"; +import { handleCommands, passedValidArgument } from "../common/handleCommands.js"; import { getLang } from "../common/lang.js"; import { injectThemesMain } from "../common/themes.js"; import { getWindowState, setWindowState } from "../common/windowState.js"; +import { disconnectDbusService } from "../dbus.js"; import { init } from "../main.js"; import { registerGlobalKeybinds } from "./globalKeybinds.js"; import { registerIpc } from "./ipc.js"; @@ -160,17 +162,18 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void { app.on("second-instance", (_event, commandLine, _workingDirectory, additionalData) => { void (async () => { // Print out data received from the second instance. - console.log(additionalData); + console.log(`data received: ${additionalData}`); if (!getConfig("multiInstance")) { - // Someone tried to run a second instance, we should focus our window. - if (passedWindow) { + // Someone tried to run a second instance, + // we should focus our window if the user is not running special commands. + if (passedWindow && !passedValidArgument(commandLine)) { if (passedWindow.isMinimized()) passedWindow.restore(); passedWindow.show(); passedWindow.focus(); } if (commandLine && commandLine.length > 0) { - console.log(commandLine); + handleCommands(commandLine); const lastArg = commandLine.pop(); if (lastArg?.startsWith("discord://-")) { navigateTo(passedWindow, lastArg.replace("discord://-", "")); @@ -383,6 +386,7 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void { }); app.on("before-quit", () => { stopRPC(); + disconnectDbusService(); try { // Ensure current window state is saved with display info if (passedWindow && !passedWindow.isDestroyed()) saveWindowState(passedWindow); @@ -550,12 +554,7 @@ export function createWindow() { const mainWindow = new BrowserWindow(browserWindowOptions); // Restore by position + size directly to match saveWindowState roundtrip. - if ( - storedBounds.x !== undefined && - storedBounds.y !== undefined && - storedBounds.width !== undefined && - storedBounds.height !== undefined - ) { + if (storedBounds.x !== undefined && storedBounds.y !== undefined) { mainWindow.setPosition(storedBounds.x, storedBounds.y); mainWindow.setSize(storedBounds.width, storedBounds.height); } diff --git a/src/main.ts b/src/main.ts index 9ee9d16..f8493c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { } from "./common/config.js"; import { getPreset } from "./common/flags.js"; import { setLang } from "./common/lang.js"; +import { setupGlobalShortcuts, startDbusService } from "./dbus.js"; // Chrome flags tracking export interface AppliedFlagsOutput { @@ -153,7 +154,7 @@ if (!app.requestSingleInstanceLock() && getConfig("multiInstance") === false) { .add("MediaSessionService"); // Your data now belongs to CCP crashReporter.start({ uploadToServer: false }); - // enable pulseaudio audio sharing on linux + // enable pulseaudio audio sharing on linux and register keybinds on supported desktop managers and start dbus socket if (process.platform === "linux") { app.commandLine.appendSwitch("gtk-version", "3"); trackSwitch("gtk-version", "3"); @@ -161,6 +162,16 @@ if (!app.requestSingleInstanceLock() && getConfig("multiInstance") === false) { disableFeatures.add("WebRtcAllowInputVolumeAdjustment"); app.commandLine.appendSwitch("enable-speech-dispatcher"); trackSwitch("enable-speech-dispatcher"); + + startDbusService() + .catch((reason) => { + console.error("Could not start DBus service.", reason); + }) + .then(() => { + setupGlobalShortcuts().catch((reason) => { + console.error("Could not setup global shortcuts.", reason); + }); + }); } // enable webrtc capturer for wayland if (process.platform === "linux" && process.env.XDG_SESSION_TYPE?.toLowerCase() === "wayland") { From f30dadcdfdaa772482bbf4211468664bdf323fc2 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:44:18 +0200 Subject: [PATCH 02/17] feat: add more performance modes --- assets/lang/de.json | 5 + assets/lang/en-US.json | 5 + assets/lang/es_419.json | 5 + assets/lang/fr.json | 5 + assets/lang/hu.json | 5 + assets/lang/id.json | 5 + assets/lang/pt_BR.json | 5 + assets/lang/pt_PT.json | 5 + assets/lang/ru.json | 5 + assets/lang/uk.json | 5 + src/@types/settings.d.ts | 11 +- src/common/flags.ts | 126 ++++++++++++++++++-- src/shelter/settings/pages/SettingsPage.tsx | 8 ++ 13 files changed, 184 insertions(+), 11 deletions(-) diff --git a/assets/lang/de.json b/assets/lang/de.json index 8b8206a..ff30da9 100644 --- a/assets/lang/de.json +++ b/assets/lang/de.json @@ -89,7 +89,12 @@ "settings-prfmMode": "Leistungsmodus", "settings-prfmMode-desc": "Der Leistungsmodus ist eine experimentelle Funktion in Legcord, die darauf ausgelegt ist, die Reaktionsfähigkeit und Leistung entsprechend Ihren Anforderungen zu optimieren. Die Auswirkungen können je nach Ihrer Hardware und Nutzung variieren. Wir empfehlen Ihnen daher, jeden Modus auszuprobieren, um herauszufinden, welcher für Sie am besten geeignet ist.", "settings-prfmMode-performance": "Leistung", + "settings-prfmMode-balanced": "Ausgewogen", "settings-prfmMode-battery": "Batterie", + "settings-prfmMode-memory": "Speichersparer", + "settings-prfmMode-voip": "Sprache & Video", + "settings-prfmMode-latency": "Niedrige Latenz", + "settings-prfmMode-smoothScreenshare": "Flüssige Bildschirmfreigabe", "settings-prfmMode-dynamic": "Dynamisch", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Automatische Verstärkung deaktivieren", diff --git a/assets/lang/en-US.json b/assets/lang/en-US.json index 59fce9a..3a43e6a 100644 --- a/assets/lang/en-US.json +++ b/assets/lang/en-US.json @@ -115,7 +115,12 @@ "settings-prfmMode": "Performance mode", "settings-prfmMode-desc": "Performance Mode is an experimental feature in Legcord designed to optimize responsiveness and performance based on your needs. The impact may vary depending on your hardware and usage, so we encourage you to try each mode to determine which works best for you.", "settings-prfmMode-performance": "Performance", + "settings-prfmMode-balanced": "Balanced", "settings-prfmMode-battery": "Battery", + "settings-prfmMode-memory": "Memory saver", + "settings-prfmMode-voip": "Voice & video", + "settings-prfmMode-latency": "Low latency", + "settings-prfmMode-smoothScreenshare": "Smooth screenshare", "settings-prfmMode-dynamic": "Dynamic", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Disable autogain", diff --git a/assets/lang/es_419.json b/assets/lang/es_419.json index 1168a07..d5d048e 100644 --- a/assets/lang/es_419.json +++ b/assets/lang/es_419.json @@ -95,7 +95,12 @@ "settings-prfmMode": "Modo rendimiento", "settings-prfmMode-desc": "El modo de rendimiento es una función experimental de Legcord diseñada para optimizar la capacidad de respuesta y el rendimiento en función de tus necesidades. El impacto puede variar en función de tu hardware y uso, por lo que te recomendamos que pruebes cada modo para determinar cuál te conviene más.", "settings-prfmMode-performance": "Rendimiento", + "settings-prfmMode-balanced": "Equilibrado", "settings-prfmMode-battery": "Batería", + "settings-prfmMode-memory": "Ahorro de memoria", + "settings-prfmMode-voip": "Voz y vídeo", + "settings-prfmMode-latency": "Baja latencia", + "settings-prfmMode-smoothScreenshare": "Compartir pantalla fluido", "settings-prfmMode-dynamic": "Dinámico", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Desactivar ganancia automática", diff --git a/assets/lang/fr.json b/assets/lang/fr.json index 734ec4d..429c0c2 100644 --- a/assets/lang/fr.json +++ b/assets/lang/fr.json @@ -57,7 +57,12 @@ "settings-prfmMode": "Mode performance", "settings-prfmMode-desc": "Le mode performance est une fonctionnalité expérimentale de Legcord conçue pour optimiser la réactivité et les performances selon vos besoins. Son impact peut varier selon votre matériel et votre utilisation, c'est pourquoi nous vous encourageons à essayer chaque mode pour déterminer lequel vous correspond le mieux.", "settings-prfmMode-performance": "Performance", + "settings-prfmMode-balanced": "Équilibré", "settings-prfmMode-battery": "Batterie", + "settings-prfmMode-memory": "Économie mémoire", + "settings-prfmMode-voip": "Voix et vidéo", + "settings-prfmMode-latency": "Faible latence", + "settings-prfmMode-smoothScreenshare": "Partage d'écran fluide", "settings-prfmMode-dynamic": "Dynamique", "settings-prfmMode-vaapi": "VAAPI", "settings-disableHttpCache": "Désactiver le cache HTTP", diff --git a/assets/lang/hu.json b/assets/lang/hu.json index 0a81c97..7214b81 100644 --- a/assets/lang/hu.json +++ b/assets/lang/hu.json @@ -94,7 +94,12 @@ "settings-prfmMode": "Teljesítmény mód", "settings-prfmMode-desc": "A Teljesítmény mód egy kísérleti funkció a Legcordban, melynek célja a válaszidő és a teljesítmény optimalizálása az Ön igényei alapján. A hatás a hardvertől és a használattól függően változhat, ezért javasoljuk, hogy próbálja ki az egyes módokat, hogy megállapítsa, melyik működik legjobban az Ön számára.", "settings-prfmMode-performance": "Teljesítmény", + "settings-prfmMode-balanced": "Kiegyensúlyozott", "settings-prfmMode-battery": "Akkumulátor", + "settings-prfmMode-memory": "Memóriatakarékos", + "settings-prfmMode-voip": "Hang és videó", + "settings-prfmMode-latency": "Alacsony késleltetés", + "settings-prfmMode-smoothScreenshare": "Sima képernyőmegosztás", "settings-prfmMode-dynamic": "Dinamikus", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Automatikus erősítés letiltása", diff --git a/assets/lang/id.json b/assets/lang/id.json index 08a583c..9f3b916 100644 --- a/assets/lang/id.json +++ b/assets/lang/id.json @@ -89,7 +89,12 @@ "settings-prfmMode": "Mode kinerja", "settings-prfmMode-desc": "Mode Kinerja adalah fitur eksperimental di Legcord yang dirancang untuk mengoptimalkan responsivitas dan kinerja sesuai dengan kebutuhan Anda. Dampak yang dihasilkan dapat bervariasi tergantung pada spesifikasi perangkat keras dan pola penggunaan Anda, jadi kami menyarankan Anda untuk mencoba setiap mode untuk menentukan mana yang paling sesuai dengan kebutuhan Anda.", "settings-prfmMode-performance": "Kinerja", + "settings-prfmMode-balanced": "Seimbang", "settings-prfmMode-battery": "Baterai", + "settings-prfmMode-memory": "Penghemat memori", + "settings-prfmMode-voip": "Suara & video", + "settings-prfmMode-latency": "Latensi rendah", + "settings-prfmMode-smoothScreenshare": "Berbagi layar mulus", "settings-prfmMode-dynamic": "Dinamis", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Nonaktifkan autogain", diff --git a/assets/lang/pt_BR.json b/assets/lang/pt_BR.json index 4dd2a74..184c091 100644 --- a/assets/lang/pt_BR.json +++ b/assets/lang/pt_BR.json @@ -145,7 +145,12 @@ "settings-prfmMode": "Modo de performance", "settings-prfmMode-desc": "Modo de performance é uma funcionalidade experimental do Legcord feita para otimizar responsitividade e performance baseado nas suas nescessidades. Impacto pode variar dependendo da configuração da sua máquina e seu uso, então recomendamos tentar cada modo e escolher um que sirva melhor para você.", "settings-prfmMode-performance": "Performance", + "settings-prfmMode-balanced": "Equilibrado", "settings-prfmMode-battery": "Bateria", + "settings-prfmMode-memory": "Economia de memória", + "settings-prfmMode-voip": "Voz e vídeo", + "settings-prfmMode-latency": "Baixa latência", + "settings-prfmMode-smoothScreenshare": "Compartilhamento de tela suave", "settings-prfmMode-dynamic": "Dinâmico", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Desabilitar ganho automático", diff --git a/assets/lang/pt_PT.json b/assets/lang/pt_PT.json index cc860dc..c148548 100644 --- a/assets/lang/pt_PT.json +++ b/assets/lang/pt_PT.json @@ -45,7 +45,12 @@ "settings-audio-desc": "Selecione o método que o Legcord usa para capturar o áudio do seu sistema durante o partilhamento de ecrã.", "settings-prfmMode": "Modo de desempenho", "settings-prfmMode-performance": "Desempenho", + "settings-prfmMode-balanced": "Equilibrado", "settings-prfmMode-dynamic": "Dinâmico", + "settings-prfmMode-latency": "Baixa latência", + "settings-prfmMode-memory": "Poupança de memória", + "settings-prfmMode-voip": "Voz e vídeo", + "settings-prfmMode-smoothScreenshare": "Partilha de ecrã fluida", "settings-none": "Nenjum", "settings-save": "Gravar configurações", "settings-restart": "Reiniciar a app", diff --git a/assets/lang/ru.json b/assets/lang/ru.json index ff9bdca..fa2c5f6 100644 --- a/assets/lang/ru.json +++ b/assets/lang/ru.json @@ -83,7 +83,12 @@ "settings-prfmMode": "Режим Производительности", "settings-prfmMode-desc": "Режимы Производительности - экспериментальная функция Legcord, оптимизирующая производительность и скорость работы программы под ваши нужды. Эффект может зависеть от вашей системной конфигурации и паттернов использования, поэтому мы рекомендуем вам попробовать каждый режим и выбрать тот, что лучше всего подходит именно вам.", "settings-prfmMode-performance": "Производительный", + "settings-prfmMode-balanced": "Сбалансированный", "settings-prfmMode-battery": "Энергосбережение", + "settings-prfmMode-memory": "Экономия памяти", + "settings-prfmMode-voip": "Голос и видео", + "settings-prfmMode-latency": "Низкая задержка", + "settings-prfmMode-smoothScreenshare": "Плавная демонстрация экрана", "settings-prfmMode-dynamic": "Динамический", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Выключить автоусиление", diff --git a/assets/lang/uk.json b/assets/lang/uk.json index ce6cdf4..d6feb55 100644 --- a/assets/lang/uk.json +++ b/assets/lang/uk.json @@ -89,7 +89,12 @@ "settings-prfmMode": "Режим продуктивності", "settings-prfmMode-desc": "Режим продуктивності – це експериментальна функція в Legcord, розроблена для оптимізації швидкості реагування та продуктивності відповідно до ваших потреб. Вплив може відрізнятися залежно від вашого обладнання та використання, тому ми рекомендуємо вам спробувати кожен режим, щоб визначити, який найкраще підходить саме вам.", "settings-prfmMode-performance": "Продуктивність", + "settings-prfmMode-balanced": "Збалансований", "settings-prfmMode-battery": "Батарея", + "settings-prfmMode-memory": "Економія пам'яті", + "settings-prfmMode-voip": "Голос і відео", + "settings-prfmMode-latency": "Низька затримка", + "settings-prfmMode-smoothScreenshare": "Плавна демонстрація екрана", "settings-prfmMode-dynamic": "Динамічний", "settings-prfmMode-vaapi": "VAAPI", "settings-disableAutogain": "Вимкнути автоматичне посилення", diff --git a/src/@types/settings.d.ts b/src/@types/settings.d.ts index 46923ae..2d44273 100644 --- a/src/@types/settings.d.ts +++ b/src/@types/settings.d.ts @@ -43,7 +43,16 @@ export interface Settings { mods: ValidMods[]; mobileMode: boolean; skipSplash: boolean; - performanceMode: "battery" | "dynamic" | "performance" | "smoothScreenshare" | "none"; + performanceMode: + | "battery" + | "dynamic" + | "performance" + | "balanced" + | "memory" + | "voip" + | "latency" + | "smoothScreenshare" + | "none"; customJsBundle: RequestInfo | URL | string; customCssBundle: RequestInfo | URL | string; startMinimized: boolean; diff --git a/src/common/flags.ts b/src/common/flags.ts index 3231cc1..67ba5b5 100644 --- a/src/common/flags.ts +++ b/src/common/flags.ts @@ -32,6 +32,76 @@ const performance: Preset = { disableFeatures: ["Vulkan"], }; +/** Light GPU boost without forcing the discrete GPU — a middle ground. */ +const balanced: Preset = { + switches: [["enable-gpu-rasterization"], ["enable-zero-copy"], ["ignore-gpu-blocklist"]], + enableFeatures: [ + "CanvasOopRasterization", + "UseSkiaRenderer", + "WebAssemblyLazyCompilation", + "CalculateNativeWinOcclusion", + "ThrottleDisplayNoneAndVisibilityHiddenCrossOriginIframes", + ], + disableFeatures: ["Vulkan"], +}; + +/** Reduce RAM / CPU usage at the cost of some visual smoothness. */ +const memory: Preset = { + switches: [ + ["enable-low-end-device-mode"], + ["enable-low-res-tiling"], + ["process-per-site"], + ["renderer-process-limit", "2"], + ["force_low_power_gpu"], + ["disk-cache-size", "67108864"], // 64 MB + ["skia-resource-cache-limit-mb", "64"], + ], + enableFeatures: ["CalculateNativeWinOcclusion", "TurnOffStreamingMediaCachingOnBattery"], + disableFeatures: [], +}; + +/** Favor voice/video call quality with HW WebRTC encode/decode. */ +const voip: Preset = { + switches: [ + ["enable-gpu-rasterization"], + ["enable-zero-copy"], + ["ignore-gpu-blocklist"], + ["force_high_performance_gpu"], + ["disable-background-timer-throttling"], + ["disable-renderer-backgrounding"], + ["disable-backgrounding-occluded-windows"], + ["enable-gpu-memory-buffer-video-frames"], + ], + enableFeatures: [ + "WebRtcHWDecoding", + "WebRtcHWEncoding", + "AcceleratedVideoDecoder", + "AcceleratedVideoEncoder", + "AcceleratedVideoDecodeLinuxGL", + "AcceleratedVideoDecodeLinuxZeroCopyGL", + "ZeroCopyDesktopCapture", + ], + disableFeatures: ["UseChromeOSDirectVideoDecoder"], +}; + +/** Minimize input/render latency; keeps the app hot in the background. */ +const latency: Preset = { + switches: [ + ["enable-gpu-rasterization"], + ["enable-zero-copy"], + ["ignore-gpu-blocklist"], + ["force_high_performance_gpu"], + ["enable-hardware-overlays", "single-fullscreen,single-on-top,underlay"], + ["disable-background-timer-throttling"], + ["disable-renderer-backgrounding"], + ["disable-backgrounding-occluded-windows"], + ["disable-ipc-flooding-protection"], + ["disable-backing-store-limit"], + ], + enableFeatures: ["EnableDrDc", "CanvasOopRasterization", "UseSkiaRenderer", "WebAssemblyLazyCompilation"], + disableFeatures: ["Vulkan"], +}; + const smoothExperiment: Preset = { switches: [ ["enable-gpu-rasterization"], @@ -54,6 +124,7 @@ const smoothExperiment: Preset = { "AcceleratedVideoEncoder", "AcceleratedVideoDecoder", "AcceleratedVideoDecodeLinuxZeroCopyGL", + "ZeroCopyDesktopCapture", ], disableFeatures: ["Vulkan", "UseChromeOSDirectVideoDecoder"], }; @@ -66,7 +137,7 @@ const battery: Preset = { ["enable-low-res-tiling"], ["process-per-site"], ], - enableFeatures: ["TurnOffStreamingMediaCachingOnBattery"], + enableFeatures: ["TurnOffStreamingMediaCachingOnBattery", "CalculateNativeWinOcclusion"], disableFeatures: [], }; @@ -160,6 +231,14 @@ function mergeWithCustomFlags(preset: Preset): Preset { }; } +function mergePresets(base: Preset, extra: Preset): Preset { + return { + switches: [...base.switches, ...extra.switches], + enableFeatures: [...base.enableFeatures, ...extra.enableFeatures], + disableFeatures: [...base.disableFeatures, ...extra.disableFeatures], + }; +} + export function getPreset(): Preset | undefined { // MIT License @@ -182,31 +261,58 @@ export function getPreset(): Preset | undefined { // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. - if (getConfig("vaapi")) { - console.log("VAAPI mode enabled"); - mergeWithCustomFlags(vaapi); - } + let preset: Preset | undefined; + switch (getConfig("performanceMode")) { case "dynamic": if (powerMonitor.isOnBatteryPower()) { console.log("Battery mode enabled"); - return mergeWithCustomFlags(battery); + preset = battery; } else { console.log("Performance mode enabled"); - return mergeWithCustomFlags(performance); + preset = performance; } + break; case "performance": console.log("Performance mode enabled"); - return mergeWithCustomFlags(performance); + preset = performance; + break; + case "balanced": + console.log("Balanced mode enabled"); + preset = balanced; + break; case "battery": console.log("Battery mode enabled"); - return mergeWithCustomFlags(battery); + preset = battery; + break; + case "memory": + console.log("Memory saver mode enabled"); + preset = memory; + break; + case "voip": + console.log("Voice & video mode enabled"); + preset = voip; + break; + case "latency": + console.log("Low latency mode enabled"); + preset = latency; + break; case "smoothScreenshare": console.log("Smooth screenshare mode enabled"); - return mergeWithCustomFlags(smoothExperiment); + preset = smoothExperiment; + break; default: console.log("No performance modes set"); } + + if (getConfig("vaapi")) { + console.log("VAAPI flags enabled"); + preset = preset ? mergePresets(preset, vaapi) : vaapi; + } + + if (preset) { + return mergeWithCustomFlags(preset); + } } /** diff --git a/src/shelter/settings/pages/SettingsPage.tsx b/src/shelter/settings/pages/SettingsPage.tsx index 9386d75..e5214f0 100644 --- a/src/shelter/settings/pages/SettingsPage.tsx +++ b/src/shelter/settings/pages/SettingsPage.tsx @@ -328,7 +328,15 @@ export function SettingsPage() { label: store.i18n["settings-prfmMode-performance"], value: "performance", }, + { label: store.i18n["settings-prfmMode-balanced"], value: "balanced" }, + { label: store.i18n["settings-prfmMode-latency"], value: "latency" }, + { + label: store.i18n["settings-prfmMode-smoothScreenshare"], + value: "smoothScreenshare", + }, + { label: store.i18n["settings-prfmMode-voip"], value: "voip" }, { label: store.i18n["settings-prfmMode-battery"], value: "battery" }, + { label: store.i18n["settings-prfmMode-memory"], value: "memory" }, { label: store.i18n["settings-none"], value: "none" }, ]} /> From 91a3d86a12ef528ea25da3383615dffa7079e520 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:56:25 +0200 Subject: [PATCH 03/17] docs: add AI notice --- .github/PULL_REQUEST_TEMPLATE.md | 7 +++++++ CONTRIBUTING.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 419d264..f7b9ea8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,6 +23,13 @@ - [ ] Manual verification for affected flows - [ ] Screenshots or recordings for UI changes +## AI Notice + + + +- [ ] I have used any AI/LLM tool to generate code or text in this PR. I understand that I am responsible for reviewing and validating any code generated by AI, and that I will be held accountable for any issues caused by this code. +- [ ] I have not used any AI to generate code in this PR. + ## Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27466d7..3a77d61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,11 @@ Before requesting review, verify: - Reference related issues and prior discussions. - If you added a workaround/suppression, include the reason in code comments and in the PR description. +## AI Notice + +While we welcome AI-assisted development, we expect contributors to review and validate any code generated by AI tools. You are responsible for ensuring that any AI-generated code meets our quality, security, and performance standards. +We don't allow vibecoded slop. There has to be at least slight human touch in the code you submit. If you use AI tools to generate code, please disclose it in your PR description. Don't lie about it. We can tell and we will not accept PRs that are entirely AI-generated. + ## Documentation We are still improving project and codebase documentation. From 4ec80d11bb0980486d4659e4f1bc622e9397a534 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:11:27 +0200 Subject: [PATCH 04/17] feat: add proxy/networking settings solves https://github.com/Legcord/Legcord/issues/1013 --- assets/lang/en-US.json | 14 ++ src/@types/settings.d.ts | 11 ++ src/common/config.ts | 4 + src/common/proxy.ts | 201 ++++++++++++++++++++ src/main.ts | 4 + src/shelter/settings/pages/SettingsPage.tsx | 41 ++++ 6 files changed, 275 insertions(+) create mode 100644 src/common/proxy.ts diff --git a/assets/lang/en-US.json b/assets/lang/en-US.json index 3a43e6a..825e474 100644 --- a/assets/lang/en-US.json +++ b/assets/lang/en-US.json @@ -164,7 +164,21 @@ "settings-category-mods": "Mods", "settings-category-behaviour": "Behaviour", "settings-category-legacy": "Legacy features", + "settings-category-networking": "Networking", "settings-category-debug": "Debug options", + "settings-proxyMode": "Proxy mode", + "settings-proxyMode-desc": "Choose how Legcord connects through a proxy. System uses your OS proxy (and HTTP(S)_PROXY environment variables). Fixed servers and PAC work like in a normal web browser. A restart is required.", + "settings-proxyMode-system": "System", + "settings-proxyMode-direct": "Direct (no proxy)", + "settings-proxyMode-fixed_servers": "Fixed servers", + "settings-proxyMode-pac_script": "PAC script", + "settings-proxyMode-auto_detect": "Auto-detect", + "settings-proxyRules": "Proxy server", + "settings-proxyRules-desc": "Proxy URL or Chromium proxy rules. Examples: http://127.0.0.1:8080, socks5://127.0.0.1:1080, or http=proxy:80;https=proxy:80", + "settings-proxyPacScript": "PAC script URL", + "settings-proxyPacScript-desc": "URL of a proxy auto-config (PAC) file, e.g. http://wpad/wpad.dat or file:///path/to/proxy.pac", + "settings-proxyBypassRules": "Proxy bypass list", + "settings-proxyBypassRules-desc": "Comma-separated hosts that skip the proxy. Use for localhost. Example: ,*.intranet.example,10.0.0.0/8", "menu-about": "About Legcord", "menu-developerTools": "Developer tools", "menu-openSettings": "Open settings", diff --git a/src/@types/settings.d.ts b/src/@types/settings.d.ts index 2d44273..762da56 100644 --- a/src/@types/settings.d.ts +++ b/src/@types/settings.d.ts @@ -25,6 +25,9 @@ export interface AudioSettings { loopbackType: "loopback" | "loopbackWithMute"; } +/** Chromium/Electron proxy modes — mirrors browser proxy settings. */ +export type ProxyMode = "system" | "direct" | "fixed_servers" | "pac_script" | "auto_detect"; + export interface Settings { // Referenced for detecting a broken config. "0"?: string; @@ -76,6 +79,14 @@ export interface Settings { quickCss: boolean; autoScroll: boolean; additionalArguments: string; + /** How Legcord resolves HTTP(S) proxies (Chromium + main-process fetch). */ + proxyMode: ProxyMode; + /** Fixed proxy rules, e.g. `http://127.0.0.1:8080` or `socks5://host:1080`. */ + proxyRules: string; + /** Hosts that bypass the proxy (comma-separated), e.g. `,*.intranet.example`. */ + proxyBypassRules: string; + /** PAC script URL when proxyMode is `pac_script`. */ + proxyPacScript: string; noBundleUpdates: ValidMods[]; automaticUpdates: boolean; overlayButtonColor: string; diff --git a/src/common/config.ts b/src/common/config.ts index 06c5031..921cfb2 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -60,6 +60,10 @@ const defaults: Settings = { noBundleUpdates: [], automaticUpdates: false, additionalArguments: "", + proxyMode: "system", + proxyRules: "", + proxyBypassRules: "", + proxyPacScript: "", customIcon: join(import.meta.dirname, "../", "/assets/desktop.png"), smoothScroll: true, autoScroll: false, diff --git a/src/common/proxy.ts b/src/common/proxy.ts new file mode 100644 index 0000000..564a9bc --- /dev/null +++ b/src/common/proxy.ts @@ -0,0 +1,201 @@ +import { app, type ProxyConfig, session } from "electron"; +import type { ProxyMode } from "../@types/settings.js"; +import { getConfig } from "./config.js"; + +function envProxy(): string | undefined { + return process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; +} + +function envNoProxy(): string | undefined { + return process.env.NO_PROXY || process.env.no_proxy; +} + +/** Convert Chromium-style bypass list to NO_PROXY form. */ +function toNoProxy(bypass: string): string { + return bypass + .split(/[,;]/) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + if (part === "") return "localhost,127.0.0.1,::1"; + return part.replace(/^\*\./, "."); + }) + .join(","); +} + +/** Pick a single proxy URL Node fetch can use from Chromium proxyRules. */ +function primaryProxyFromRules(rules: string): string | undefined { + const trimmed = rules.trim(); + if (!trimmed) return undefined; + + // Prefer an explicit https=/http=/socks= mapping, else first token. + const parts = trimmed + .split(";") + .map((p) => p.trim()) + .filter(Boolean); + for (const scheme of ["https", "http", "socks", "socks5", "socks4"]) { + const match = parts.find((p) => p.toLowerCase().startsWith(`${scheme}=`)); + if (match) { + const value = match + .slice(scheme.length + 1) + .split(",")[0] + ?.trim(); + if (value && value !== "direct://") return value.includes("://") ? value : `http://${value}`; + } + } + + const first = parts[0]?.split(",")[0]?.trim(); + if (!first || first === "direct://") return undefined; + if (first.includes("=")) { + const value = first.split("=")[1]?.trim(); + if (!value || value === "direct://") return undefined; + return value.includes("://") ? value : `http://${value}`; + } + return first.includes("://") ? first : `http://${first}`; +} + +function resolveProxyConfig(): ProxyConfig { + const mode = getConfig("proxyMode") as ProxyMode | undefined; + const proxyRules = (getConfig("proxyRules") ?? "").trim(); + const proxyBypassRules = (getConfig("proxyBypassRules") ?? "").trim(); + const proxyPacScript = (getConfig("proxyPacScript") ?? "").trim(); + + switch (mode) { + case "direct": + return { mode: "direct" }; + case "auto_detect": + return { + mode: "auto_detect", + ...(proxyBypassRules ? { proxyBypassRules } : {}), + }; + case "pac_script": + return { + mode: "pac_script", + pacScript: proxyPacScript, + ...(proxyBypassRules ? { proxyBypassRules } : {}), + }; + case "fixed_servers": + return { + mode: "fixed_servers", + proxyRules, + ...(proxyBypassRules ? { proxyBypassRules } : {}), + }; + default: { + // system — also honor HTTP(S)_PROXY so env-based setups work for Chromium + const fromEnv = envProxy(); + if (fromEnv) { + return { + mode: "fixed_servers", + proxyRules: fromEnv, + proxyBypassRules: proxyBypassRules || toNoProxy(envNoProxy() ?? ""), + }; + } + return { + mode: "system", + ...(proxyBypassRules ? { proxyBypassRules } : {}), + }; + } + } +} + +/** + * Configure Node.js fetch / http to honor proxy env vars, and sync env from settings + * when using a fixed proxy. Must run before main-process network (e.g. mod downloads). + */ +export function configureNodeProxyEnv(): void { + process.env.NODE_USE_ENV_PROXY = "1"; + + const mode = (getConfig("proxyMode") as ProxyMode | undefined) ?? "system"; + + if (mode === "direct") { + // Force no proxy for Node fetches + process.env.HTTP_PROXY = ""; + process.env.HTTPS_PROXY = ""; + process.env.http_proxy = ""; + process.env.https_proxy = ""; + process.env.NO_PROXY = "*"; + process.env.no_proxy = "*"; + console.log("[Proxy] Node fetch: direct (no proxy)"); + return; + } + + if (mode === "fixed_servers") { + const rules = (getConfig("proxyRules") ?? "").trim(); + const proxyUrl = primaryProxyFromRules(rules); + if (proxyUrl) { + process.env.HTTP_PROXY = proxyUrl; + process.env.HTTPS_PROXY = proxyUrl; + process.env.http_proxy = proxyUrl; + process.env.https_proxy = proxyUrl; + const bypass = (getConfig("proxyBypassRules") ?? "").trim(); + if (bypass) { + const noProxy = toNoProxy(bypass); + process.env.NO_PROXY = noProxy; + process.env.no_proxy = noProxy; + } + console.log(`[Proxy] Node fetch: ${proxyUrl}`); + return; + } + } + + // system / pac / auto_detect — keep existing env (HTTPS_PROXY etc.) + if (envProxy()) { + console.log(`[Proxy] Node fetch: using environment proxy (${envProxy()})`); + } else { + console.log("[Proxy] Node fetch: no HTTP(S)_PROXY in environment"); + } +} + +/** + * Apply Chromium command-line proxy switches early (before app ready). + * Session.setProxy still runs later for the authoritative config. + */ +export function applyProxyCommandLineSwitches(): void { + const mode = (getConfig("proxyMode") as ProxyMode | undefined) ?? "system"; + const proxyRules = (getConfig("proxyRules") ?? "").trim(); + const proxyBypassRules = (getConfig("proxyBypassRules") ?? "").trim(); + const proxyPacScript = (getConfig("proxyPacScript") ?? "").trim(); + + if (mode === "direct") { + app.commandLine.appendSwitch("no-proxy-server"); + return; + } + + if (mode === "auto_detect") { + app.commandLine.appendSwitch("proxy-auto-detect"); + if (proxyBypassRules) app.commandLine.appendSwitch("proxy-bypass-list", proxyBypassRules); + return; + } + + if (mode === "pac_script" && proxyPacScript) { + app.commandLine.appendSwitch("proxy-pac-url", proxyPacScript); + if (proxyBypassRules) app.commandLine.appendSwitch("proxy-bypass-list", proxyBypassRules); + return; + } + + if (mode === "fixed_servers" && proxyRules) { + app.commandLine.appendSwitch("proxy-server", proxyRules); + if (proxyBypassRules) app.commandLine.appendSwitch("proxy-bypass-list", proxyBypassRules); + return; + } + + // system — if HTTPS_PROXY is set, push it to Chromium too + const fromEnv = envProxy(); + if (fromEnv) { + app.commandLine.appendSwitch("proxy-server", fromEnv); + const bypass = proxyBypassRules || toNoProxy(envNoProxy() ?? ""); + app.commandLine.appendSwitch("proxy-bypass-list", bypass); + } +} + +/** Apply proxy to the default session once Electron is ready. */ +export async function applySessionProxy(): Promise { + const config = resolveProxyConfig(); + console.log(`[Proxy] Applying session proxy: ${JSON.stringify(config)}`); + try { + await session.defaultSession.setProxy(config); + await session.defaultSession.closeAllConnections(); + } catch (error) { + console.error("[Proxy] Failed to apply session proxy:", error); + } +} diff --git a/src/main.ts b/src/main.ts index f8493c0..49b65ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { } from "./common/config.js"; import { getPreset } from "./common/flags.js"; import { setLang } from "./common/lang.js"; +import { applyProxyCommandLineSwitches, applySessionProxy, configureNodeProxyEnv } from "./common/proxy.js"; import { setupGlobalShortcuts, startDbusService } from "./dbus.js"; // Chrome flags tracking @@ -193,6 +194,8 @@ if (!app.requestSingleInstanceLock() && getConfig("multiInstance") === false) { app.commandLine.appendSwitch("enable-transparent-visuals"); trackSwitch("enable-transparent-visuals"); checkIfConfigIsBroken(); + configureNodeProxyEnv(); + applyProxyCommandLineSwitches(); const preset = getPreset(); if (preset) { preset.switches.forEach(([key, val]) => { @@ -333,6 +336,7 @@ if (!app.requestSingleInstanceLock() && getConfig("multiInstance") === false) { void app.whenReady().then(async () => { if (isDev) console.log(JSON.stringify(getAppliedFlags())); + await applySessionProxy(); process.on("SIGINT", () => app.quit()); process.on("SIGTERM", () => app.quit()); // Patch for linux bug to ensure things are loaded before window creation (fixes transparency on some linux systems) diff --git a/src/shelter/settings/pages/SettingsPage.tsx b/src/shelter/settings/pages/SettingsPage.tsx index e5214f0..02e28b9 100644 --- a/src/shelter/settings/pages/SettingsPage.tsx +++ b/src/shelter/settings/pages/SettingsPage.tsx @@ -388,6 +388,47 @@ export function SettingsPage() { onInput={(v: string) => setConfig("scanInterval", Number(v))} /> +
+ {store.i18n["settings-category-networking"]} +
+ setConfig("proxyMode", v as Settings["proxyMode"], true)} + title={store.i18n["settings-proxyMode"]} + note={store.i18n["settings-proxyMode-desc"]} + link="https://www.electronjs.org/docs/latest/api/structures/proxy-config" + options={[ + { label: store.i18n["settings-proxyMode-system"], value: "system" }, + { label: store.i18n["settings-proxyMode-direct"], value: "direct" }, + { label: store.i18n["settings-proxyMode-fixed_servers"], value: "fixed_servers" }, + { label: store.i18n["settings-proxyMode-pac_script"], value: "pac_script" }, + { label: store.i18n["settings-proxyMode-auto_detect"], value: "auto_detect" }, + ]} + /> + + setConfig("proxyRules", v, true)} + /> + + + setConfig("proxyPacScript", v, true)} + /> + + + "} + onInput={(v: string) => setConfig("proxyBypassRules", v, true)} + /> +
{store.i18n["settings-category-debug"]}
From 550662ce0c78c254db9568077b609fbb7eab5cde Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:46:47 +0200 Subject: [PATCH 05/17] feat: redesign settings page --- assets/lang/en-US.json | 17 +- .../components/BackupSection.module.css | 9 + .../settings/components/BackupSection.tsx | 20 +- .../components/SettingsPanel.module.css | 109 ++ .../settings/components/SettingsPanel.tsx | 75 + .../settings/components/SettingsSearch.tsx | 26 + .../components/icons/SettingsPanelIcons.tsx | 111 ++ .../settings/pages/SettingsPage.module.css | 27 +- src/shelter/settings/pages/SettingsPage.tsx | 1563 ++++++++++------- 9 files changed, 1336 insertions(+), 621 deletions(-) create mode 100644 src/shelter/settings/components/SettingsPanel.module.css create mode 100644 src/shelter/settings/components/SettingsPanel.tsx create mode 100644 src/shelter/settings/components/SettingsSearch.tsx create mode 100644 src/shelter/settings/components/icons/SettingsPanelIcons.tsx diff --git a/assets/lang/en-US.json b/assets/lang/en-US.json index 825e474..8da6b8d 100644 --- a/assets/lang/en-US.json +++ b/assets/lang/en-US.json @@ -161,11 +161,26 @@ "settings-quickCss": "Quick CSS", "settings-quickCss-desc": "Quickly edit your CSS in a simple text editor. Changes are applied immediately after saving the file.", "settings-category-lookAndFeel": "Look and feel", + "settings-category-lookAndFeel-desc": "Window chrome, tray icon, splash screen, and mobile layout.", "settings-category-mods": "Mods", + "settings-category-mods-desc": "Client mods, CSP, and plugin capabilities.", "settings-category-behaviour": "Behaviour", + "settings-category-behaviour-desc": "Startup, tray, scrolling, spellcheck, and Discord channel.", "settings-category-legacy": "Legacy features", "settings-category-networking": "Networking", + "settings-category-networking-desc": "HTTP(S) and SOCKS proxy settings for Discord and downloads.", "settings-category-debug": "Debug options", + "settings-category-powerManagement": "Power Management", + "settings-category-powerManagement-desc": "Performance presets and power-saving behaviour.", + "settings-category-arrpc": "Rich Presence", + "settings-category-arrpc-desc": "arRPC game activity detection and scanning.", + "settings-category-backup": "Backup and restore", + "settings-category-backup-desc": "Export or restore settings, themes, plugins, and mod data.", + "settings-category-advanced": "Advanced", + "settings-category-advanced-desc": "Audio capture, hardware, updates, flags, and debug tools.", + "settings-search": "Search settings", + "settings-search-desc": "Filter settings by name or description.", + "settings-noResults": "No settings match your search.", "settings-proxyMode": "Proxy mode", "settings-proxyMode-desc": "Choose how Legcord connects through a proxy. System uses your OS proxy (and HTTP(S)_PROXY environment variables). Fixed servers and PAC work like in a normal web browser. A restart is required.", "settings-proxyMode-system": "System", @@ -262,8 +277,6 @@ "settings-channel-stable": "Stable", "settings-channel-canary": "Canary", "settings-channel-ptb": "PTB", - "settings-category-powerManagement": "Power Management", - "settings-category-arrpc": "arRPC", "settings-extendedPluginAbilities": "Extended plugin abilities", "settings-extendedPluginAbilities-desc": "Allows plugins to read and write files in a scoped folder on your computer (e.g. for caching deleted messages). Only enable for plugins you trust—they can store data locally. Data is stored per plugin in Legcord's plugin-storage folder.", "settings-audio-loopback": "Loopback", diff --git a/src/shelter/settings/components/BackupSection.module.css b/src/shelter/settings/components/BackupSection.module.css index 9ed0f98..d1d9530 100644 --- a/src/shelter/settings/components/BackupSection.module.css +++ b/src/shelter/settings/components/BackupSection.module.css @@ -45,3 +45,12 @@ .item { margin-bottom: 20px; } + +.embedded { + margin-bottom: 0; +} + +.embedded .actions { + margin-top: 0; + margin-bottom: 0; +} diff --git a/src/shelter/settings/components/BackupSection.tsx b/src/shelter/settings/components/BackupSection.tsx index 0e46eff..2cab53d 100644 --- a/src/shelter/settings/components/BackupSection.tsx +++ b/src/shelter/settings/components/BackupSection.tsx @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js"; +import { createSignal, Show } from "solid-js"; import classes from "./BackupSection.module.css"; const { @@ -143,7 +143,7 @@ function applyClientModsFromRestore(raw: unknown): void { } } -export function BackupSection() { +export function BackupSection(props: { embedded?: boolean }) { const t = store.i18n; function buildClientMods(includes: IncludeOptions) { @@ -248,11 +248,13 @@ export function BackupSection() { } return ( -
-
- {t["backup-pageTitle"]} -
-
{t["backup-pageSubtitle"]}
+
+ +
+ {t["backup-pageTitle"]} +
+
{t["backup-pageSubtitle"]}
+
- + + +
); } diff --git a/src/shelter/settings/components/SettingsPanel.module.css b/src/shelter/settings/components/SettingsPanel.module.css new file mode 100644 index 0000000..6dab667 --- /dev/null +++ b/src/shelter/settings/components/SettingsPanel.module.css @@ -0,0 +1,109 @@ +.container { + display: flex; + flex-direction: column; + margin-top: 16px; + border: 1px solid var(--border-subtle, var(--background-modifier-accent)); + border-radius: 8px; + background-color: var(--background-surface-higher, var(--background-secondary)); + text-align: left; +} + +.containerOpened { + margin-bottom: 16px; +} + +.header { + display: flex; + align-items: center; + width: 100%; + padding: 16px; + gap: 16px; + cursor: pointer; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.header:focus-visible { + outline: 2px solid var(--focus-primary, var(--brand-500)); + outline-offset: -2px; + border-radius: 8px; +} + +.icon { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background-color: var(--background-base-lowest, var(--background-tertiary)); + color: var(--interactive-text-default, var(--interactive-normal)); +} + +.title { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.titleText { + cursor: pointer; + margin: 0; +} + +.description { + color: var(--interactive-text-default, var(--text-muted)); + font-size: 12px; + line-height: 16px; + font-weight: 500; +} + +.caret { + margin-left: auto; + color: var(--interactive-text-default, var(--interactive-normal)); + display: flex; + align-items: center; +} + +.caretIcon { + transition: none; +} + +.section { + display: flex; + flex-direction: column; + max-height: 0; + padding-top: 0; + overflow: hidden; + gap: 8px; + border-width: 0; +} + +.sectionOpened { + max-height: none; + margin: 16px; + margin-top: 0; + padding-top: 16px; + border-top: 1px solid var(--border-subtle, var(--background-modifier-accent)); +} + +:global(.full-motion) .container { + transition: margin-bottom 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +:global(.full-motion) .caretIcon { + transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +:global(.full-motion) .section { + transition: + max-height 0.5s cubic-bezier(0.16, 1, 0.3, 1), + padding-top 0.5s cubic-bezier(0.16, 1, 0.3, 1), + margin-top 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} diff --git a/src/shelter/settings/components/SettingsPanel.tsx b/src/shelter/settings/components/SettingsPanel.tsx new file mode 100644 index 0000000..3f70440 --- /dev/null +++ b/src/shelter/settings/components/SettingsPanel.tsx @@ -0,0 +1,75 @@ +import type { Component, JSX } from "solid-js"; +import { createSignal, Show } from "solid-js"; +import classes from "./SettingsPanel.module.css"; + +const { + ui: { Header, HeaderTags }, +} = shelter; + +export type SettingsPanelProps = { + title: string; + description?: string; + icon: Component; + children: JSX.Element; + /** Controlled open state (e.g. while searching). */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; + /** Hide the panel entirely (e.g. no search matches). */ + hidden?: boolean; +}; + +const CaretIcon = (props: { open: boolean }) => ( + +); + +export function SettingsPanel(props: SettingsPanelProps) { + const [internalOpen, setInternalOpen] = createSignal(props.defaultOpen ?? false); + + const isControlled = () => props.open !== undefined; + const isOpen = () => (isControlled() ? Boolean(props.open) : internalOpen()); + + function toggle() { + const next = !isOpen(); + if (!isControlled()) setInternalOpen(next); + props.onOpenChange?.(next); + } + + return ( + +
+ +
{props.children}
+
+
+ ); +} diff --git a/src/shelter/settings/components/SettingsSearch.tsx b/src/shelter/settings/components/SettingsSearch.tsx new file mode 100644 index 0000000..dcf9172 --- /dev/null +++ b/src/shelter/settings/components/SettingsSearch.tsx @@ -0,0 +1,26 @@ +import type { Accessor, JSX, ParentProps } from "solid-js"; +import { createContext, Show, useContext } from "solid-js"; + +const SearchQueryContext = createContext>(() => ""); + +export function SettingsSearchProvider(props: ParentProps<{ query: Accessor }>) { + return {props.children}; +} + +export function useSettingsSearchQuery(): Accessor { + return useContext(SearchQueryContext); +} + +export function matchesSettingsQuery(keywords: Array, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return keywords.some((keyword) => { + if (!keyword) return false; + return keyword.toLowerCase().includes(q); + }); +} + +export function SearchableSetting(props: { keywords: Array; children: JSX.Element }) { + const query = useSettingsSearchQuery(); + return {props.children}; +} diff --git a/src/shelter/settings/components/icons/SettingsPanelIcons.tsx b/src/shelter/settings/components/icons/SettingsPanelIcons.tsx new file mode 100644 index 0000000..e9c1065 --- /dev/null +++ b/src/shelter/settings/components/icons/SettingsPanelIcons.tsx @@ -0,0 +1,111 @@ +/** + * Category icons for settings panels. + * Filled 24×24 paths match Discord’s design-system icons from + * https://docs.discord.com/developers/intro (Bots, Activities, Social Layer, etc.). + */ +import type { JSX } from "solid-js"; + +const ICON_SIZE = 20; + +const PanelIcon = (props: { children: JSX.Element; viewBox?: string }) => ( + +); + +/** Discord docs — download / archive style (Backup). */ +export const BackupPanelIcon = () => ( + + + +); + +/** Discord docs — Bots & Apps robot. */ +export const ModsPanelIcon = () => ( + + + + +); + +/** Discord docs — Activities (appearance / creative shapes). */ +export const LookFeelPanelIcon = () => ( + + + +); + +/** Discord docs — Help Center (behaviour / how the app acts). */ +export const BehaviourPanelIcon = () => ( + + + +); + +/** Discord docs — Build Your First App rocket (power / performance). */ +export const PowerPanelIcon = () => ( + + + +); + +/** Discord docs — Social Layer / Clyde (rich presence). */ +export const RpcPanelIcon = () => ( + + + +); + +/** Discord-style filled globe (networking / proxy). */ +export const NetworkPanelIcon = () => ( + + + +); + +/** Discord docs — Report Issues bug (advanced / debug). */ +export const AdvancedPanelIcon = () => ( + + + +); diff --git a/src/shelter/settings/pages/SettingsPage.module.css b/src/shelter/settings/pages/SettingsPage.module.css index 847dd7d..51b43d9 100644 --- a/src/shelter/settings/pages/SettingsPage.module.css +++ b/src/shelter/settings/pages/SettingsPage.module.css @@ -1,3 +1,28 @@ -.category { +.search { + margin: 8px 0 4px; +} + +.searchHint { + margin: 0 0 8px; + color: var(--text-muted, var(--text-subtle)); + font-size: 12px; + line-height: 16px; +} + +.noResults { + margin: 24px 0; + text-align: center; + color: var(--text-muted, var(--text-subtle)); + font-size: 14px; +} + +.panelActions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.crashTitle { margin-bottom: 8px; } diff --git a/src/shelter/settings/pages/SettingsPage.tsx b/src/shelter/settings/pages/SettingsPage.tsx index 02e28b9..4f56f18 100644 --- a/src/shelter/settings/pages/SettingsPage.tsx +++ b/src/shelter/settings/pages/SettingsPage.tsx @@ -1,7 +1,19 @@ -import { Show } from "solid-js"; +import { createMemo, createSignal, Show } from "solid-js"; import type { Settings } from "../../../@types/settings.js"; import { BackupSection } from "../components/BackupSection.jsx"; import { DropdownItem } from "../components/DropdownItem.jsx"; +import { + AdvancedPanelIcon, + BackupPanelIcon, + BehaviourPanelIcon, + LookFeelPanelIcon, + ModsPanelIcon, + NetworkPanelIcon, + PowerPanelIcon, + RpcPanelIcon, +} from "../components/icons/SettingsPanelIcons.jsx"; +import { SettingsPanel } from "../components/SettingsPanel.jsx"; +import { matchesSettingsQuery, SearchableSetting, SettingsSearchProvider } from "../components/SettingsSearch.jsx"; import { SupportBanner } from "../components/SupportBanner.jsx"; import { TextBoxItem } from "../components/TextBoxItem.jsx"; import { isMinWindowsVersion, setConfig, toggleMod } from "../settings.js"; @@ -9,7 +21,7 @@ import classes from "./SettingsPage.module.css"; const { plugin: { store }, - ui: { SwitchItem, Header, HeaderTags, Button, ButtonSizes }, + ui: { SwitchItem, Header, HeaderTags, Button, ButtonSizes, TextBox }, } = shelter; const noBundleUpdates = (settings: Settings) => { @@ -18,16 +30,23 @@ const noBundleUpdates = (settings: Settings) => { return value ? ["shelter", "vencord", "equicord", "custom"] : []; }; +type PanelId = "backup" | "mods" | "lookAndFeel" | "behaviour" | "power" | "rpc" | "networking" | "advanced"; + export function SettingsPage() { + const [query, setQuery] = createSignal(""); + const [openPanels, setOpenPanels] = createSignal>>({}); + const settings = store.settings as Settings; + const t = store.i18n; + if (!settings) { return ( <> -
- {store.i18n?.["settings-firstTimeCrash"] ?? "Setting things up..."} +
+ {t?.["settings-firstTimeCrash"] ?? "Setting things up..."}

- {store.i18n?.["settings-firstTimeCrash-desc"] ?? + {t?.["settings-firstTimeCrash-desc"] ?? "Settings are not available on a first-time launch. Please restart."}


@@ -37,625 +56,949 @@ export function SettingsPage() { ); } + + const panelKeywords: Record> = { + backup: [ + t["settings-category-backup"], + t["settings-category-backup-desc"], + t["backup-pageTitle"], + t["backup-pageSubtitle"], + t["backup-createBackup"], + t["backup-restore"], + ], + mods: [ + t["settings-category-mods"], + t["settings-category-mods-desc"], + t["settings-csp"], + t["settings-csp-desc"], + "Vencord", + "Equicord", + t["settings-mod-vencord"], + t["settings-mod-equicord"], + t["settings-extendedPluginAbilities"], + t["settings-extendedPluginAbilities-desc"], + ], + lookAndFeel: [ + t["settings-category-lookAndFeel"], + t["settings-category-lookAndFeel-desc"], + t["settings-theme"], + t["settings-theme-desc"], + t["settings-autoHideMenuBar"], + t["settings-transparency"], + t["settings-material"], + t["settings-bounceOnPing"], + t["settings-trayIcon"], + t["settings-skipSplash"], + t["settings-mobileMode"], + ], + behaviour: [ + t["settings-category-behaviour"], + t["settings-category-behaviour-desc"], + t["settings-channel"], + t["settings-MultiInstance"], + t["settings-popoutPiP"], + t["settings-useSystemCssEditor"], + t["settings-useMacSystemPicker"], + t["settings-disableAutogain"], + t["settings-mintoTray"], + t["settings-startMinimized"], + t["settings-smoothScroll"], + t["settings-autoScroll"], + t["settings-spellcheck"], + ], + power: [ + t["settings-category-powerManagement"], + t["settings-category-powerManagement-desc"], + t["settings-prfmMode"], + t["settings-prfmMode-desc"], + t["settings-blockPowerSavingInVoiceChat"], + t["settings-sleepInBackground"], + ], + rpc: [ + t["settings-category-arrpc"], + t["settings-category-arrpc-desc"], + t["settings-invitewebsocket"], + t["settings-processScanning"], + t["settings-windowsLegacyScanning"], + t["settings-scanInterval"], + "arRPC", + "Rich Presence", + ], + networking: [ + t["settings-category-networking"], + t["settings-category-networking-desc"], + t["settings-proxyMode"], + t["settings-proxyRules"], + t["settings-proxyPacScript"], + t["settings-proxyBypassRules"], + "proxy", + "SOCKS", + ], + advanced: [ + t["settings-category-advanced"], + t["settings-category-advanced-desc"], + t["settings-showExperimentalPluginMenu"], + t["settings-venmic-deviceSelect"], + t["settings-venmic-granularSelect"], + t["settings-venmic-workaround"], + t["settings-audio"], + t["settings-hardwareAcceleration"], + t["settings-vaapi"], + t["settings-automaticClientUpdates"], + t["settings-disableHttpCache"], + t["settings-additionalArguments"], + t["settings-noBundleUpdates"], + t["settings-openCustomIconDialog"], + t["settings-storageFolder"], + t["settings-copyDebugInfo"], + t["settings-copyGPUInfo"], + t["settings-clearClientModCache"], + "venmic", + "VAAPI", + ], + }; + + const isSearching = createMemo(() => query().trim().length > 0); + + const panelOpen = (id: PanelId) => { + if (isSearching()) return matchesSettingsQuery(panelKeywords[id], query()); + return openPanels()[id] ?? false; + }; + + const panelHidden = (id: PanelId) => { + if (!isSearching()) return false; + return !matchesSettingsQuery(panelKeywords[id], query()); + }; + + const anyPanelVisible = createMemo(() => + (Object.keys(panelKeywords) as PanelId[]).some((id) => matchesSettingsQuery(panelKeywords[id], query())), + ); + + const onPanelOpenChange = (id: PanelId, open: boolean) => { + if (isSearching()) return; + setOpenPanels((prev) => ({ ...prev, [id]: open })); + }; + return ( - <> + - -
- {store.i18n["settings-category-mods"]} -
- setConfig("csp", v as Settings["csp"], true)} - title={store.i18n["settings-csp"]} - note={store.i18n["settings-csp-desc"]} - link="https://github.com/Legcord/Legcord/wiki/CSP-Options" - options={[ - { label: store.i18n["settings-csp-none"], value: "none" }, - { label: store.i18n["settings-csp-strict"], value: "strict" }, - { label: store.i18n["settings-csp-vanilla"], value: "vanilla" }, - ]} - /> - toggleMod("vencord", e)} - > - Vencord - - toggleMod("equicord", e)} - > - Equicord - - setConfig("extendedPluginAbilities", e)} - > - {store.i18n["settings-extendedPluginAbilities"]} - - setConfig("windowStyle", v as Settings["windowStyle"], true)} - title={store.i18n["settings-theme"]} - note={store.i18n["settings-theme-desc"]} - link="https://github.com/Legcord/Legcord/wiki/Settings-%5Bwip%5D#legcord-theme" - options={[ - { label: store.i18n["settings-theme-default"], value: "default" }, - { label: store.i18n["settings-theme-native"], value: "native" }, - { label: store.i18n["settings-theme-overlay"], value: "overlay" }, - { label: store.i18n["settings-theme-legacy"], value: "legacy" }, - ]} - /> - - setConfig("autoHideMenuBar", e, true)} - > - {store.i18n["settings-autoHideMenuBar"]} - +
+ setQuery(v)} placeholder={t["settings-search"]} /> +
+ + +

{t["settings-noResults"]}

- setConfig("transparency", v as Settings["transparency"], true)} - title={store.i18n["settings-transparency"]} - note={store.i18n["settings-transparency-desc"]} - link="https://github.com/Legcord/Legcord/wiki/Transparency-options" - extraItems={ - = 25 - } + + + + - } - options={[ - { - label: store.i18n["settings-transparency-universal"], - value: "universal", - }, - ...(window.legcord.platform === "win32" || window.legcord.platform === "darwin" - ? [ - { - label: store.i18n["settings-transparency-modern"], - value: "modern", - }, - ] - : []), - { label: store.i18n["settings-none"], value: "none" }, - ]} - /> - - setConfig("windowMaterial", v as Settings["windowMaterial"], true)} - title={store.i18n["settings-material"]} - note={store.i18n["settings-material-desc"]} - link="https://github.com/Legcord/Legcord/wiki/Settings-%5Bwip%5D#legcord-theme" - options={[ - { label: store.i18n["settings-material-mica"], value: "mica" }, - { label: store.i18n["settings-material-mica-alt"], value: "tabbed" }, - { label: store.i18n["settings-material-acrylic"], value: "acrylic" }, - { label: store.i18n["settings-material-none"], value: "none" }, - ]} - /> - - - setConfig("bounceOnPing", e, true)} - > - {store.i18n["settings-bounceOnPing"]} - - - setConfig("tray", v as Settings["tray"], true)} - title={store.i18n["settings-trayIcon"]} - note={store.i18n["settings-trayIcon-desc"]} - options={[ - { label: store.i18n["settings-trayIcon-dynamic"], value: "dynamic" }, - { - label: store.i18n["settings-trayIcon-disabled"], - value: "disabled", - }, - { label: store.i18n["settings-trayIcon-normal"], value: "dsc-tray" }, - { - label: store.i18n["settings-trayIcon-classic"], - value: "clsc-dsc-tray", - }, - { - label: store.i18n["settings-trayIcon-colored-plug"], - value: "ac_plug_colored", - }, - { - label: store.i18n["settings-trayIcon-white-plug"], - value: "ac_white_plug", - }, - { - label: store.i18n["settings-trayIcon-white-plug-alt"], - value: "ac_white_plug_hollow", - }, - { - label: store.i18n["settings-trayIcon-black-plug"], - value: "ac_black_plug", - }, - { - label: store.i18n["settings-trayIcon-black-plug-alt"], - value: "ac_black_plug_hollow", - }, - ]} - /> - setConfig("skipSplash", e)} - > - {store.i18n["settings-skipSplash"]} - - setConfig("mobileMode", e, true)} - > - {store.i18n["settings-mobileMode"]} - -
- Behaviour -
- setConfig("channel", v as Settings["channel"], true)} - title={store.i18n["settings-channel"]} - note={store.i18n["settings-channel-desc"]} - link="https://support.discord.com/hc/en-us/articles/360035675191-Discord-Testing-Clients" - options={[ - { label: store.i18n["settings-channel-stable"], value: "stable" }, - { label: store.i18n["settings-channel-canary"], value: "canary" }, - { label: store.i18n["settings-channel-ptb"], value: "ptb" }, - ]} - /> - setConfig("multiInstance", e)} - > - {store.i18n["settings-MultiInstance"]} - - setConfig("popoutPiP", e)} - > - {store.i18n["settings-popoutPiP"]} - - setConfig("useSystemCssEditor", e)} - > - {store.i18n["settings-useSystemCssEditor"]} - - - setConfig("useMacSystemPicker", e)} - > - {store.i18n["settings-useMacSystemPicker"]} - - - setConfig("disableAutogain", e)} - > - {store.i18n["settings-disableAutogain"]} - - setConfig("minimizeToTray", e)} - > - {store.i18n["settings-mintoTray"]} - - setConfig("startMinimized", e)} - > - {store.i18n["settings-startMinimized"]} - - setConfig("smoothScroll", e, true)} - > - {store.i18n["settings-smoothScroll"]} - - setConfig("autoScroll", e, true)} - > - {store.i18n["settings-autoScroll"]} - - setConfig("spellcheck", e, true)} - > - {store.i18n["settings-spellcheck"]} - -
- {store.i18n["settings-category-powerManagement"]} -
- setConfig("performanceMode", v as Settings["performanceMode"], true)} - title={store.i18n["settings-prfmMode"]} - note={store.i18n["settings-prfmMode-desc"]} - link="https://github.com/Legcord/Legcord/blob/dev/src/common/flags.ts" - options={[ - { label: store.i18n["settings-prfmMode-dynamic"], value: "dynamic" }, - { - label: store.i18n["settings-prfmMode-performance"], - value: "performance", - }, - { label: store.i18n["settings-prfmMode-balanced"], value: "balanced" }, - { label: store.i18n["settings-prfmMode-latency"], value: "latency" }, - { - label: store.i18n["settings-prfmMode-smoothScreenshare"], - value: "smoothScreenshare", - }, - { label: store.i18n["settings-prfmMode-voip"], value: "voip" }, - { label: store.i18n["settings-prfmMode-battery"], value: "battery" }, - { label: store.i18n["settings-prfmMode-memory"], value: "memory" }, - { label: store.i18n["settings-none"], value: "none" }, - ]} - /> - setConfig("blockPowerSavingInVoiceChat", e, true)} - > - {store.i18n["settings-blockPowerSavingInVoiceChat"]} - - setConfig("sleepInBackground", e, true)} - > - {store.i18n["settings-sleepInBackground"]} - -
- {store.i18n["settings-category-arrpc"]} -
- setConfig("inviteWebsocket", e, true)} - > - {store.i18n["settings-invitewebsocket"]} - - - setConfig("processScanning", e, true)} - > - {store.i18n["settings-processScanning"]} - - - setConfig("windowsLegacyScanning", e, true)} + {t["settings-venmic-deviceSelect"]} + + + - {store.i18n["settings-windowsLegacyScanning"]} - + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.granularSelect = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-granularSelect"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.workaround = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-workaround"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.ignoreVirtual = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-ignoreVirtual"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.ignoreDevices = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-ignoreDevices"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.ignoreInputMedia = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-ignoreInputMedia"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.onlySpeakers = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-onlySpeakers"]} + + + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.onlyDefaultSpeakers = e; + setConfig("audio", audioSettings); + }} + > + {t["settings-venmic-onlyDefaultSpeakers"]} + + - setConfig("scanInterval", Number(v))} - /> - -
- {store.i18n["settings-category-networking"]} -
- setConfig("proxyMode", v as Settings["proxyMode"], true)} - title={store.i18n["settings-proxyMode"]} - note={store.i18n["settings-proxyMode-desc"]} - link="https://www.electronjs.org/docs/latest/api/structures/proxy-config" - options={[ - { label: store.i18n["settings-proxyMode-system"], value: "system" }, - { label: store.i18n["settings-proxyMode-direct"], value: "direct" }, - { label: store.i18n["settings-proxyMode-fixed_servers"], value: "fixed_servers" }, - { label: store.i18n["settings-proxyMode-pac_script"], value: "pac_script" }, - { label: store.i18n["settings-proxyMode-auto_detect"], value: "auto_detect" }, - ]} - /> - - setConfig("proxyRules", v, true)} - /> - - - setConfig("proxyPacScript", v, true)} - /> - - - "} - onInput={(v: string) => setConfig("proxyBypassRules", v, true)} - /> - -
- {store.i18n["settings-category-debug"]} -
- setConfig("showExperimentalPluginMenu", e, true)} - > - {store.i18n["settings-showExperimentalPluginMenu"]} - - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.deviceSelect = e; - setConfig("audio", audioSettings); - }} + + { + const audioSettings = structuredClone({ ...settings.audio }); + audioSettings.loopbackType = v as Settings["audio"]["loopbackType"]; + setConfig("audio", audioSettings); + }} + title={t["settings-audio"]} + note={t["settings-audio-desc"]} + link="https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts" + options={[ + { label: t["settings-audio-loopback"], value: "loopback" }, + { label: t["settings-audio-loopbackWithMute"], value: "loopbackWithMute" }, + ]} + /> + + - {store.i18n["settings-venmic-deviceSelect"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.granularSelect = e; - setConfig("audio", audioSettings); - }} + setConfig("hardwareAcceleration", e, true)} + > + {t["settings-hardwareAcceleration"]} + + + + + setConfig("vaapi", e, true)} + > + {t["settings-vaapi"]} + + + + - {store.i18n["settings-venmic-granularSelect"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.workaround = e; - setConfig("audio", audioSettings); - }} + setConfig("automaticUpdates", e, true)} + > + {t["settings-automaticClientUpdates"]} + + + + setConfig("disableHttpCache", e, true)} + > + {t["settings-disableHttpCache"]} + + + - {store.i18n["settings-venmic-workaround"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.ignoreVirtual = e; - setConfig("audio", audioSettings); - }} + setConfig("additionalArguments", v)} + /> + + - {store.i18n["settings-venmic-ignoreVirtual"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.ignoreDevices = e; - setConfig("audio", audioSettings); - }} - > - {store.i18n["settings-venmic-ignoreDevices"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.ignoreInputMedia = e; - setConfig("audio", audioSettings); - }} - > - {store.i18n["settings-venmic-ignoreInputMedia"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.onlySpeakers = e; - setConfig("audio", audioSettings); - }} - > - {store.i18n["settings-venmic-onlySpeakers"]} - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.onlyDefaultSpeakers = e; - setConfig("audio", audioSettings); - }} - > - {store.i18n["settings-venmic-onlyDefaultSpeakers"]} - - - { - const audioSettings = structuredClone({ ...settings.audio }); - audioSettings.loopbackType = v as Settings["audio"]["loopbackType"]; - setConfig("audio", audioSettings); - }} - title={store.i18n["settings-audio"]} - note={store.i18n["settings-audio-desc"]} - link="https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts" - options={[ - { label: store.i18n["settings-audio-loopback"], value: "loopback" }, - { - label: store.i18n["settings-audio-loopbackWithMute"], - value: "loopbackWithMute", - }, - ]} - /> - setConfig("hardwareAcceleration", e, true)} - > - {store.i18n["settings-hardwareAcceleration"]} - - - setConfig("vaapi", e, true)} - > - {store.i18n["settings-vaapi"]} - - - setConfig("automaticUpdates", e, true)} - > - {store.i18n["settings-automaticClientUpdates"]} - - setConfig("disableHttpCache", e, true)} - > - {store.i18n["settings-disableHttpCache"]} - - setConfig("additionalArguments", v)} - /> -
- {store.i18n["settings-noBundleUpdates"]} -
- { - const next = new Set(noBundleUpdates(settings)); - if (e) next.add("shelter"); - else next.delete("shelter"); - setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); - }} - > - {store.i18n["settings-mod-shelter"]} - - - { - const next = new Set(noBundleUpdates(settings)); - if (e) next.add("vencord"); - else next.delete("vencord"); - setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); - }} - > - Vencord - - - - { - const next = new Set(noBundleUpdates(settings)); - if (e) next.add("equicord"); - else next.delete("equicord"); - setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); - }} - > - Equicord - - - - { - const next = new Set(noBundleUpdates(settings)); - if (e) next.add("custom"); - else next.delete("custom"); - setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); - }} - > - {store.i18n["settings-mod-custom"]} - - - -
- -
- -
- -
- - + { + const next = new Set(noBundleUpdates(settings)); + if (e) next.add("shelter"); + else next.delete("shelter"); + setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); + }} + > + {t["settings-mod-shelter"]} + + + + + { + const next = new Set(noBundleUpdates(settings)); + if (e) next.add("vencord"); + else next.delete("vencord"); + setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); + }} + > + Vencord + + + + + + { + const next = new Set(noBundleUpdates(settings)); + if (e) next.add("equicord"); + else next.delete("equicord"); + setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); + }} + > + Equicord + + + + + + { + const next = new Set(noBundleUpdates(settings)); + if (e) next.add("custom"); + else next.delete("custom"); + setConfig("noBundleUpdates", Array.from(next) as Settings["noBundleUpdates"], true); + }} + > + {t["settings-mod-custom"]} + + + +
+ + + + + + + + + + + + + + + +
+ +
); } From 5d72d180b1c87b42f10bc124a8d3aaacc8186303 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:01:46 +0200 Subject: [PATCH 06/17] feat: revise the rest of settings pages --- assets/lang/en-US.json | 38 +++++++++- .../components/AddDetectableModal.module.css | 6 ++ .../components/AddDetectableModal.tsx | 20 ++++-- .../settings/components/EmptyState.module.css | 27 +++++++ .../settings/components/EmptyState.tsx | 38 ++++++++++ .../settings/components/PluginCard.tsx | 13 ++-- .../components/SettingsPageHeader.module.css | 10 +++ .../components/SettingsPageHeader.tsx | 28 ++++++++ .../settings/components/ThemesCard.tsx | 20 ++++-- .../settings/pages/KeybindsPage.module.css | 12 ++++ src/shelter/settings/pages/KeybindsPage.tsx | 45 +++++++++--- .../settings/pages/PluginsPage.module.css | 6 ++ src/shelter/settings/pages/PluginsPage.tsx | 72 ++++++++++++------- .../settings/pages/RegisteredGamesPage.tsx | 23 +++--- src/shelter/settings/pages/ThemesPage.tsx | 34 +++++++-- .../settings/pages/ThemesPages.module.css | 5 ++ 16 files changed, 326 insertions(+), 71 deletions(-) create mode 100644 src/shelter/settings/components/AddDetectableModal.module.css create mode 100644 src/shelter/settings/components/EmptyState.module.css create mode 100644 src/shelter/settings/components/EmptyState.tsx create mode 100644 src/shelter/settings/components/SettingsPageHeader.module.css create mode 100644 src/shelter/settings/components/SettingsPageHeader.tsx create mode 100644 src/shelter/settings/pages/KeybindsPage.module.css create mode 100644 src/shelter/settings/pages/PluginsPage.module.css diff --git a/assets/lang/en-US.json b/assets/lang/en-US.json index 8da6b8d..057e068 100644 --- a/assets/lang/en-US.json +++ b/assets/lang/en-US.json @@ -308,12 +308,21 @@ "detectable-appName": "App Name*", "detectable-appId": "App ID*", "detectable-themes": "Themes", + "detectable-themes-note": "Optional genre tags for this game, comma-separated.", "detectable-aliases": "Aliases", + "detectable-aliases-note": "Optional alternate names Discord may match, comma-separated.", + "detectable-appId-note": "Discord application ID from the Developer Portal for this game’s rich presence.", "detectable-enabled": "Enabled", "detectable-placeholderName": "e.g. Discord", "detectable-placeholderId": "e.g. 1234567890", "detectable-placeholderThemes": "Action, Adventure", "detectable-placeholderAliases": "Alias1, Alias2", + "themes-pageTitle": "Themes", + "themes-pageDesc": "Install BetterDiscord themes or write Quick CSS to customize Discord’s look. Themes are .theme.css files; Quick CSS is a single stylesheet you can edit anytime.", + "themes-empty": "No themes installed yet.", + "themes-emptyDesc": "Import a .theme.css file or paste a raw theme URL below. You can also browse community themes online.", + "themes-browseThemes": "Browse themes on BetterDiscord", + "themes-importUrlNote": "Paste a direct link to a raw .theme.css file (for example from GitHub).", "themes-success": "Success!", "themes-updated": "Theme successfully updated!", "themes-bdInstalled": "BD theme successfully installed!", @@ -326,23 +335,48 @@ "themes-importFromFile": "Import from file", "themes-openThemesFolder": "Open themes folder", "themes-import": "Import", + "themes-refresh": "Refresh", "themes-importUrlPlaceholder": "https://raw.githubusercontent.com/... [.theme.css]", "themes-installed": "Installed Themes", + "keybinds-pageTitle": "Keybinds", + "keybinds-pageDesc": "Create keyboard shortcuts for Legcord actions like mute, deafen, or opening Quick CSS. Enable Global on a keybind to use it even when Discord is in the background.", + "keybinds-empty": "No keybinds added yet.", + "keybinds-emptyDesc": "Start by adding one with the button above.", "games-registeredGames": "Registered Games", + "games-pageDesc": "Manage custom games for Rich Presence (arRPC). Pick a running process, then set its Discord application ID. Changes usually need a restart to apply.", "games-refreshList": "Refresh list", "games-add": "Add", "games-remove": "Remove", "games-removeConfirmHeader": "Remove game?", "games-removeConfirmBody": "This game will be removed from rich presence. The client must be restarted for changes to take effect.", "games-noExecutables": "No executables", - "games-empty": "No registered games. Add one using the dropdown above.", + "games-empty": "No registered games yet.", + "games-emptyDesc": "Choose a process from the dropdown above, then click Add to register it for rich presence.", "games-lastDetected": "Last detected games", - "games-lastDetectedEmpty": "No games detected yet. Start a game with rich presence to see them here.", + "games-lastDetectedEmpty": "No games detected yet.", + "games-lastDetectedEmptyDesc": "Start a game with rich presence while Legcord is running to see it listed here.", "games-blacklist": "Blacklist", "games-blacklisted": "Blacklisted games", "games-blacklistedEmpty": "No blacklisted games.", + "games-blacklistedEmptyDesc": "Games you blacklist won’t show up in last detected.", "games-removeFromBlacklist": "Remove", "games-application": "Application", + "plugins-pageTitle": "Plugins", + "plugins-pageDesc": "Experimental filesystem plugins loaded from your Legcord plugins folder. Each plugin can run code in the main process, preload, and/or renderer. Only enable plugins you trust.", + "plugins-empty": "No runtime plugins found.", + "plugins-emptyDesc": "Open the plugins folder and add a plugin directory with a manifest.json, then refresh this list.", + "plugins-refresh": "Refresh list", + "plugins-openFolder": "Open plugins folder", + "plugins-reload": "Reload", + "plugins-targets": "Targets", + "plugins-targetsNone": "none", + "plugins-incompatible": "Plugin is not compatible.", + "plugins-toastTitle": "Plugins", + "plugins-toastEnableFailed": "Failed to enable {name}.", + "plugins-toastDisableFailed": "Failed to disable {name}.", + "plugins-toastReloaded": "Reloaded {name}.", + "plugins-toastReloadFailed": "Failed to reload {name}.", + "plugins-toastIncompatible": "{name} is not compatible with this Legcord version.", "screenshare-selectSource": "Please select a source", "screenshare-venmicDisabled": "Venmic disabled", "screenshare-share": "Share", diff --git a/src/shelter/settings/components/AddDetectableModal.module.css b/src/shelter/settings/components/AddDetectableModal.module.css new file mode 100644 index 0000000..97722fe --- /dev/null +++ b/src/shelter/settings/components/AddDetectableModal.module.css @@ -0,0 +1,6 @@ +.fieldNote { + display: block; + margin: 4px 0 8px; + color: var(--text-muted, var(--text-subtle)); + line-height: 1.35; +} diff --git a/src/shelter/settings/components/AddDetectableModal.tsx b/src/shelter/settings/components/AddDetectableModal.tsx index d525976..8be495c 100644 --- a/src/shelter/settings/components/AddDetectableModal.tsx +++ b/src/shelter/settings/components/AddDetectableModal.tsx @@ -1,6 +1,7 @@ import type { Game } from "arrpc"; import { createSignal } from "solid-js"; import { setRestartRequired } from "../settings.js"; +import classes from "./AddDetectableModal.module.css"; const { ui: { @@ -16,6 +17,8 @@ const { SwitchItem, showToast, openConfirmationModal, + Text, + TextTags, }, plugin: { store }, } = shelter; @@ -88,16 +91,25 @@ export const AddDetectableModal = (props: { close: () => void; executable: strin {t["detectable-addApp"]} -
{t["detectable-appName"]}
+
{t["detectable-appName"]}
-
{t["detectable-appId"]}
+
{t["detectable-appId"]}
+ + {t["detectable-appId-note"]} + -
{t["detectable-themes"]}
+
{t["detectable-themes"]}
+ + {t["detectable-themes-note"]} + -
{t["detectable-aliases"]}
+
{t["detectable-aliases"]}
+ + {t["detectable-aliases-note"]} + diff --git a/src/shelter/settings/components/EmptyState.module.css b/src/shelter/settings/components/EmptyState.module.css new file mode 100644 index 0000000..449bc09 --- /dev/null +++ b/src/shelter/settings/components/EmptyState.module.css @@ -0,0 +1,27 @@ +.empty { + margin: 1rem 0 1.5rem; + padding: 1rem; + border: 1px solid var(--border-subtle, var(--background-modifier-accent)); + border-radius: 8px; + background: var(--background-secondary, transparent); +} + +.message { + margin: 0; + color: var(--header-primary, var(--text-normal)); +} + +.description { + margin-top: 6px; + color: var(--text-muted, var(--text-subtle)); + line-height: 1.35; + max-width: 36rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 12px; +} diff --git a/src/shelter/settings/components/EmptyState.tsx b/src/shelter/settings/components/EmptyState.tsx new file mode 100644 index 0000000..cc26498 --- /dev/null +++ b/src/shelter/settings/components/EmptyState.tsx @@ -0,0 +1,38 @@ +import { Show } from "solid-js"; +import classes from "./EmptyState.module.css"; + +const { + ui: { Header, HeaderTags, Text, TextTags, Button, ButtonSizes, LinkButton }, +} = shelter; + +export function EmptyState(props: { + message: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + linkHref?: string; + linkLabel?: string; +}) { + return ( +
+
+ {props.message} +
+ + + {props.description} + + +
+ + + + + {props.linkLabel} + +
+
+ ); +} diff --git a/src/shelter/settings/components/PluginCard.tsx b/src/shelter/settings/components/PluginCard.tsx index 4fb3dd7..ef76aba 100644 --- a/src/shelter/settings/components/PluginCard.tsx +++ b/src/shelter/settings/components/PluginCard.tsx @@ -3,6 +3,7 @@ import classes from "./PluginCard.module.css"; const { ui: { Header, HeaderTags, Switch, Button, ButtonSizes }, + plugin: { store }, } = shelter; export function PluginCard(props: { @@ -11,6 +12,7 @@ export function PluginCard(props: { onToggle: (enabled: boolean) => void; onReload: () => void; }) { + const t = store.i18n; const targets = [ props.plugin.hasMain ? "main" : null, props.plugin.hasPreload ? "preload" : null, @@ -23,7 +25,7 @@ export function PluginCard(props: {
-
+
{props.plugin.name}
@@ -33,10 +35,12 @@ export function PluginCard(props: { {props.plugin.author ? ` • ${props.plugin.author}` : ""}
{props.plugin.description ?
{props.plugin.description}
: null} -
Targets: {targets || "none"}
+
+ {t["plugins-targets"]}: {targets || t["plugins-targetsNone"]} +
{!props.plugin.compatible ? (
- {props.plugin.compatibilityMessage ?? "Plugin is not compatible."} + {props.plugin.compatibilityMessage ?? t["plugins-incompatible"]}
) : null}
@@ -45,8 +49,9 @@ export function PluginCard(props: { size={ButtonSizes.SMALL} onClick={props.onReload} disabled={props.busy || !props.plugin.enabled} + tooltip={t["plugins-reload"]} > - Reload + {t["plugins-reload"]}
diff --git a/src/shelter/settings/components/SettingsPageHeader.module.css b/src/shelter/settings/components/SettingsPageHeader.module.css new file mode 100644 index 0000000..ec8b0d5 --- /dev/null +++ b/src/shelter/settings/components/SettingsPageHeader.module.css @@ -0,0 +1,10 @@ +.header { + margin-bottom: 4px; +} + +.description { + margin-top: 4px; + color: var(--text-muted, var(--text-subtle)); + max-width: 42rem; + line-height: 1.35; +} diff --git a/src/shelter/settings/components/SettingsPageHeader.tsx b/src/shelter/settings/components/SettingsPageHeader.tsx new file mode 100644 index 0000000..3293a5e --- /dev/null +++ b/src/shelter/settings/components/SettingsPageHeader.tsx @@ -0,0 +1,28 @@ +import type { JSX } from "solid-js"; +import { Show } from "solid-js"; +import classes from "./SettingsPageHeader.module.css"; + +const { + ui: { Header, HeaderTags, Text, TextTags, Divider }, +} = shelter; + +export function SettingsPageHeader(props: { + title: string; + description: string; + children?: JSX.Element; + /** Render a Divider under the header block (default true). */ + divider?: boolean; +}) { + return ( +
+
{props.title}
+ + {props.description} + + {props.children} + + + +
+ ); +} diff --git a/src/shelter/settings/components/ThemesCard.tsx b/src/shelter/settings/components/ThemesCard.tsx index 862062b..a6db8fe 100644 --- a/src/shelter/settings/components/ThemesCard.tsx +++ b/src/shelter/settings/components/ThemesCard.tsx @@ -52,7 +52,7 @@ export const ThemesCard = (props: { theme: ThemeManifest }) => {
-
+
{props.theme.name}
@@ -66,18 +66,23 @@ export const ThemesCard = (props: { theme: ThemeManifest }) => {
- - -
diff --git a/src/shelter/settings/pages/KeybindsPage.module.css b/src/shelter/settings/pages/KeybindsPage.module.css new file mode 100644 index 0000000..3443c2d --- /dev/null +++ b/src/shelter/settings/pages/KeybindsPage.module.css @@ -0,0 +1,12 @@ +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.tip { + margin-top: 8px; + color: var(--text-muted, var(--text-subtle)); + max-width: 42rem; + line-height: 1.35; +} diff --git a/src/shelter/settings/pages/KeybindsPage.tsx b/src/shelter/settings/pages/KeybindsPage.tsx index 7a85d2b..198b50f 100644 --- a/src/shelter/settings/pages/KeybindsPage.tsx +++ b/src/shelter/settings/pages/KeybindsPage.tsx @@ -1,26 +1,49 @@ -const { - plugin: { store }, - ui: { Button, Header, HeaderTags, ButtonSizes, Divider, openModal }, -} = shelter; - -import { For } from "solid-js"; +import { For, Show } from "solid-js"; import type { Keybind } from "../../../@types/keybind.js"; +import { EmptyState } from "../components/EmptyState.jsx"; import { KeybindCard } from "../components/KeybindCard.jsx"; import { KeybindMaker } from "../components/KeybindMaker.jsx"; +import { SettingsPageHeader } from "../components/SettingsPageHeader.jsx"; +import classes from "./KeybindsPage.module.css"; + +const { + plugin: { store }, + ui: { Button, ButtonSizes, openModal, Text, TextTags }, +} = shelter; + export function KeybindsPage() { function addNewKeybind() { openModal(({ close }: { close: () => void }) => ); } + + const t = store.i18n; + const keybinds = () => (store.settings.keybinds as Keybind[]) ?? []; + return ( <> -
Keybinds
- -
+ + + {t["keybind-globalNote"]} + + +
- {(keybind: Keybind) => } + 0} + fallback={ + + } + > + {(keybind: Keybind) => } + ); } diff --git a/src/shelter/settings/pages/PluginsPage.module.css b/src/shelter/settings/pages/PluginsPage.module.css new file mode 100644 index 0000000..bd3fbb6 --- /dev/null +++ b/src/shelter/settings/pages/PluginsPage.module.css @@ -0,0 +1,6 @@ +.toolbar { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} diff --git a/src/shelter/settings/pages/PluginsPage.tsx b/src/shelter/settings/pages/PluginsPage.tsx index c36f848..6089618 100644 --- a/src/shelter/settings/pages/PluginsPage.tsx +++ b/src/shelter/settings/pages/PluginsPage.tsx @@ -1,14 +1,23 @@ import { createSignal, For, onMount, Show } from "solid-js"; import type { LegcordPluginInfo } from "../../../@types/legcordWindow.js"; +import { EmptyState } from "../components/EmptyState.jsx"; import { PluginCard } from "../components/PluginCard.jsx"; +import { SettingsPageHeader } from "../components/SettingsPageHeader.jsx"; +import classes from "./PluginsPage.module.css"; const { - ui: { Header, HeaderTags, Divider, Button, ButtonSizes, showToast }, + ui: { Button, ButtonSizes, showToast }, + plugin: { store }, } = shelter; +function formatPluginMessage(template: string, name: string) { + return template.replaceAll("{name}", name); +} + export function PluginsPage() { const [plugins, setPlugins] = createSignal([]); const [busyIds, setBusyIds] = createSignal([]); + const t = () => store.i18n; const setBusy = (pluginId: string, state: boolean) => { setBusyIds((current) => { @@ -31,9 +40,10 @@ export function PluginsPage() { try { if (enabled && !plugin.compatible) { showToast({ - title: "Plugins", + title: t()["plugins-toastTitle"], content: - plugin.compatibilityMessage ?? `${plugin.name} is not compatible with this Legcord version.`, + plugin.compatibilityMessage ?? + formatPluginMessage(t()["plugins-toastIncompatible"], plugin.name), duration: 3500, }); return; @@ -41,8 +51,11 @@ export function PluginsPage() { const result = await window.legcord.plugins.setEnabled(plugin.id, enabled); if (!result.ok) { showToast({ - title: "Plugins", - content: `Failed to ${enabled ? "enable" : "disable"} ${plugin.name}.`, + title: t()["plugins-toastTitle"], + content: formatPluginMessage( + enabled ? t()["plugins-toastEnableFailed"] : t()["plugins-toastDisableFailed"], + plugin.name, + ), duration: 3000, }); } @@ -57,8 +70,11 @@ export function PluginsPage() { try { const result = await window.legcord.plugins.reload(plugin.id); showToast({ - title: "Plugins", - content: result.ok ? `Reloaded ${plugin.name}.` : `Failed to reload ${plugin.name}.`, + title: t()["plugins-toastTitle"], + content: formatPluginMessage( + result.ok ? t()["plugins-toastReloaded"] : t()["plugins-toastReloadFailed"], + plugin.name, + ), duration: 2500, }); await refreshPlugins(); @@ -73,29 +89,37 @@ export function PluginsPage() { return ( <> -
Plugins
- -
+ +
- -
No runtime plugins found in your plugins folder.
-
- - {(plugin) => ( - void onToggle(plugin, enabled)} - onReload={() => void onReload(plugin)} + 0} + fallback={ + - )} - + } + > + + {(plugin) => ( + void onToggle(plugin, enabled)} + onReload={() => void onReload(plugin)} + /> + )} + + ); } diff --git a/src/shelter/settings/pages/RegisteredGamesPage.tsx b/src/shelter/settings/pages/RegisteredGamesPage.tsx index 9d314d9..74c870d 100644 --- a/src/shelter/settings/pages/RegisteredGamesPage.tsx +++ b/src/shelter/settings/pages/RegisteredGamesPage.tsx @@ -5,6 +5,8 @@ import { sleep } from "../../../common/sleep.js"; import { AddDetectableModal } from "../components/AddDetectableModal.jsx"; import { DetectableCard } from "../components/DetectableCard.jsx"; import { Dropdown } from "../components/Dropdown.jsx"; +import { EmptyState } from "../components/EmptyState.jsx"; +import { SettingsPageHeader } from "../components/SettingsPageHeader.jsx"; import classes from "./RegisteredGames.module.css"; const { @@ -93,8 +95,7 @@ export function RegisteredGamesPage() { return ( <> -
{t["games-registeredGames"]}
- +
0} - fallback={ -
- {t["games-empty"]} -
- } + fallback={} > {(detectable) => }
-
+
{t["games-lastDetected"]}
0} fallback={ -
- {t["games-lastDetectedEmpty"]} -
+ } >
    @@ -171,16 +166,14 @@ export function RegisteredGamesPage() {
-
+
{t["games-blacklisted"]}
0} fallback={ -
- {t["games-blacklistedEmpty"]} -
+ } >
    diff --git a/src/shelter/settings/pages/ThemesPage.tsx b/src/shelter/settings/pages/ThemesPage.tsx index b311791..f736c3f 100644 --- a/src/shelter/settings/pages/ThemesPage.tsx +++ b/src/shelter/settings/pages/ThemesPage.tsx @@ -1,14 +1,19 @@ -import { createSignal, For } from "solid-js"; +import { createSignal, For, Show } from "solid-js"; import type { Settings } from "../../../@types/settings.js"; import type { ThemeManifest } from "../../../@types/themeManifest.js"; +import { EmptyState } from "../components/EmptyState.jsx"; +import { SettingsPageHeader } from "../components/SettingsPageHeader.jsx"; import { ThemesCard } from "../components/ThemesCard.jsx"; import { refreshThemes, setConfig } from "../settings.js"; import classes from "./ThemesPages.module.css"; const { - ui: { Button, Header, HeaderTags, ButtonSizes, TextBox, showToast, SwitchItem }, + ui: { Button, Header, HeaderTags, ButtonSizes, TextBox, showToast, SwitchItem, Text, TextTags }, plugin: { store }, } = shelter; + +const BETTERDISCORD_THEMES_URL = "https://betterdiscord.app/themes"; + export function ThemesPage() { const [downloadUrl, setDownloadUrl] = createSignal(""); refreshThemes(); @@ -28,9 +33,11 @@ export function ThemesPage() { const settings = () => store.settings as Settings; const t = store.i18n; + const themes = () => (store.themes as ThemeManifest[]) ?? []; + return ( <> -
    Themes
    + - {t["themes-refresh"] ?? "Refresh"} + {t["themes-refresh"]}
@@ -79,9 +86,24 @@ export function ThemesPage() { {t["themes-import"]}
+ + {t["themes-importUrlNote"]} +
-
{t["themes-installed"]}
- {(theme: ThemeManifest) => } +
{t["themes-installed"]}
+ 0} + fallback={ + + } + > + {(theme: ThemeManifest) => } + ); } diff --git a/src/shelter/settings/pages/ThemesPages.module.css b/src/shelter/settings/pages/ThemesPages.module.css index e10bc40..0839a0c 100644 --- a/src/shelter/settings/pages/ThemesPages.module.css +++ b/src/shelter/settings/pages/ThemesPages.module.css @@ -21,3 +21,8 @@ border: none; border-top: 1px solid var(--background-modifier-accent); } + +.importNote { + margin-top: 6px; + color: var(--text-muted, var(--text-subtle)); +} From 464a1c2fb71055cced4d38f0806fd5f3fc74ae5f Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:25:08 +0200 Subject: [PATCH 07/17] fix: settings jumping after state change --- .../components/SettingsPanel.module.css | 2 +- .../settings/components/SettingsPanel.tsx | 68 ++++++++------- src/shelter/settings/pages/SettingsPage.tsx | 43 ++++------ src/shelter/settings/settings.ts | 83 ++++++++++++++++--- 4 files changed, 127 insertions(+), 69 deletions(-) diff --git a/src/shelter/settings/components/SettingsPanel.module.css b/src/shelter/settings/components/SettingsPanel.module.css index 6dab667..81d5678 100644 --- a/src/shelter/settings/components/SettingsPanel.module.css +++ b/src/shelter/settings/components/SettingsPanel.module.css @@ -101,9 +101,9 @@ transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); } +/* Avoid transitioning max-height: none ↔ 0 — it visually collapses on content reflow. */ :global(.full-motion) .section { transition: - max-height 0.5s cubic-bezier(0.16, 1, 0.3, 1), padding-top 0.5s cubic-bezier(0.16, 1, 0.3, 1), margin-top 0.5s cubic-bezier(0.16, 1, 0.3, 1); } diff --git a/src/shelter/settings/components/SettingsPanel.tsx b/src/shelter/settings/components/SettingsPanel.tsx index 3f70440..5db0ac1 100644 --- a/src/shelter/settings/components/SettingsPanel.tsx +++ b/src/shelter/settings/components/SettingsPanel.tsx @@ -6,14 +6,21 @@ const { ui: { Header, HeaderTags }, } = shelter; +/** Survives SettingsPanel / SettingsPage remounts (e.g. after toggling a setting). */ +const persistedOpen: Record = {}; + export type SettingsPanelProps = { + /** Stable id used to remember open/closed across remounts. */ + id: string; title: string; description?: string; icon: Component; children: JSX.Element; - /** Controlled open state (e.g. while searching). */ - open?: boolean; - onOpenChange?: (open: boolean) => void; + /** + * When true, the panel is forced open (search). Manual toggles are ignored + * until forceOpen is cleared — matching Shelter’s settings panels. + */ + forceOpen?: boolean; defaultOpen?: boolean; /** Hide the panel entirely (e.g. no search matches). */ hidden?: boolean; @@ -38,38 +45,39 @@ const CaretIcon = (props: { open: boolean }) => ( ); export function SettingsPanel(props: SettingsPanelProps) { - const [internalOpen, setInternalOpen] = createSignal(props.defaultOpen ?? false); + const [internalOpen, setInternalOpen] = createSignal(persistedOpen[props.id] ?? props.defaultOpen ?? false); - const isControlled = () => props.open !== undefined; - const isOpen = () => (isControlled() ? Boolean(props.open) : internalOpen()); + const isOpen = () => Boolean(props.forceOpen) || internalOpen(); function toggle() { - const next = !isOpen(); - if (!isControlled()) setInternalOpen(next); - props.onOpenChange?.(next); + if (props.forceOpen) return; + const next = !internalOpen(); + persistedOpen[props.id] = next; + setInternalOpen(next); } return ( - -
- -
{props.children}
-
-
+
+ +
{props.children}
+
); } diff --git a/src/shelter/settings/pages/SettingsPage.tsx b/src/shelter/settings/pages/SettingsPage.tsx index 4f56f18..7b3bd2b 100644 --- a/src/shelter/settings/pages/SettingsPage.tsx +++ b/src/shelter/settings/pages/SettingsPage.tsx @@ -34,7 +34,6 @@ type PanelId = "backup" | "mods" | "lookAndFeel" | "behaviour" | "power" | "rpc" export function SettingsPage() { const [query, setQuery] = createSignal(""); - const [openPanels, setOpenPanels] = createSignal>>({}); const settings = store.settings as Settings; const t = store.i18n; @@ -160,10 +159,7 @@ export function SettingsPage() { const isSearching = createMemo(() => query().trim().length > 0); - const panelOpen = (id: PanelId) => { - if (isSearching()) return matchesSettingsQuery(panelKeywords[id], query()); - return openPanels()[id] ?? false; - }; + const panelForceOpen = (id: PanelId) => isSearching() && matchesSettingsQuery(panelKeywords[id], query()); const panelHidden = (id: PanelId) => { if (!isSearching()) return false; @@ -174,11 +170,6 @@ export function SettingsPage() { (Object.keys(panelKeywords) as PanelId[]).some((id) => matchesSettingsQuery(panelKeywords[id], query())), ); - const onPanelOpenChange = (id: PanelId, open: boolean) => { - if (isSearching()) return; - setOpenPanels((prev) => ({ ...prev, [id]: open })); - }; - return ( @@ -197,9 +188,9 @@ export function SettingsPage() { title={t["settings-category-backup"]} description={t["settings-category-backup-desc"]} icon={BackupPanelIcon} - open={panelOpen("backup")} + id="backup" + forceOpen={panelForceOpen("backup")} hidden={panelHidden("backup")} - onOpenChange={(open) => onPanelOpenChange("backup", open)} > @@ -210,9 +201,9 @@ export function SettingsPage() { title={t["settings-category-mods"]} description={t["settings-category-mods-desc"]} icon={ModsPanelIcon} - open={panelOpen("mods")} + id="mods" + forceOpen={panelForceOpen("mods")} hidden={panelHidden("mods")} - onOpenChange={(open) => onPanelOpenChange("mods", open)} > From f0ff4f9221dbbe2d097434fd91ddea488b82d967 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:54:16 +0200 Subject: [PATCH 13/17] fix: invisible traffic light buttons on modern transparency https://github.com/Legcord/Legcord/issues/1095 --- src/discord/preload/newTitlebar.mts | 19 ++++++++++++------ src/discord/window.ts | 17 +++++++++++++++- src/shelter/titlebar/index.ts | 30 +++++++++++++++++------------ 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/discord/preload/newTitlebar.mts b/src/discord/preload/newTitlebar.mts index cf079eb..1fdb1a1 100644 --- a/src/discord/preload/newTitlebar.mts +++ b/src/discord/preload/newTitlebar.mts @@ -3,15 +3,22 @@ const { ipcRenderer } = require("electron"); import { addStyle } from "../../common/dom.js"; import { sleep } from "../../common/sleep.js"; -if ( - ipcRenderer.sendSync("getConfig", "windowStyle") === "default" || - ipcRenderer.sendSync("getConfig", "windowStyle") === "overlay" -) { +const windowStyle = ipcRenderer.sendSync("getConfig", "windowStyle") as string; +const transparency = ipcRenderer.sendSync("getConfig", "transparency") as string; +const os = ipcRenderer.sendSync("getOS") as string; + +// Native + transparency on macOS uses overlay chrome (see createWindow / Legcord#1095). +const usesOverlayChrome = + windowStyle === "default" || + windowStyle === "overlay" || + (windowStyle === "native" && os === "darwin" && transparency !== "none"); + +if (usesOverlayChrome) { document.addEventListener("DOMContentLoaded", () => { - document.body.setAttribute("legcord-platform", ipcRenderer.sendSync("getOS")); + document.body.setAttribute("legcord-platform", os); addStyle("legcord://assets/css/baseTitlebar.css"); sleep(500); - switch (ipcRenderer.sendSync("getOS")) { + switch (os) { case "darwin": // breaks traffic lights with bar__ and hidden__ classes // document.body.setAttribute("class", "platform-osx"); diff --git a/src/discord/window.ts b/src/discord/window.ts index 5dbf2ae..5057644 100644 --- a/src/discord/window.ts +++ b/src/discord/window.ts @@ -527,7 +527,22 @@ export function createWindow() { } break; case "native": - browserWindowOptions.frame = true; + // On macOS, frame:true + transparent/vibrancy makes the native title bar + // and traffic lights invisible (Legcord#1095). Use overlay chrome instead. + if (os.platform() === "darwin" && getConfig("transparency") !== "none") { + browserWindowOptions.titleBarStyle = "hidden"; + browserWindowOptions.titleBarOverlay = { + color: getConfig("overlayButtonColor"), + symbolColor: "#99aab5", + height: 30, + }; + browserWindowOptions.trafficLightPosition = { + x: 10, + y: 10, + }; + } else { + browserWindowOptions.frame = true; + } break; case "overlay": browserWindowOptions.titleBarStyle = "hidden"; diff --git a/src/shelter/titlebar/index.ts b/src/shelter/titlebar/index.ts index 2c85b0d..ff54a76 100644 --- a/src/shelter/titlebar/index.ts +++ b/src/shelter/titlebar/index.ts @@ -66,19 +66,25 @@ function layerPop() { export function onLoad() { log("Legcord Titlebar Controller"); - switch (settings.windowStyle) { - case "default": - document.body.setAttribute("customTitlebar", ""); - injectButtonControls(); - break; - case "overlay": - document.body.setAttribute("customTitlebar", ""); - dispatcher.subscribe("LAYER_PUSH", layerPush); - dispatcher.subscribe("LAYER_POP", layerPop); - break; - default: - log("Unsupported window style"); + if (settings.windowStyle === "default") { + document.body.setAttribute("customTitlebar", ""); + injectButtonControls(); + return; } + + // Native + transparency on macOS uses the same overlay chrome as "overlay" (Legcord#1095). + const overlayLike = + settings.windowStyle === "overlay" || + (settings.windowStyle === "native" && window.legcord.platform === "darwin" && settings.transparency !== "none"); + + if (overlayLike) { + document.body.setAttribute("customTitlebar", ""); + dispatcher.subscribe("LAYER_PUSH", layerPush); + dispatcher.subscribe("LAYER_POP", layerPop); + return; + } + + log("Unsupported window style"); } export function onUnload() { From 38e5b20dfeab39056a1f66e90e3a31c48146e232 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:13:34 +0200 Subject: [PATCH 14/17] feat: add go back to dsc button on invite pages --- assets/app/css/inviteBack.css | 49 ++++++++++++++++ assets/lang/en-US.json | 1 + src/discord/preload/inviteBackButton.mts | 75 ++++++++++++++++++++++++ src/discord/preload/preload.mts | 1 + 4 files changed, 126 insertions(+) create mode 100644 assets/app/css/inviteBack.css create mode 100644 src/discord/preload/inviteBackButton.mts diff --git a/assets/app/css/inviteBack.css b/assets/app/css/inviteBack.css new file mode 100644 index 0000000..5f27cab --- /dev/null +++ b/assets/app/css/inviteBack.css @@ -0,0 +1,49 @@ +#legcord-invite-back { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 2147483646; + display: none; + align-items: center; + justify-content: center; + gap: 8px; + margin: 0; + padding: 0 16px; + min-height: 38px; + border: none; + border-radius: 8px; + background: var(--brand-500, #5865f2); + color: var(--white-500, #fff); + font-family: var(--font-primary, "gg sans", "gg sans Fallback", "Noto Sans", Helvetica, Arial, sans-serif); + font-size: 14px; + font-weight: 500; + line-height: 18px; + letter-spacing: 0; + white-space: nowrap; + cursor: pointer; + box-shadow: + var(--elevation-high, 0 8px 16px rgba(0, 0, 0, 0.24)), + 0 0 0 1px rgba(0, 0, 0, 0.08); + -webkit-app-region: no-drag; + user-select: none; + transition: background-color 0.17s ease, box-shadow 0.17s ease, transform 0.17s ease; +} + +#legcord-invite-back[data-visible="true"] { + display: inline-flex; +} + +#legcord-invite-back:hover { + background: var(--brand-560, #4752c4); +} + +#legcord-invite-back:active { + background: var(--brand-600, #3c45a5); + transform: translateX(-50%) translateY(1px); +} + +#legcord-invite-back:focus-visible { + outline: 2px solid var(--brand-500, #5865f2); + outline-offset: 2px; +} diff --git a/assets/lang/en-US.json b/assets/lang/en-US.json index 1c04db6..0199d66 100644 --- a/assets/lang/en-US.json +++ b/assets/lang/en-US.json @@ -147,6 +147,7 @@ "settings-save": "Save Settings", "settings-experimental": "Experimental", "settings-restart": "Restart App", + "invite-goBackToApp": "Go back to the Discord app", "settings-updater": "Check for updates", "settings-skipSplash": "Skip Splash Screen", "settings-skipSplash-desc": "Skips Legcord splash screen when you start up the app.", diff --git a/src/discord/preload/inviteBackButton.mts b/src/discord/preload/inviteBackButton.mts new file mode 100644 index 0000000..06f0a30 --- /dev/null +++ b/src/discord/preload/inviteBackButton.mts @@ -0,0 +1,75 @@ +import { addScript, addStyle } from "../../common/dom.js"; + +const { ipcRenderer } = require("electron"); + +document.addEventListener("DOMContentLoaded", () => { + void (async () => { + const label = + ((await ipcRenderer.invoke("getLang", "invite-goBackToApp")) as string | undefined) || + "Go back to the Discord app"; + + addStyle("legcord://assets/css/inviteBack.css"); + + // Injected into the page world so Discord SPA history.pushState/replaceState is visible. + addScript(` +(() => { + const BTN_ID = "legcord-invite-back"; + const LABEL = ${JSON.stringify(label)}; + + function isInvitePath(pathname) { + const path = (pathname || "").toLowerCase(); + return path.startsWith("/invite/") || path.startsWith("/guest-invite/"); + } + + function ensureButton() { + let btn = document.getElementById(BTN_ID); + if (btn) return btn; + btn = document.createElement("button"); + btn.id = BTN_ID; + btn.type = "button"; + btn.textContent = LABEL; + btn.setAttribute("aria-label", LABEL); + btn.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + try { + history.pushState({}, "", "/app"); + window.dispatchEvent(new PopStateEvent("popstate", { state: {} })); + } catch (_) { + location.assign("/app"); + } + }); + (document.body || document.documentElement).appendChild(btn); + return btn; + } + + function sync() { + const btn = ensureButton(); + const onInvite = isInvitePath(location.pathname); + btn.setAttribute("data-visible", onInvite ? "true" : "false"); + btn.hidden = !onInvite; + btn.setAttribute("aria-hidden", onInvite ? "false" : "true"); + } + + function patchHistory(method) { + const original = history[method]; + if (typeof original !== "function") return; + history[method] = function () { + const result = original.apply(this, arguments); + queueMicrotask(sync); + return result; + }; + } + + patchHistory("pushState"); + patchHistory("replaceState"); + window.addEventListener("popstate", sync); + window.addEventListener("hashchange", sync); + + sync(); + // Fallback for Discord navigations that do not go through history hooks. + setInterval(sync, 1000); +})(); +`); + })(); +}); diff --git a/src/discord/preload/preload.mts b/src/discord/preload/preload.mts index bfcbef7..a83fbde 100644 --- a/src/discord/preload/preload.mts +++ b/src/discord/preload/preload.mts @@ -9,6 +9,7 @@ import "./patches.mjs"; import "./newTitlebar.mjs"; import "./titlebar.mjs"; import "./themes.js"; +import "./inviteBackButton.mjs"; console.log("Legcord"); window.localStorage.setItem("hideNag", "true"); From d5641b5f0e925df28a8c5d624b1ed729505d8020 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:15:30 +0200 Subject: [PATCH 15/17] fix: make the window focus properly on deeplinks --- src/common/dom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/dom.ts b/src/common/dom.ts index 4670db8..189bd97 100644 --- a/src/common/dom.ts +++ b/src/common/dom.ts @@ -47,5 +47,6 @@ export function navigateTo(passedWindow: BrowserWindow, url: string): void { passedWindow.webContents.executeJavaScript( `history.pushState({}, null, ${JSON.stringify(sanitized)});window.dispatchEvent(new PopStateEvent("popstate", {}));`, ); + passedWindow.show(); passedWindow.focus(); } From 6ac0f48f4f9a1b26124f807a6b82549a0f7cbcd3 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:15:43 +0200 Subject: [PATCH 16/17] chore: linting --- assets/app/css/inviteBack.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/app/css/inviteBack.css b/assets/app/css/inviteBack.css index 5f27cab..2fc5622 100644 --- a/assets/app/css/inviteBack.css +++ b/assets/app/css/inviteBack.css @@ -27,7 +27,10 @@ 0 0 0 1px rgba(0, 0, 0, 0.08); -webkit-app-region: no-drag; user-select: none; - transition: background-color 0.17s ease, box-shadow 0.17s ease, transform 0.17s ease; + transition: + background-color 0.17s ease, + box-shadow 0.17s ease, + transform 0.17s ease; } #legcord-invite-back[data-visible="true"] { From 642533f8007a37067d612c03d8f3871c9d52b68a Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:32:44 +0200 Subject: [PATCH 17/17] fix: dropdowns not overflowing panels --- src/shelter/settings/components/SettingsPanel.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shelter/settings/components/SettingsPanel.module.css b/src/shelter/settings/components/SettingsPanel.module.css index 81d5678..85b752c 100644 --- a/src/shelter/settings/components/SettingsPanel.module.css +++ b/src/shelter/settings/components/SettingsPanel.module.css @@ -87,6 +87,8 @@ .sectionOpened { max-height: none; + /* Allow dropdown menus to paint outside the panel instead of being clipped. */ + overflow: visible; margin: 16px; margin-top: 0; padding-top: 16px;