Compare commits

..

6 commits

Author SHA1 Message Date
smartfrigde
e9a95d4c75 fix: start minimized not working
closes https://github.com/Legcord/Legcord/issues/941
2026-07-24 17:10:54 +02:00
smartfrigde
c222657c30 feat sdp rewrite toggle 2026-07-24 17:02:38 +02:00
smartfrigde
9558af52dc feat: redesign the ootb setup to be more in-line with discord 2026-07-24 15:00:06 +02:00
smartfrigde
acdec4a5e3 chore: bump deps 2026-07-24 13:52:01 +02:00
smartfrigde
e60e46c88e fix: don't the dbus interface on mac/win 2026-07-24 13:35:44 +02:00
Renan Giuliasse
71c6484a74
feat. debian target license & dependencies (#1130) 2026-07-24 13:15:58 +02:00
27 changed files with 1512 additions and 453 deletions

3
.gitignore vendored
View file

@ -5,4 +5,5 @@ ts-out/
package-lock.json
.pnpm-store
.cache
.DS_Store
.DS_Store
scripts/spdx-license.txt

BIN
assets/app/desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/app/overlay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

1
assets/app/shelter.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="322" height="322" fill="none" viewBox="0 0 322 322"><g clip-path="url(#clip0_77_26)"><path fill="url(#paint0_linear_77_26)" d="M297 0H25C11.1929 0 0 11.1929 0 25V297C0 310.807 11.1929 322 25 322H297C310.807 322 322 310.807 322 297V25C322 11.1929 310.807 0 297 0Z"/><g filter="url(#filter0_d_77_26)"><path fill="#CCFFED" d="M99.4277 199.184C103.018 227.904 130.909 239.778 161.286 239.778C197.186 239.778 221.763 224.037 221.763 193.661C221.763 175.158 209.889 159.97 185.587 154.723L152.725 147.543C141.955 145.058 140.298 140.363 140.298 135.945C140.298 129.593 144.441 122.413 159.077 122.413C174.265 122.413 183.93 130.422 185.311 141.468L220.106 134.564C215.412 110.539 192.767 93.6935 160.181 93.6935C125.386 93.6935 103.846 112.748 103.846 139.259C103.846 159.142 117.93 173.225 142.784 178.472L170.951 184.271C182.826 186.757 186.14 191.728 186.14 196.698C186.14 204.431 178.131 210.506 164.6 210.506C150.24 210.506 138.089 205.811 136.156 191.728L99.4277 199.184Z"/></g></g><defs><filter id="filter0_d_77_26" width="122.335" height="153.085" x="99.428" y="93.694" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="7"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow_77_26"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_77_26" mode="normal" result="shape"/></filter><linearGradient id="paint0_linear_77_26" x1="161" x2="161" y1="0" y2="1105" gradientUnits="userSpaceOnUse"><stop stop-color="#2A3B4B"/><stop offset="1" stop-color="#2BFAAC"/></linearGradient><clipPath id="clip0_77_26"><rect width="322" height="322" fill="#fff"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -78,6 +78,8 @@
"settings-automaticClientUpdates-desc": "Disables automatic Legcord updates",
"settings-hardwareAcceleration": "Hardware acceleration",
"settings-hardwareAcceleration-desc": "Hardware acceleration uses your GPU to make Legcord run faster. If you're experiencing visual glitches, try disabling this.",
"settings-sdpH264BaselineRewrite": "H.264 Baseline SDP rewrite",
"settings-sdpH264BaselineRewrite-desc": "Rewrites Discord's Constrained Baseline H.264 profile in WebRTC SDP so screenshare can use hardware encode (VideoToolbox / Media Foundation) instead of OpenH264. Disable if Go Live fails to connect or video is broken, then restart Legcord.",
"settings-processScanning": "Process scanning",
"settings-processScanning-desc": "Scan for running games to improve Rich Presence detection. Disable to reduce CPU usage. Requires a restart.",
"settings-windowsLegacyScanning": "Windows legacy scanning",
@ -244,10 +246,13 @@
"setup-welcomeTitle": "Welcome to Legcord",
"setup-welcomeSubtitle": "Let's get you set up with your perfect configuration.",
"setup-getStarted": "Get Started",
"setup-windowStyle-overlayTitle": "Overlay Titlebar",
"setup-windowStyle-overlayDesc": "A modern titlebar that blends into Discord. Recommended for most users.",
"setup-windowStyle-nativeTitle": "Native Window",
"setup-windowStyle-nativeDesc": "Use your system's default window decorations",
"setup-windowStyle-customTitle": "Custom Titlebar",
"setup-windowStyle-customDesc": "Use Legcord's custom titlebar design",
"setup-recommended": "Recommended",
"setup-chooseWindowStyle": "Choose Window Style",
"setup-selectAppearance": "Select how Legcord appears on your machine",
"setup-systemTray": "System Tray",
@ -263,6 +268,8 @@
"setup-launchLegcord": "Launch Legcord",
"setup-modSelectorTitle": "Choose Your Client Mod",
"setup-modSelectorSubtitle": "Legcord includes Shelter out of the box, but you can also choose another client mod if wanted.",
"setup-shelterOnlyTitle": "Shelter Only",
"setup-shelterOnlyDesc": "Legcord was built around Shelter. Most typical users won't need another client mod.",
"setup-vencordTitle": "Vencord",
"setup-vencordDesc": "Client mod with plugins and themes.",
"setup-equicordTitle": "Equicord",

21
docker/Dockerfile.deb Normal file
View file

@ -0,0 +1,21 @@
FROM ubuntu
WORKDIR /app
COPY ./dist/*.deb .
RUN apt-get update -y && apt-get upgrade -y
RUN apt-get install ./*.deb -y
RUN apt-get install -qqy x11-apps x11vnc xvfb
RUN mkdir ~/.vnc
RUN x11vnc -storepasswd 1234 ~/.vnc/passwd
COPY docker/start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 5900
CMD ["/start.sh"]

21
docker/Dockerfile.pacman Normal file
View file

@ -0,0 +1,21 @@
FROM archlinux
WORKDIR /app
COPY ./dist/*.pacman .
RUN pacman -Syu --noconfirm
RUN pacman -U --noconfirm ./*.pacman
RUN pacman -Syu --noconfirm x11vnc xorg-server-xvfb
RUN mkdir ~/.vnc
RUN x11vnc -storepasswd 1234 ~/.vnc/passwd
COPY docker/start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 5900
CMD ["/start.sh"]

13
docker/start.sh Normal file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
export DISPLAY=:99
Xvfb :99 -screen 0 1280x800x24 &
sleep 1
x11vnc -display :99 -forever -usepw -rfbport 5900 &
legcord --no-sandbox &
tail -f /dev/null

View file

@ -1,6 +1,7 @@
import type { Configuration } from "electron-builder";
import { applyAppImageSandboxFix } from "./scripts/build/sandboxFix.mjs";
import debianLicence from "./scripts/spdxLicenceDebianFormat";
import { ACTION_FRIENDLY_NAMES, EXCLUDED_FROM_SHORTCUTS, ValidActions } from "./src/common/commandDefinitions";
const desktopActions = (exec: "AppRun" | "/opt/Legcord/legcord") =>
@ -71,7 +72,7 @@ export const config: Configuration = {
},
pacman: {
depends: ["gtk3", "libnotify", "nss", "libxss", "libxtst", "xdg-utils", "at-spi2-core", "libsecret"],
depends: ["gtk3", "libnotify", "xdg-utils", "at-spi2-core", "alsa-lib", "nspr", "nss"],
},
nsis: {
@ -101,13 +102,14 @@ export const config: Configuration = {
deb: {
category: "Network",
icon: "build/icon.icns",
depends: ["libgbm-dev", "libasound2", "libnspr4", "libnss3"],
depends: ["libasound2", "libnspr4", "libnss3", "libasound2t64", "libasound2-plugins"],
desktop: {
entry: {
Actions: availableActions,
},
desktopActions: desktopActions("/opt/Legcord/legcord"),
},
fpm: [`${debianLicence()}=/usr/share/doc/legcord/copyright`],
},
files: [

View file

@ -40,17 +40,19 @@
"@types/ws": "^8.18.1",
"@uwu/lune": "^1.6.2",
"@uwu/shelter-defs": "^1.5.1",
"@uwu/shelter-ui": "^0.0.6",
"@xmldom/xmldom": "^0.9.10",
"app-builder-lib": "^26.8.1",
"babel-preset-solid": "^1.9.12",
"electron": "43.2.0",
"electron-builder": "26.8.1",
"electron-builder": "26.15.7",
"lucide-solid": "^1.16.0",
"monaco-editor": "0.52.2",
"rolldown": "1.0.3",
"rolldown": "1.2.0",
"rollup-plugin-copy": "^3.5.0",
"solid-js": "^1.9.14",
"solid-motionone": "^1.0.4",
"spdx-license-list": "^6.11.0",
"typescript": "^6.0.3",
"xml-formatter": "^3.7.0"
},

565
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,8 @@ export default defineConfig([
targets: [
{ src: "src/**/**/*.html", dest: "ts-out/html/" },
{ src: "src/**/**/*.css", dest: "ts-out/css/" },
{ src: "src/setup/setup.css", dest: "ts-out/html/" },
{ src: "node_modules/@uwu/shelter-ui/compat.css", dest: "ts-out/html/" },
{ src: "src/**/**/*.js", dest: "ts-out/js/" },
{ src: "package.json", dest: "ts-out/" },
{ src: "assets/**/**", dest: "ts-out/assets/" },

View file

@ -0,0 +1,66 @@
// https://www.debian.org/doc/debian-policy/ch-docs.html#copyright-information
// https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import spdxLicenseList from "spdx-license-list";
interface CopyrightConfig {
upstreamName: string;
source: string;
copyrightHolder: string;
licenseId: string;
comment: string; // optional but nice to have
}
function indentText(text: string): string {
// formatted text fields follow the same rules as Debian control file
// long descriptions: each line indented with a single space, blank
// lines represented as " ."
return text
.split("\n")
.map((line) => (line.trim() === "" ? " ." : ` ${line}`))
.join("\n");
}
function generateCopyrightFile(config: CopyrightConfig): string {
const license = spdxLicenseList[config.licenseId];
if (!license) {
throw new Error(`Unknown SPDX license id: ${config.licenseId}`);
}
const header = [
"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/",
`Upstream-Name: ${config.upstreamName}`,
`Source: ${config.source}`,
`Comment: ${indentText(config.comment)}`, // optional field
].join("\n");
const licenseText = readFileSync("license.txt").toString();
const licenseStanza = [`License: ${config.licenseId}`, indentText(licenseText)].join("\n");
const filesStanza = ["Files: *", `Copyright: ${config.copyrightHolder}`, `License: ${config.licenseId}`].join("\n");
return `${[header, filesStanza, licenseStanza].join("\n\n")}\n`;
}
/**
* Returns the Debian SPDX-formatted license for given license ID
*
* @export
* @return {*} {string}
*/
export default function (): string {
const copyrightContent: string = generateCopyrightFile({
upstreamName: "Legcord",
source: "https://github.com/Legcord/Legcord",
copyrightHolder: `2020 - ${new Date().getFullYear()} Legcord Contributors`, // git log --reverse
licenseId: "OSL-3.0",
comment: "Open-Source Discord client alternative.",
});
const licensePath = path.join(__dirname, "spdx-license.txt");
writeFileSync(licensePath, copyrightContent);
return licensePath;
}

View file

@ -61,6 +61,8 @@ export interface Settings {
startMinimized: boolean;
keybinds: Keybind[];
hardwareAcceleration: boolean;
/** Rewrite H.264 Constrained Baseline (42e0) → Baseline (4200) in WebRTC SDP for HW encode. */
sdpH264BaselineRewrite: boolean;
useMacSystemPicker: boolean;
inviteWebsocket: boolean;
disableAutogain: boolean;

View file

@ -12,7 +12,7 @@ let configCache: Settings | null = null;
let configCacheTime = 0;
const CONFIG_CACHE_TTL = 5000; // Cache for 5 seconds
const defaults: Settings = {
windowStyle: "default",
windowStyle: "overlay",
channel: "stable",
bounceOnPing: false,
csp: "none",
@ -34,11 +34,12 @@ const defaults: Settings = {
loopbackType: "loopback",
},
multiInstance: false,
mods: ["vencord"],
mods: [],
transparency: "none",
windowMaterial: "mica",
spellcheck: true,
hardwareAcceleration: true,
sdpH264BaselineRewrite: true,
performanceMode: "none",
skipSplash: false,
inviteWebsocket: true,
@ -81,6 +82,7 @@ const safeMode: Settings = {
windowStyle: "native",
csp: "vanilla",
hardwareAcceleration: false,
sdpH264BaselineRewrite: false,
disableHttpCache: true,
vaapi: false,
additionalArguments: "",
@ -120,6 +122,11 @@ export function getConfig<K extends keyof Settings>(object: K): Settings[K] {
configCacheTime = now;
return returnData[object];
}
/** True when the settings toggle is on or `--start-minimized` was passed for this session. */
export function shouldStartMinimized(): boolean {
return process.argv.includes("--start-minimized") || getConfig("startMinimized") === true;
}
export function setConfig<K extends keyof Settings>(object: K, toSet: Settings[K]): void {
const rawData = readFileSync(getConfigLocation(), "utf-8");
const parsed = JSON.parse(rawData) as Settings;

View file

@ -1,7 +1,13 @@
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 {
interface as dbusInterface,
type MessageBus,
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";
@ -13,6 +19,8 @@ export const DBUS_ADDRESS = "/app/legcord/Legcord";
const FREEDESKTOP_PORTAL_NAME = "org.freedesktop.portal.Desktop";
const FREEDESKTOP_PORTAL_ADDRESS = "/org/freedesktop/portal/desktop";
const isLinux = process.platform === "linux";
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html
type Shortcuts = [string, { description: Variant<string> }];
interface GlobalShortcuts extends ProxyInterface {
@ -74,8 +82,16 @@ class LegcordInterface extends Interface {
}
}
const bus = sessionBus();
var legcordInterface = new LegcordInterface();
let bus: MessageBus | undefined;
let legcordInterface: LegcordInterface | undefined;
function getBus(): MessageBus {
if (!bus) {
bus = sessionBus();
legcordInterface = new LegcordInterface();
}
return bus;
}
function ensureDesktopFile(): void {
const dir = join(homedir(), ".local/share/applications");
@ -88,14 +104,18 @@ function ensureDesktopFile(): void {
function registerAppId(): Promise<void> {
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, {});
});
return getBus()
.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);
if (!isLinux) return;
const portalObj = await getBus().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");
@ -108,17 +128,19 @@ export async function setupGlobalShortcuts() {
console.debug(`Registered ${DBUS_INTERFACE_NAME} in freedesktop Registry.`);
function awaitResponse(requestPath: string): Promise<Record<string, unknown>> {
return bus.getProxyObject(FREEDESKTOP_PORTAL_NAME, requestPath).then(
(requestObj) =>
new Promise<Record<string, unknown>>((resolve, reject) => {
const requestIface = requestObj.getInterface("org.freedesktop.portal.Request");
return getBus()
.getProxyObject(FREEDESKTOP_PORTAL_NAME, requestPath)
.then(
(requestObj) =>
new Promise<Record<string, unknown>>((resolve, reject) => {
const requestIface = requestObj.getInterface("org.freedesktop.portal.Request");
requestIface.once("Response", (responseCode: number, results: Record<string, unknown>) => {
if (responseCode === 0) resolve(results);
else reject(new Error(`Request failed with code ${responseCode}`));
});
}),
);
requestIface.once("Response", (responseCode: number, results: Record<string, unknown>) => {
if (responseCode === 0) resolve(results);
else reject(new Error(`Request failed with code ${responseCode}`));
});
}),
);
}
const sessionRequestPath = await globalShortcuts.CreateSession({
@ -146,13 +168,19 @@ export async function setupGlobalShortcuts() {
}
export async function startDbusService(): Promise<void> {
await bus.requestName(DBUS_INTERFACE_NAME);
bus.export(DBUS_ADDRESS, legcordInterface);
if (!isLinux) return;
const dbus = getBus();
await dbus.requestName(DBUS_INTERFACE_NAME);
dbus.export(DBUS_ADDRESS, legcordInterface!);
console.info(`Registered DBus service at ${DBUS_INTERFACE_NAME} ${DBUS_ADDRESS}`);
// console.debug(legcordInterface)
}
export function disconnectDbusService(): void {
if (!bus) return;
bus.disconnect();
bus = undefined;
legcordInterface = undefined;
}

View file

@ -18,7 +18,7 @@ import {
unblacklistGame as blacklistGameRemove,
getBlacklist,
} from "../common/blacklistGame.js";
import { getConfig, getConfigLocation, setConfig, setConfigBulk } from "../common/config.js";
import { getConfig, getConfigLocation, setConfig, setConfigBulk, shouldStartMinimized } from "../common/config.js";
import { addDetectable, getDetectables, removeDetectable } from "../common/detectables.js";
import { getLang, getLangName, getRawLang, setLang } from "../common/lang.js";
import {
@ -217,8 +217,8 @@ export function registerIpc(passedWindow: BrowserWindow): void {
});
ipcMain.on("splashEnd", () => {
splashWindow.close();
if (getConfig("startMinimized")) {
splashWindow?.close();
if (shouldStartMinimized()) {
passedWindow.hide();
} else {
passedWindow.show();

View file

@ -36,7 +36,11 @@ const version = ipcRenderer.sendSync("displayVersion") as string;
// Discord's Go Live answer forces profile-level-id=42e01f (OpenH264); local-only munging
// is overwritten by setRemoteDescription, so the answer must be rewritten too.
// Without this, Windows burns CPU on OpenH264 even when Media Foundation HW encode is available.
// Gated by settings.sdpH264BaselineRewrite (default on) so users can disable if negotiation breaks.
{
const rewriteBaselineSetting = ipcRenderer.sendSync("getConfig", "sdpH264BaselineRewrite") as boolean | undefined;
const preferHwH264 =
(process.platform === "darwin" || process.platform === "win32") && (rewriteBaselineSetting ?? true);
const bitrateScript = document.createElement("script");
bitrateScript.textContent = `(function () {
var CAP = "80000";
@ -45,7 +49,7 @@ const version = ipcRenderer.sendSync("displayVersion") as string;
var MIN_BR = "3000";
var START_BR = "3000";
// VideoToolbox (macOS) and Media Foundation (Windows) HW encode avoid OpenH264 CBP.
var preferHwH264 = ${process.platform === "darwin" || process.platform === "win32" ? "true" : "false"};
var preferHwH264 = ${preferHwH264 ? "true" : "false"};
function setOrAppendFmtpParam(sdp, key, value) {
var re = new RegExp(key + "=\\\\d+", "g");
if (sdp.indexOf(key + "=") !== -1) {

View file

@ -14,7 +14,7 @@ import {
shell,
} from "electron";
import contextMenu from "electron-context-menu";
import { firstRun, getConfig, setConfig } from "../common/config.js";
import { firstRun, getConfig, setConfig, shouldStartMinimized } from "../common/config.js";
import { navigateTo } from "../common/dom.js";
import { forceQuit, setForceQuit } from "../common/forceQuit.js";
import { handleCommands, passedValidArgument } from "../common/handleCommands.js";
@ -474,8 +474,13 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void {
break;
}
if (getConfig("skipSplash")) {
passedWindow.show();
// When splash won't run, finalize visibility here (splashEnd never fires).
if (getConfig("skipSplash") || shouldStartMinimized()) {
if (shouldStartMinimized()) {
passedWindow.hide();
} else {
passedWindow.show();
}
}
}

View file

@ -15,6 +15,7 @@ import {
setConfig,
setFirstRun,
setup,
shouldStartMinimized,
} from "./common/config.js";
import { getPreset } from "./common/flags.js";
import { setLang } from "./common/lang.js";
@ -108,7 +109,9 @@ function args(): void {
export async function init(): Promise<void> {
// Skip setup if bypass flag was used
if (bypassSetup || !(firstRun === true || undefined)) {
if (getConfig("skipSplash") === false) {
// Splash owns the final show/hide via splashEnd; skip it when splash is disabled
// or when we intentionally start in the background.
if (getConfig("skipSplash") === false && !shouldStartMinimized()) {
void createSplashWindow(); // NOTE - Awaiting will hang at start
}
createWindow();

View file

@ -12,8 +12,8 @@ export async function createSetupWindow(): Promise<void> {
if (platform() !== "darwin") import("./tray.js");
return new Promise(() => {
const windowOptions: BrowserWindowConstructorOptions = {
width: 800,
height: 600,
width: 840,
height: 720,
title: getLang("setup-windowTitle"),
darkTheme: true,
icon: getConfig("customIcon") ?? path.join(import.meta.dirname, "../", "/assets/desktop.png"),

View file

@ -15,17 +15,7 @@ contextBridge.exposeInMainWorld("setup", {
if (ipcRenderer.sendSync("setup-getOS") !== "darwin") {
document.addEventListener("DOMContentLoaded", () => {
const css = document.createElement("style");
css.innerHTML = `.bg {
background-image: url("legcord://assets/mockup.jpg");
background-repeat: round;
height: 100%;
width: 100%;
position: absolute;
outline: 80px solid black;
-webkit-filter: blur(10px);
}`;
document.head.appendChild(css);
document.body.classList.add("has-setup-bg");
});
}

488
src/setup/setup.css Normal file
View file

@ -0,0 +1,488 @@
:root {
--font-primary: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-headline: "ABC Ginto Nord", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--saturation-factor: 1;
/* Soft Legcord purple accents (kept quiet) */
--setup-purple: var(--blurple-50);
--setup-purple-soft: rgba(88, 101, 242, 0.14);
--setup-purple-glow: rgba(114, 137, 218, 0.18);
/* Discord Mana tokens (from stable web stylesheets / theme-dark) */
--blurple-50-hsl: 234.935 calc(var(--saturation-factor, 1) * 85.556%) 64.706%;
--blurple-60-hsl: 232.941 calc(var(--saturation-factor, 1) * 46.667%) 50%;
--blurple-65-hsl: 232 calc(var(--saturation-factor, 1) * 47.511%) 43.333%;
--blurple-50: hsl(var(--blurple-50-hsl) / 1);
--blurple-60: hsl(var(--blurple-60-hsl) / 1);
--blurple-65: hsl(var(--blurple-65-hsl) / 1);
--neutral-1: #fff;
--opacity-white-8: hsl(0 0% 100% / 0.0784313725490196);
--opacity-4: hsl(0 0% 100% / 0.0392156862745098);
--opacity-12: hsl(0 0% 100% / 0.12156862745098039);
--opacity-16: hsl(0 0% 100% / 0.1607843137254902);
--opacity-20: hsl(0 0% 100% / 0.2);
/* shelter-ui → Discord Mana control colors */
--control-primary-background-default: var(--blurple-50);
--control-primary-background-hover: var(--blurple-60);
--control-primary-background-active: var(--blurple-65);
--control-primary-text-default: var(--neutral-1);
--control-primary-text-hover: var(--neutral-1);
--control-primary-text-active: var(--neutral-1);
--control-primary-border-default: var(--opacity-white-8);
--control-primary-border-hover: var(--opacity-white-8);
--control-primary-border-active: var(--opacity-white-8);
--control-secondary-background-default: var(--opacity-12);
--control-secondary-background-hover: var(--opacity-16);
--control-secondary-background-active: var(--opacity-20);
--control-secondary-text-default: var(--neutral-1);
--control-secondary-text-hover: var(--neutral-1);
--control-secondary-text-active: var(--neutral-1);
--control-secondary-border-default: var(--opacity-4);
--control-secondary-border-hover: var(--opacity-4);
--control-secondary-border-active: var(--opacity-4);
--custom-button-transition-duration: 170ms;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
font-family: var(--font-primary);
background: #1e1f22;
color: var(--text-default);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.draggable {
position: absolute;
top: 0;
width: 100%;
height: 40px;
-webkit-user-select: none;
user-select: none;
-webkit-app-region: drag;
z-index: 10;
}
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 50% at 50% -8%, var(--setup-purple-glow), transparent 58%),
radial-gradient(ellipse 40% 35% at 0% 100%, rgba(88, 101, 242, 0.08), transparent 55%), #1e1f22;
}
body.has-setup-bg .bg {
background-image:
linear-gradient(180deg, rgba(30, 31, 34, 0.75), rgba(30, 31, 34, 0.9)),
radial-gradient(ellipse 60% 40% at 50% 0%, var(--setup-purple-glow), transparent 60%),
url("legcord://assets/mockup.jpg");
background-size: cover;
background-position: center;
filter: none;
}
body.has-setup-bg .bg::after {
content: "";
position: absolute;
inset: 0;
backdrop-filter: blur(18px);
background: rgba(30, 31, 34, 0.32);
}
#root {
position: relative;
z-index: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
}
.setup-shell {
position: relative;
width: 100%;
max-width: 560px;
max-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
background: var(--modal-background, #313338);
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.3),
0 0 0 1px var(--setup-purple-soft),
0 12px 28px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.setup-shell::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--setup-purple) 35%, var(--setup-purple) 65%, transparent);
opacity: 0.55;
pointer-events: none;
z-index: 2;
}
.setup-progress {
display: flex;
gap: 4px;
padding: 16px 20px 0;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
background: rgba(88, 101, 242, 0.16);
transition: background 0.15s ease;
}
.setup-progress-segment--done,
.setup-progress-segment--active {
background: var(--setup-purple);
}
.setup-body {
padding: 22px 24px 14px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.setup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 24px;
background: var(--modal-footer-background, #2b2d31);
border-top: 1px solid rgba(0, 0, 0, 0.25);
}
.setup-step-indicator {
color: var(--header-secondary);
font-family: var(--font-primary);
font-size: 12px;
font-weight: var(--font-weight-medium);
letter-spacing: 0.02em;
line-height: 16px;
white-space: nowrap;
}
.setup-header {
margin-bottom: 20px;
}
.setup-title {
margin: 0;
color: var(--header-primary, #f2f3f5);
font-family: var(--font-display);
font-size: 20px;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.01em;
line-height: 1.2;
}
.setup-title--hero {
font-size: 32px;
font-weight: var(--font-weight-bold);
line-height: 1.15;
}
.setup-subtitle {
display: block;
margin: 8px 0 0;
color: var(--header-secondary, #b5bac1);
font-family: var(--font-primary);
font-size: 14px;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: 0;
}
.setup-subtitle--hero {
margin-top: 10px;
margin-bottom: 24px;
max-width: 360px;
font-size: 16px;
line-height: 22px;
}
.setup-welcome,
.setup-finish {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 8px 28px;
}
.setup-logo {
width: 88px;
height: 88px;
margin-bottom: 20px;
border-radius: 22px;
object-fit: cover;
box-shadow:
0 0 0 1px rgba(88, 101, 242, 0.22),
0 10px 28px rgba(0, 0, 0, 0.4),
0 0 32px rgba(88, 101, 242, 0.18);
}
.setup-finish-icon {
width: 64px;
height: 64px;
margin-bottom: 18px;
}
.setup-finish-icon svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 6px 16px rgba(88, 101, 242, 0.35));
}
.setup-cta {
margin-top: 4px;
max-width: 100%;
font-family: var(--font-primary);
font-weight: var(--font-weight-medium);
letter-spacing: 0;
transition:
background-color var(--custom-button-transition-duration) ease,
color var(--custom-button-transition-duration) ease,
border-color var(--custom-button-transition-duration) ease;
}
.setup-footer-btn {
font-family: var(--font-primary);
font-weight: var(--font-weight-medium);
transition:
background-color var(--custom-button-transition-duration) ease,
color var(--custom-button-transition-duration) ease,
border-color var(--custom-button-transition-duration) ease;
}
.setup-note {
width: 100%;
margin: 0 0 22px;
padding: 12px 14px;
border-radius: 8px;
background: color-mix(in srgb, var(--setup-purple) 6%, var(--background-secondary, #2b2d31));
border: 1px solid rgba(88, 101, 242, 0.12);
color: var(--header-secondary);
text-align: left;
}
.setup-note p {
margin: 0;
font-family: var(--font-primary);
font-size: 14px;
line-height: 20px;
color: var(--header-secondary);
}
.setup-options {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 8px;
}
.setup-option {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 12px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--background-secondary, #2b2d31);
color: inherit;
text-align: left;
cursor: pointer;
font-family: var(--font-primary);
outline: none;
transition:
background 0.1s ease-out,
border-color 0.1s ease-out,
box-shadow 0.1s ease-out;
-webkit-app-region: no-drag;
}
.setup-option:hover {
background: var(--background-surface-high, #393a41);
}
.setup-option:focus-visible {
box-shadow: 0 0 0 2px var(--setup-purple);
}
.setup-option--selected {
background: color-mix(in srgb, var(--setup-purple) 14%, var(--background-secondary, #2b2d31));
border-color: color-mix(in srgb, var(--setup-purple) 55%, transparent);
box-shadow: inset 0 0 0 1px rgba(88, 101, 242, 0.2);
}
.setup-option--selected:hover {
background: color-mix(in srgb, var(--setup-purple) 20%, var(--background-secondary, #2b2d31));
}
.setup-option-preview {
width: 128px;
height: 80px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--input-background, #1e1f22);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.setup-option-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.setup-option-icon {
width: 44px;
height: 44px;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
background: var(--input-background, #1e1f22);
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.setup-option-icon--selected {
background: color-mix(in srgb, var(--setup-purple) 18%, var(--input-background, #1e1f22));
}
.setup-option-icon img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.setup-option-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.setup-option-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.setup-option-title {
color: var(--header-primary, #f2f3f5);
font-family: var(--font-primary);
font-size: 16px;
font-weight: var(--font-weight-semibold);
line-height: 20px;
letter-spacing: 0.01em;
}
.setup-option-desc {
color: var(--header-secondary, #b5bac1);
font-family: var(--font-primary);
font-size: 14px;
font-weight: var(--font-weight-normal);
line-height: 18px;
}
.setup-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
background: var(--setup-purple);
color: #fff;
font-family: var(--font-primary);
font-size: 10px;
font-weight: var(--font-weight-bold);
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 14px;
}
.setup-radio {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--interactive-normal);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition:
border-color 0.1s ease-out,
background 0.1s ease-out;
}
.setup-radio--selected {
border-color: var(--setup-purple);
background: var(--setup-purple);
}
.setup-radio-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
}
.setup-warning {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 8px;
background: rgba(240, 178, 50, 0.1);
border: 1px solid rgba(240, 178, 50, 0.28);
color: #f0b232;
font-family: var(--font-primary);
font-size: 14px;
line-height: 18px;
}
.setup-warning-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 1px;
}

View file

@ -4,18 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legcord Setup</title>
<style>
.draggable {
position: absolute;
top: 0;
width: 100%;
height: 50px;
-webkit-user-select: none;
user-select: none;
-webkit-app-region: drag;
}
</style>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="compat.css">
<link rel="stylesheet" href="setup.css">
</head>
<body>
<div class="draggable"></div>
@ -23,4 +13,4 @@
<div id="root"></div>
<script type="module" src="setup.js"></script>
</body>
</html>
</html>

View file

@ -1,122 +1,167 @@
import {
Box,
ChevronLeft,
ChevronRight,
CircleAlert,
LaptopMinimal,
LaptopMinimalCheck,
MinimizeIcon,
Monitor,
PuzzleIcon,
Settings,
Sparkles,
} from "lucide-solid";
import { createResource, createSignal, For, Show } from "solid-js";
import { Button, ButtonColors, ButtonSizes, injectInternalStyles, niceScrollbarsClass } from "@uwu/shelter-ui";
import { type Accessor, createMemo, createResource, createSignal, For, onMount, Show } from "solid-js";
import { render } from "solid-js/web";
import { Motion } from "solid-motionone";
const Welcome = ({ onNext, t }: { onNext: () => void; t: () => Record<string, string> | undefined }) => (
<Motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} class="text-center">
<Sparkles class="w-8 h-8 text-purple-400 mx-auto mb-4" />
<h1 class="text-3xl font-bold text-white mb-4 bg-gradient-to-r from-purple-400 via-white to-purple-400 bg-clip-text text-transparent">
{t()?.["setup-welcomeTitle"] ?? "Welcome to Legcord"}
</h1>
<p class="text-gray-400 mb-8">
injectInternalStyles();
type Lang = Record<string, string> | undefined;
const ctaStyle = {
"--shltr-btn-w": "280px",
"--shltr-btn-h": "44px",
"font-size": "16px",
"line-height": "20px",
padding: "2px 24px",
} as const;
const footerBtnStyle = {
"--shltr-btn-w": "96px",
"--shltr-btn-h": "38px",
} as const;
const WarningIcon = () => (
<svg class="setup-warning-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 14.25a1.25 1.25 0 1 1 1.25-1.25A1.25 1.25 0 0 1 12 16.25Zm1.1-3.75h-2.2l-.35-6h2.9Z" />
</svg>
);
const OptionCard = (props: {
selected: boolean;
onSelect: () => void;
title: string;
description: string;
badge?: string;
preview?: string;
icon?: string;
}) => (
<button
type="button"
class="setup-option"
classList={{ "setup-option--selected": props.selected }}
onClick={props.onSelect}
aria-pressed={props.selected}
>
<Show when={props.preview}>
<div class="setup-option-preview">
<img src={props.preview} alt="" />
</div>
</Show>
<Show when={props.icon}>
<div class="setup-option-icon" classList={{ "setup-option-icon--selected": props.selected }}>
<img src={props.icon} alt="" />
</div>
</Show>
<div class="setup-option-content">
<div class="setup-option-title-row">
<span class="setup-option-title">{props.title}</span>
<Show when={props.badge}>
<span class="setup-badge">{props.badge}</span>
</Show>
</div>
<span class="setup-option-desc">{props.description}</span>
</div>
<span class="setup-radio" classList={{ "setup-radio--selected": props.selected }} aria-hidden="true">
<Show when={props.selected}>
<span class="setup-radio-dot" />
</Show>
</span>
</button>
);
const Progress = (props: { current: number; total: number }) => (
<div class="setup-progress" aria-hidden="true">
<For each={Array.from({ length: props.total }, (_, i) => i)}>
{(i) => (
<div
class="setup-progress-segment"
classList={{
"setup-progress-segment--done": i < props.current,
"setup-progress-segment--active": i === props.current,
}}
/>
)}
</For>
</div>
);
const Welcome = ({ onNext, t }: { onNext: () => void; t: () => Lang }) => (
<Motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} class="setup-welcome">
<img class="setup-logo" src="legcord://assets/desktop.png" alt="" />
<h1 class="setup-title setup-title--hero">{t()?.["setup-welcomeTitle"] ?? "Welcome to Legcord"}</h1>
<p class="setup-subtitle setup-subtitle--hero">
{t()?.["setup-welcomeSubtitle"] ?? "Let's get you set up with your perfect configuration."}
</p>
<Motion.button
<Button
color={ButtonColors.PRIMARY}
size={ButtonSizes.LARGE}
onClick={onNext}
class="px-6 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-medium"
class="setup-cta"
style={ctaStyle}
>
{t()?.["setup-getStarted"] ?? "Get Started"}
</Motion.button>
</Button>
</Motion.div>
);
const WindowStyle = ({
readyToNext,
selectedStyle,
onSelect,
t,
}: {
readyToNext: (valid: boolean) => void;
t: () => Record<string, string> | undefined;
selectedStyle: Accessor<string>;
onSelect: (styleId: string) => void;
t: () => Lang;
}) => {
const [selectedStyle, setSelectedStyle] = createSignal<string | null>(null);
const handleStyleSelect = (styleId: string) => {
const newValue = selectedStyle() === styleId ? null : styleId;
setSelectedStyle(newValue);
if (newValue === null) {
readyToNext(false);
} else {
readyToNext(true);
window.setup.saveSettings({ windowStyle: newValue });
}
};
const styles = [
{
id: "overlay",
titleKey: "setup-windowStyle-overlayTitle",
titleFallback: "Overlay Titlebar",
descKey: "setup-windowStyle-overlayDesc",
descFallback: "A modern titlebar that blends into Discord. Recommended for most users.",
screenshot: "legcord://assets/overlay.png",
recommended: true,
},
{
id: "native",
titleKey: "setup-windowStyle-nativeTitle",
titleFallback: "Native Window",
descKey: "setup-windowStyle-nativeDesc",
descFallback: "Use your system's default window decorations",
screenshot: "legcord://assets/native.png",
},
{
id: "default",
titleKey: "setup-windowStyle-customTitle",
titleFallback: "Custom Titlebar",
descKey: "setup-windowStyle-customDesc",
descFallback: "Use Legcord's custom titlebar design",
screenshot: "legcord://assets/custom.png",
},
];
const lang = t();
return (
<div class="space-y-6">
<div class="text-center">
<Monitor class="w-8 h-8 text-purple-400 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-white mb-2">
{lang?.["setup-chooseWindowStyle"] ?? "Choose Window Style"}
</h2>
<p class="text-gray-400">
<div class="setup-step">
<div class="setup-header">
<h2 class="setup-title">{lang?.["setup-chooseWindowStyle"] ?? "Choose Window Style"}</h2>
<p class="setup-subtitle">
{lang?.["setup-selectAppearance"] ?? "Select how Legcord appears on your machine"}
</p>
</div>
<div class="space-y-4">
<div class="setup-options">
<For each={styles}>
{(item) => (
<Motion.button
onClick={() => handleStyleSelect(item.id)}
class={`group relative w-full p-4 rounded-xl transition-all duration-300 text-left ${
selectedStyle() === item.id
? "bg-purple-900/40 border border-purple-500/50 shadow-lg shadow-purple-500/20"
: "bg-gray-800/40 border border-gray-700/30 hover:bg-gray-800/60"
}`}
>
<div class="flex items-center gap-4">
<div class="w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
<img
src={item.screenshot}
alt={`${lang?.[item.titleKey] ?? item.titleKey} preview`}
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white">
{lang?.[item.titleKey] ?? item.titleKey}
</h3>
<p class="text-gray-400 text-sm mt-1">{lang?.[item.descKey] ?? item.descKey}</p>
</div>
<div
class={`w-5 h-5 rounded-full border-2 transition-colors ${
selectedStyle() === item.id ? "border-purple-400" : "border-gray-600"
}`}
>
{selectedStyle() === item.id && (
<div class="w-3 h-3 m-0.5 rounded-full bg-purple-400" />
)}
</div>
</div>
</Motion.button>
<OptionCard
selected={selectedStyle() === item.id}
onSelect={() => onSelect(item.id)}
title={lang?.[item.titleKey] ?? item.titleFallback}
description={lang?.[item.descKey] ?? item.descFallback}
badge={item.recommended ? (lang?.["setup-recommended"] ?? "Recommended") : undefined}
preview={item.screenshot}
/>
)}
</For>
</div>
@ -125,224 +170,166 @@ const WindowStyle = ({
};
const TraySettings = ({
readyToNext,
selectedOption,
onSelect,
t,
}: {
readyToNext: (valid: boolean) => void;
t: () => Record<string, string> | undefined;
selectedOption: Accessor<string | null>;
onSelect: (optionId: string) => void;
t: () => Lang;
}) => {
const [selectedOption, setSelectedOption] = createSignal<string | null>(null);
const handleOptionSelect = (optionId: string) => {
const newValue = selectedOption() === optionId ? null : optionId;
setSelectedOption(newValue);
if (newValue === null) {
readyToNext(false);
} else {
readyToNext(true);
window.setup.saveSettings({ tray: newValue });
}
};
const options = [
{ id: "dynamic", titleKey: "setup-trayEnableTitle", descKey: "setup-trayEnableDesc", icon: LaptopMinimalCheck },
{ id: "disabled", titleKey: "setup-trayDisableTitle", descKey: "setup-trayDisableDesc", icon: LaptopMinimal },
{
id: "dynamic",
titleKey: "setup-trayEnableTitle",
titleFallback: "Enable Tray Icon",
descKey: "setup-trayEnableDesc",
descFallback: "Show Legcord in your system tray",
},
{
id: "disabled",
titleKey: "setup-trayDisableTitle",
titleFallback: "Disable Tray Icon",
descKey: "setup-trayDisableDesc",
descFallback: "Don't show Legcord in your system tray",
},
];
const lang = t();
return (
<div class="space-y-6">
<div class="text-center">
<MinimizeIcon class="w-8 h-8 text-purple-400 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-white mb-2">{lang?.["setup-systemTray"] ?? "System Tray"}</h2>
<p class="text-gray-400">
<div class="setup-step">
<div class="setup-header">
<h2 class="setup-title">{lang?.["setup-systemTray"] ?? "System Tray"}</h2>
<p class="setup-subtitle">
{lang?.["setup-trayChoose"] ?? "Choose whether to enable the system tray icon"}
</p>
</div>
<Show when={window.setup.os === "linux"}>
<div class="space-y-4">
<div class="p-4 rounded-xl bg-yellow-500/10 border border-yellow-500/20 flex items-center gap-3">
<CircleAlert class="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div>
<p class="text-yellow-200/80 text-sm mt-0.5">
{lang?.["setup-linuxTrayWarning"] ??
"System tray functionality may have issues or behave differently on Linux systems."}
</p>
</div>
</div>
<div class="setup-warning">
<WarningIcon />
<span>
{lang?.["setup-linuxTrayWarning"] ??
"System tray functionality may have issues or behave differently on Linux systems."}
</span>
</div>
</Show>
<For each={options}>
{(option) => (
<Motion.button
onClick={() => handleOptionSelect(option.id)}
class={`group relative w-full p-4 rounded-xl transition-all duration-300 text-left ${
selectedOption() === option.id
? "bg-purple-900/40 border border-purple-500/50 shadow-lg shadow-purple-500/20"
: "bg-gray-800/40 border border-gray-700/30 hover:bg-gray-800/60"
}`}
>
<div class="flex items-start gap-4">
<div
class={`w-10 h-10 rounded-lg transition-colors flex items-center justify-center ${
selectedOption() === option.id ? "bg-purple-500/20" : "bg-gray-800/40"
}`}
>
<option.icon class="w-6 h-6 text-purple-400" />
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white">
{lang?.[option.titleKey] ?? option.titleKey}
</h3>
<p class="text-gray-400 text-sm mt-1">{lang?.[option.descKey] ?? option.descKey}</p>
</div>
<div
class={`w-5 h-5 rounded-full border-2 transition-colors ${
selectedOption() === option.id ? "border-purple-400" : "border-gray-600"
}`}
>
{selectedOption() === option.id && (
<div class="w-3 h-3 m-0.5 rounded-full bg-purple-400" />
)}
</div>
</div>
</Motion.button>
)}
</For>
<div class="setup-options">
<For each={options}>
{(option) => (
<OptionCard
selected={selectedOption() === option.id}
onSelect={() => onSelect(option.id)}
title={lang?.[option.titleKey] ?? option.titleFallback}
description={lang?.[option.descKey] ?? option.descFallback}
/>
)}
</For>
</div>
</div>
);
};
const Finish = ({ restart, t }: { restart: () => void; t: () => Record<string, string> | undefined }) => (
<div class="text-center">
<Box class="w-8 h-8 text-purple-400 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-white mb-4">{t()?.["setup-finishTitle"] ?? "You're All Set!"}</h2>
<p class="text-gray-400 mb-6">
const Finish = ({ restart, t }: { restart: () => void; t: () => Lang }) => (
<Motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} class="setup-finish">
<div class="setup-finish-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" role="presentation">
<circle cx="12" cy="12" r="12" fill="var(--brand-500)" />
<path
d="M7.5 12.5 10.5 15.5 16.5 8.5"
stroke="white"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<h1 class="setup-title setup-title--hero">{t()?.["setup-finishTitle"] ?? "You're All Set!"}</h1>
<p class="setup-subtitle setup-subtitle--hero">
{t()?.["setup-finishSubtitle"] ??
"Your Legcord configuration is complete and personalized to your preferences."}
</p>
<div class="p-4 rounded-xl bg-gray-800/40 border border-gray-700/30 mb-6">
<Settings class="w-6 h-6 text-purple-400 mx-auto mb-2" />
<p class="text-sm text-gray-400">
<div class="setup-note">
<p>
{t()?.["setup-finishSettingsNote"] ??
"Need to make changes later? You'll find all these options in Discord's settings menu under Legcord."}
</p>
</div>
<Motion.button
<Button
color={ButtonColors.PRIMARY}
size={ButtonSizes.LARGE}
onClick={restart}
class="px-6 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-medium"
class="setup-cta"
style={ctaStyle}
>
{t()?.["setup-launchLegcord"] ?? "Launch Legcord"}
</Motion.button>
</div>
</Button>
</Motion.div>
);
const ModSelector = ({
readyToNext,
selectedMod,
onSelect,
t,
}: {
readyToNext: (valid: boolean) => void;
t: () => Record<string, string> | undefined;
selectedMod: Accessor<string>;
onSelect: (modId: string) => void;
t: () => Lang;
}) => {
const [selectedMod, setSelectedMod] = createSignal<string | null>(null);
const handleModSelect = (optionId: string) => {
const newValue = selectedMod() === optionId ? null : optionId;
setSelectedMod(newValue);
if (newValue === null) {
readyToNext(false);
} else {
readyToNext(true);
if (newValue !== "shelter") {
window.setup.saveSettings({ mods: [newValue] });
} else {
window.setup.saveSettings({ mods: [] });
}
}
};
const mods = [
{
id: "shelter",
titleKey: "setup-shelterOnlyTitle",
titleFallback: "Shelter Only",
descKey: "setup-shelterOnlyDesc",
descFallback: "Legcord was built around Shelter. Most typical users won't need another client mod.",
recommended: true,
icon: "legcord://assets/shelter.svg",
},
{
id: "vencord",
titleKey: "setup-vencordTitle",
titleFallback: "Vencord",
descKey: "setup-vencordDesc",
descFallback: "Client mod with plugins and themes.",
icon: "legcord://assets/vencord.png",
},
{
id: "equicord",
titleKey: "setup-equicordTitle",
titleFallback: "Equicord",
descKey: "setup-equicordDesc",
descFallback: "A fork of Vencord with more plugins.",
icon: "legcord://assets/equicord.png",
},
];
const lang = t();
return (
<div class="space-y-6">
<div class="text-center">
<PuzzleIcon class="w-8 h-8 text-purple-400 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-white mb-2">
{lang?.["setup-modSelectorTitle"] ?? "Choose Your Client Mod"}
</h2>
<p class="text-gray-400">
<div class="setup-step">
<div class="setup-header">
<h2 class="setup-title">{lang?.["setup-modSelectorTitle"] ?? "Choose Your Client Mod"}</h2>
<p class="setup-subtitle">
{lang?.["setup-modSelectorSubtitle"] ??
"Legcord includes Shelter out of the box, but you can also choose another client mod if wanted."}
</p>
</div>
<div class="space-y-3">
<For each={mods} fallback={<div>{lang?.["setup-loading"] ?? "Loading..."}</div>}>
<div class="setup-options">
<For each={mods}>
{(mod) => (
<Motion.button
onClick={() => handleModSelect(mod.id)}
class={`group relative w-full p-4 rounded-xl transition-all duration-300 text-left ${
selectedMod() === mod.id
? "bg-purple-900/40 border border-purple-500/50 shadow-lg shadow-purple-500/20"
: "bg-gray-800/40 border border-gray-700/30 hover:bg-gray-800/60"
}`}
>
<div class="flex items-start gap-4">
<div
class={`w-10 h-10 rounded-lg transition-colors overflow-hidden ${
selectedMod() === mod.id ? "bg-purple-500/20" : "bg-gray-800/40"
}`}
>
<img
src={mod.icon}
alt={`${lang?.[mod.titleKey] ?? mod.titleKey} icon`}
class="w-full h-full object-contain p-1.5"
/>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white">
{lang?.[mod.titleKey] ?? mod.titleKey}
</h3>
<p class="text-gray-400 text-sm mt-1">{lang?.[mod.descKey] ?? mod.descKey}</p>
</div>
<div
class={`w-5 h-5 rounded-full border-2 transition-colors ${
selectedMod() === mod.id ? "border-purple-400" : "border-gray-600"
}`}
>
{selectedMod() === mod.id && (
<div class="w-3 h-3 m-0.5 rounded-full bg-purple-400" />
)}
</div>
</div>
</Motion.button>
<OptionCard
selected={selectedMod() === mod.id}
onSelect={() => onSelect(mod.id)}
title={lang?.[mod.titleKey] ?? mod.titleFallback}
description={lang?.[mod.descKey] ?? mod.descFallback}
badge={mod.recommended ? (lang?.["setup-recommended"] ?? "Recommended") : undefined}
icon={mod.icon}
/>
)}
</For>
</div>
<Motion.button
onClick={() => handleModSelect("shelter")}
class={`w-full px-6 py-2.5 rounded-xl border transition-colors font-medium
${
selectedMod() === "shelter"
? "border-purple-500/50 text-purple-400 hover:bg-purple-500/10"
: "border-gray-700/30 text-gray-500 hover:border-purple-500/50 hover:text-purple-400 hover:bg-purple-500/10"
}`}
>
{lang?.["setup-useShelterOnly"] ?? "Use Shelter Only"}
</Motion.button>
</div>
);
};
@ -350,84 +337,117 @@ const ModSelector = ({
function Stepper() {
const [t] = createResource(() => window.setup.getRawLang());
const [currentStep, setCurrentStep] = createSignal(0);
const [isValid, setValid] = createSignal(true);
const [selectedStyle, setSelectedStyle] = createSignal("overlay");
const [selectedMod, setSelectedMod] = createSignal("shelter");
const [selectedTray, setSelectedTray] = createSignal<string | null>(null);
const maxSteps = 5;
const handleNext = () => {
if (!isValid()) return;
setCurrentStep((prev) => prev + 1);
setValid(false);
onMount(() => {
window.setup.saveSettings({ windowStyle: "overlay", mods: [] });
});
const canProceed = createMemo(() => {
switch (currentStep()) {
case 0:
case 1:
case 2:
return true;
case 3:
return selectedTray() !== null;
default:
return true;
}
});
const handleStyleSelect = (styleId: string) => {
setSelectedStyle(styleId);
window.setup.saveSettings({ windowStyle: styleId });
};
const handleBack = () => {
setCurrentStep((prev) => prev - 1);
if (currentStep() === 0) {
setValid(true);
const handleModSelect = (modId: string) => {
setSelectedMod(modId);
if (modId !== "shelter") {
window.setup.saveSettings({ mods: [modId] });
} else {
window.setup.saveSettings({ mods: [] });
}
};
const setReady = (valid: boolean) => {
setValid(valid);
const handleTraySelect = (trayId: string) => {
setSelectedTray(trayId);
window.setup.saveSettings({ tray: trayId });
};
const handleNext = () => {
if (!canProceed()) return;
setCurrentStep((prev) => Math.min(prev + 1, maxSteps - 1));
};
const handleBack = () => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
const restart = () => {
console.log("Restarting...");
window.setup.saveSettings({ doneSetup: true });
window.setup.restart();
};
const stepOfText = () => {
const lang = t();
const template = lang?.["setup-stepOf"] ?? "Step {current} of {total}";
return template.replace("{current}", String(currentStep() + 1)).replace("{total}", String(maxSteps));
};
return (
<div class="min-h-screen flex items-center justify-center">
<Motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
class="w-full max-w-md p-8 rounded-3xl bg-gradient-to-br from-[#171631] via-gray-900 to-[#171631] backdrop-blur-xl border border-gray-800/50 shadow-2xl"
>
<Motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} class="setup-shell">
<Show when={currentStep() !== 0 && currentStep() !== 4}>
<Progress current={currentStep()} total={maxSteps} />
</Show>
<div class={`setup-body ${niceScrollbarsClass()}`}>
<Show when={currentStep() === 0}>
<Welcome onNext={handleNext} t={t} />
</Show>
<Show when={currentStep() === 1}>
<WindowStyle readyToNext={setReady} t={t} />
<WindowStyle selectedStyle={selectedStyle} onSelect={handleStyleSelect} t={t} />
</Show>
<Show when={currentStep() === 2}>
<ModSelector readyToNext={setReady} t={t} />
<ModSelector selectedMod={selectedMod} onSelect={handleModSelect} t={t} />
</Show>
<Show when={currentStep() === 3}>
<TraySettings readyToNext={setReady} t={t} />
<TraySettings selectedOption={selectedTray} onSelect={handleTraySelect} t={t} />
</Show>
<Show when={currentStep() === 4}>
<Finish restart={restart} t={t} />
</Show>
</div>
<Show when={currentStep() !== 0 && currentStep() !== 4}>
<div class="mt-8 flex items-center justify-between">
<Motion.button
onClick={handleBack}
class="px-6 py-2.5 rounded-xl bg-gray-800 hover:bg-gray-700 text-white font-medium transition-colors inline-flex items-center gap-2"
>
<ChevronLeft class="w-4 h-4" />
{t()?.["setup-back"] ?? "Back"}
</Motion.button>
<div class="px-4 py-1 rounded-full bg-gray-800/50 text-gray-400 font-medium text-sm">
{stepOfText()}
</div>
<Motion.button
onClick={handleNext}
class={`px-6 py-2.5 rounded-xl text-white font-medium transition-colors inline-flex items-center gap-2 ${
isValid()
? "bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500"
: "bg-gray-700 cursor-not-allowed opacity-50"
}`}
>
{t()?.["setup-next"] ?? "Next"}
<ChevronRight class="w-4 h-4" />
</Motion.button>
</div>
</Show>
</Motion.div>
</div>
<Show when={currentStep() !== 0 && currentStep() !== 4}>
<div class="setup-footer">
<Button
color={ButtonColors.SECONDARY}
size={ButtonSizes.MEDIUM}
onClick={handleBack}
class="setup-footer-btn"
style={footerBtnStyle}
>
{t()?.["setup-back"] ?? "Back"}
</Button>
<span class="setup-step-indicator">{stepOfText()}</span>
<Button
color={ButtonColors.PRIMARY}
size={ButtonSizes.MEDIUM}
disabled={!canProceed()}
onClick={handleNext}
class="setup-footer-btn"
style={footerBtnStyle}
>
{t()?.["setup-next"] ?? "Next"}
</Button>
</div>
</Show>
</Motion.div>
);
}

View file

@ -142,6 +142,7 @@ export function SettingsPage() {
t["settings-venmic-workaround"],
t["settings-audio"],
t["settings-hardwareAcceleration"],
t["settings-sdpH264BaselineRewrite"],
t["settings-vaapi"],
t["settings-automaticClientUpdates"],
t["settings-disableHttpCache"],
@ -488,7 +489,7 @@ export function SettingsPage() {
<SwitchItem
note={t["settings-startMinimized-desc"]}
value={settings.startMinimized}
onChange={(e: boolean) => setConfig("startMinimized", e)}
onChange={(e: boolean) => setConfig("startMinimized", e, true)}
>
{t["settings-startMinimized"]}
</SwitchItem>
@ -856,6 +857,26 @@ export function SettingsPage() {
{t["settings-hardwareAcceleration"]}
</SwitchItem>
</SearchableSetting>
<Show when={window.legcord.platform === "darwin" || window.legcord.platform === "win32"}>
<SearchableSetting
keywords={[
t["settings-sdpH264BaselineRewrite"],
t["settings-sdpH264BaselineRewrite-desc"],
"H264",
"SDP",
"VideoToolbox",
"OpenH264",
]}
>
<SwitchItem
note={t["settings-sdpH264BaselineRewrite-desc"]}
value={settings.sdpH264BaselineRewrite ?? true}
onChange={(e: boolean) => setConfig("sdpH264BaselineRewrite", e, true)}
>
{t["settings-sdpH264BaselineRewrite"]}
</SwitchItem>
</SearchableSetting>
</Show>
<Show when={window.legcord.platform === "linux"}>
<SearchableSetting keywords={[t["settings-vaapi"], t["settings-vaapi-desc"], "VAAPI"]}>
<SwitchItem

View file

@ -1,12 +1,12 @@
import path from "node:path";
import { BrowserWindow, ipcMain } from "electron";
import isDev from "electron-is-dev";
import { getConfig } from "../common/config.js";
import { getConfig, shouldStartMinimized } from "../common/config.js";
import { getLang } from "../common/lang.js";
export let splashWindow: BrowserWindow;
export async function createSplashWindow(): Promise<void> {
if (getConfig("startMinimized")) return;
if (shouldStartMinimized()) return;
splashWindow = new BrowserWindow({
width: 300,
height: 350,