diff --git a/forge.config.ts b/forge.config.ts index 71c17fe..84de803 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -159,7 +159,12 @@ const config: ForgeConfig = { target: "preload", }, ], - renderer: [], + renderer: [ + { + name: "main_window", + config: "vite.renderer.config.ts", + }, + ], }), // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application diff --git a/index.html b/index.html index ebcd263..02bdeac 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,62 @@ - + - Hello World! + + Stoat β€” Select Server -

πŸ’– Hello World!

-

Welcome to your Electron application.

+
+ Stoat Desktop +
+ + + +
+
+ +
+
+
+ +

Stoat Desktop

+

Connect to a Revolt-compatible server

+
+ +
+ + + +
+ +
+ + + +
+ + +
+
+ diff --git a/src/config.d.ts b/src/config.d.ts index d5f0cde..068205e 100644 --- a/src/config.d.ts +++ b/src/config.d.ts @@ -5,6 +5,7 @@ declare type DesktopConfig = { spellchecker: boolean; hardwareAcceleration: boolean; discordRpc: boolean; + customServer: string; windowState: { x: number; y: number; diff --git a/src/index.css b/src/index.css index 9bfd0b1..2550222 100644 --- a/src/index.css +++ b/src/index.css @@ -1,8 +1,286 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #111114; + --card: #1c1c21; + --border: #2e2e38; + --accent: #5865f2; + --accent-hover: #4752c4; + --text: #f2f2f2; + --text-sub: #8e9297; + --error: #ed4245; + --option-hover: #26262d; + --option-active-bg: rgba(88, 101, 242, 0.12); + --titlebar-h: 32px; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - margin: auto; - max-width: 38rem; - padding: 2rem; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + user-select: none; + overflow: hidden; +} + +/* ── Titlebar ─────────────────────────────── */ + +.titlebar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--titlebar-h); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 14px; + background: #0d0d10; + border-bottom: 1px solid var(--border); + -webkit-app-region: drag; + z-index: 100; +} + +.titlebar-title { + font-size: 12px; + font-weight: 600; + color: var(--text-sub); + letter-spacing: 0.3px; +} + +.window-controls { + display: flex; + gap: 2px; + -webkit-app-region: no-drag; +} + +.wc-btn { + width: 28px; + height: 24px; + border: none; + background: transparent; + color: var(--text-sub); + font-size: 13px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s, color 0.1s; +} + +.wc-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +.wc-close:hover { + background: #c42b1c; + color: #fff; +} + +/* ── Main layout ──────────────────────────── */ + +.container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: calc(var(--titlebar-h) + 24px) 24px 24px; +} + +/* ── Card ─────────────────────────────────── */ + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px 28px; + width: 100%; + max-width: 420px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +/* ── Header ───────────────────────────────── */ + +.header { + text-align: center; + margin-bottom: 28px; +} + +.logo { + font-size: 44px; + display: block; + margin-bottom: 10px; +} + +.header h1 { + font-size: 22px; + font-weight: 700; + color: var(--text); + margin-bottom: 5px; +} + +.header .subtitle { + color: var(--text-sub); + font-size: 13px; +} + +/* ── Server option buttons ────────────────── */ + +.server-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.server-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 13px 16px; + border-radius: 10px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + transition: + background 0.12s, + border-color 0.12s; +} + +.server-option:hover { + background: var(--option-hover); +} + +.server-option.active { + border-color: var(--accent); + background: var(--option-active-bg); +} + +.server-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.server-name { + font-weight: 600; + font-size: 14px; +} + +.server-url { + color: var(--text-sub); + font-size: 11px; + font-family: "Consolas", "Courier New", monospace; +} + +.check-icon { + color: var(--accent); + font-size: 16px; + font-weight: 700; + opacity: 0; + transition: opacity 0.12s; + flex-shrink: 0; +} + +.server-option.active .check-icon { + opacity: 1; +} + +/* ── Custom URL input ─────────────────────── */ + +.input-group { + margin-bottom: 20px; + display: none; +} + +.input-group.visible { + display: block; +} + +.input-group label { + display: block; + font-size: 11px; + font-weight: 700; + color: var(--text-sub); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 8px; +} + +.input-group input { + width: 100%; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 13px; + font-family: "Consolas", "Courier New", monospace; + outline: none; + transition: border-color 0.12s; +} + +.input-group input:focus { + border-color: var(--accent); +} + +.input-group input::placeholder { + color: #4a4a55; +} + +.input-error { + display: block; + color: var(--error); + font-size: 12px; + margin-top: 6px; + min-height: 16px; +} + +/* ── Connect button ───────────────────────── */ + +.connect-btn { + width: 100%; + padding: 12px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: + background 0.12s, + transform 0.08s, + opacity 0.12s; +} + +.connect-btn:hover { + background: var(--accent-hover); +} + +.connect-btn:active { + transform: scale(0.98); +} + +.connect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; } diff --git a/src/main.ts b/src/main.ts index 911db87..e8e9f5e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,13 @@ import { IUpdateInfo, updateElectronApp } from "update-electron-app"; -import { BrowserWindow, Notification, app, shell } from "electron"; +import { BrowserWindow, Notification, app, globalShortcut, shell } from "electron"; import started from "electron-squirrel-startup"; import { autoLaunch } from "./native/autoLaunch"; import { config } from "./native/config"; import { initDiscordRpc } from "./native/discordRpc"; import { initTray } from "./native/tray"; -import { BUILD_URL, createMainWindow, mainWindow } from "./native/window"; +import { getBuildURL, createMainWindow, loadServerPicker, mainWindow } from "./native/window"; // Squirrel-specific logic // create/remove shortcuts on Windows when installing / uninstalling @@ -54,6 +54,10 @@ if (acquiredLock) { initTray(); initDiscordRpc(); + globalShortcut.register("CmdOrCtrl+Shift+S", () => { + loadServerPicker(mainWindow); + }); + // Windows specific fix for notifications if (process.platform === "win32") { app.setAppUserModelId("chat.stoat.notifications"); @@ -87,9 +91,12 @@ if (acquiredLock) { // ensure URLs launch in external context app.on("web-contents-created", (_, contents) => { - // prevent navigation out of build URL origin + // prevent navigation out of current server origin contents.on("will-navigate", (event, navigationUrl) => { - if (new URL(navigationUrl).origin !== BUILD_URL.origin) { + const currentOrigin = getBuildURL().origin; + const navOrigin = new URL(navigationUrl).origin; + // allow same-origin navigation and local file/dev-server navigation + if (navOrigin !== currentOrigin && navOrigin !== "null") { event.preventDefault(); } }); diff --git a/src/native/config.ts b/src/native/config.ts index f7c2d37..9f7d1c1 100644 --- a/src/native/config.ts +++ b/src/native/config.ts @@ -28,6 +28,9 @@ const schema = { discordRpc: { type: "boolean", } as JSONSchema.Boolean, + customServer: { + type: "string", + } as JSONSchema.String, windowState: { type: "object", properties: { @@ -60,6 +63,7 @@ const store = new Store({ spellchecker: true, hardwareAcceleration: true, discordRpc: true, + customServer: "", windowState: { x: 0, y: 0, @@ -83,6 +87,7 @@ class Config { spellchecker: this.spellchecker, hardwareAcceleration: this.hardwareAcceleration, discordRpc: this.discordRpc, + customServer: this.customServer, windowState: this.windowState, }); } @@ -192,6 +197,19 @@ class Config { this.sync(); } + get customServer() { + return (store as never as { get(k: string): string }).get("customServer"); + } + + set customServer(value: string) { + (store as never as { set(k: string, value: string): void }).set( + "customServer", + value, + ); + + this.sync(); + } + get windowState() { return ( store as never as { get(k: string): DesktopConfig["windowState"] } diff --git a/src/native/discordRpc.ts b/src/native/discordRpc.ts index 9df475b..a7f3140 100644 --- a/src/native/discordRpc.ts +++ b/src/native/discordRpc.ts @@ -31,8 +31,8 @@ export async function initDiscordRpc() { rpc.on("disconnected", reconnect); - rpc.login({ clientId: "872068124005007420" }); - } catch (err) { + await rpc.login({ clientId: "872068124005007420" }); + } catch { reconnect(); } } diff --git a/src/native/tray.ts b/src/native/tray.ts index 45fd6be..153bdeb 100644 --- a/src/native/tray.ts +++ b/src/native/tray.ts @@ -4,7 +4,7 @@ import trayIconAsset from "../../assets/desktop/icon.png?asset"; import macOsTrayIconAsset from "../../assets/desktop/iconTemplate.png?asset"; import { version } from "../../package.json"; -import { mainWindow, quitApp } from "./window"; +import { mainWindow, quitApp, getBuildURL, loadServerPicker } from "./window"; // internal tray state let tray: Tray = null; @@ -64,6 +64,20 @@ export function updateTrayMenu() { } }, }, + { type: "separator" }, + { + label: `Connected to: ${getBuildURL().hostname}`, + type: "normal", + enabled: false, + }, + { + label: "Change Server…", + type: "normal", + click() { + loadServerPicker(mainWindow); + }, + }, + { type: "separator" }, { label: "Quit App", type: "normal", diff --git a/src/native/window.ts b/src/native/window.ts index 3cc68c8..b8a1bb8 100644 --- a/src/native/window.ts +++ b/src/native/window.ts @@ -14,15 +14,22 @@ import windowIconAsset from "../../assets/desktop/icon.png?asset"; import { config } from "./config"; import { updateTrayMenu } from "./tray"; +// injected by Electron Forge Vite plugin +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; +declare const MAIN_WINDOW_VITE_NAME: string; + +export const DEFAULT_SERVER = "https://beta.revolt.chat"; + // global reference to main window export let mainWindow: BrowserWindow; -// currently in-use build -export const BUILD_URL = new URL( - app.commandLine.hasSwitch("force-server") - ? app.commandLine.getSwitchValue("force-server") - : /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://beta.revolt.chat", -); +// currently in-use server URL β€” initialized inside createMainWindow() to avoid +// the circular config ↔ window import being evaluated before both modules are ready +let _buildUrl: URL; + +export function getBuildURL(): URL { + return _buildUrl; +} // internal window state let shouldQuit = false; @@ -30,12 +37,90 @@ let shouldQuit = false; // load the window icon const windowIcon = nativeImage.createFromDataURL(windowIconAsset); -// windowIcon.setTemplateImage(true); +/** + * Load the local server-picker renderer page + */ +export function loadServerPicker(win: BrowserWindow = mainWindow) { + if ( + typeof MAIN_WINDOW_VITE_DEV_SERVER_URL !== "undefined" && + MAIN_WINDOW_VITE_DEV_SERVER_URL + ) { + win.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + } else { + win.loadFile( + join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), + ); + } +} + +/** + * Inject a floating "Switch Server" button into the currently-loaded chat page. + * executeJavaScript bypasses the remote site's CSP, so no external assets are needed. + */ +function injectSwitchServerButton() { + mainWindow.webContents.executeJavaScript(` + (function () { + if (document.getElementById('__stoat-switch-btn')) return; + + const btn = document.createElement('button'); + btn.id = '__stoat-switch-btn'; + btn.title = 'Switch Server (Ctrl+Shift+S)'; + btn.innerHTML = 'Switch Server'; + + Object.assign(btn.style, { + position: 'fixed', + bottom: '16px', + right: '16px', + zIndex: '2147483647', + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '6px 12px 6px 9px', + background: 'rgba(20,20,26,0.82)', + color: '#ccc', + border: '1px solid rgba(255,255,255,0.10)', + borderRadius: '20px', + fontSize: '12px', + fontFamily: '-apple-system,system-ui,sans-serif', + fontWeight: '500', + cursor: 'pointer', + backdropFilter: 'blur(10px)', + opacity: '0.35', + transition: 'opacity 0.18s, background 0.18s, color 0.18s', + userSelect: 'none', + lineHeight: '1', + }); + + btn.onmouseenter = () => { + btn.style.opacity = '1'; + btn.style.background = 'rgba(88,101,242,0.92)'; + btn.style.color = '#fff'; + }; + btn.onmouseleave = () => { + btn.style.opacity = '0.35'; + btn.style.background = 'rgba(20,20,26,0.82)'; + btn.style.color = '#ccc'; + }; + + btn.onclick = () => window.native?.showServerPicker?.(); + + document.body.appendChild(btn); + })(); + `); +} /** * Create the main application window */ export function createMainWindow() { + // resolve which server URL to use β€” done here (not at module level) to avoid + // the circular config ↔ window import triggering a TDZ error on startup + _buildUrl = new URL( + app.commandLine.hasSwitch("force-server") + ? app.commandLine.getSwitchValue("force-server") + : config.customServer || DEFAULT_SERVER, + ); + // (CLI arg --hidden or config) const startHidden = app.commandLine.hasSwitch("hidden") || config.startMinimisedToTray; @@ -46,7 +131,7 @@ export function createMainWindow() { minHeight: 300, width: 1280, height: 720, - backgroundColor: "#191919", + backgroundColor: "#111114", frame: !config.customFrame, icon: windowIcon, show: !startHidden, @@ -83,8 +168,14 @@ export function createMainWindow() { mainWindow.maximize(); } - // load the entrypoint - mainWindow.loadURL(BUILD_URL.toString()); + // show server picker on first launch / when no server is saved, + // otherwise go straight to the saved (or CLI-forced) server + const forceServer = app.commandLine.hasSwitch("force-server"); + if (!forceServer && !config.customServer) { + loadServerPicker(mainWindow); + } else { + mainWindow.loadURL(_buildUrl.toString()); + } // minimise window to tray mainWindow.on("close", (event) => { @@ -141,8 +232,21 @@ export function createMainWindow() { } }); - // send the config - mainWindow.webContents.on("did-finish-load", () => config.sync()); + // send the config, then inject the switch-server button when on the chat page + mainWindow.webContents.on("did-finish-load", () => { + config.sync(); + const pageUrl = mainWindow.webContents.getURL(); + try { + if ( + pageUrl.startsWith("http") && + new URL(pageUrl).origin === _buildUrl.origin + ) { + injectSwitchServerButton(); + } + } catch { + // ignore malformed URLs + } + }); // configure spellchecker context menu mainWindow.webContents.on("context-menu", (_, params) => { @@ -193,13 +297,33 @@ export function createMainWindow() { mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize(), ); ipcMain.on("close", () => mainWindow.close()); - - // mainWindow.webContents.openDevTools(); - - // let i = 0; - // setInterval(() => setBadgeCount((++i % 30) + 1), 1000); } +// navigate to a server URL, persist it, and load it in the window +// registered once at module level β€” ipcMain.handle throws on duplicate registration +ipcMain.handle("navigate", (_, url: string) => { + try { + _buildUrl = new URL(url); + // only persist if not overridden by --force-server CLI flag + if (!app.commandLine.hasSwitch("force-server")) { + config.customServer = url; + } + mainWindow.loadURL(url); + return true; + } catch { + return false; + } +}); + +// return the current server URL to the renderer +ipcMain.handle("getServerUrl", () => _buildUrl.toString()); + +// show the server picker without wiping the saved server +// (picker pre-fills from getServerUrl; a new choice is only saved on Connect) +ipcMain.handle("showServerPicker", () => { + loadServerPicker(mainWindow); +}); + /** * Quit the entire app */ diff --git a/src/renderer.ts b/src/renderer.ts index 08b05f6..d9e5708 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,32 +1,142 @@ -/** - * This file will automatically be loaded by vite and run in the "renderer" context. - * To learn more about the differences between the "main" and the "renderer" context in - * Electron, visit: - * - * https://electronjs.org/docs/tutorial/process-model - * - * By default, Node.js integration in this file is disabled. When enabling Node.js integration - * in a renderer process, please be aware of potential security implications. You can read - * more about security risks here: - * - * https://electronjs.org/docs/tutorial/security - * - * To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration` - * flag: - * - * ``` - * // Create the browser window. - * mainWindow = new BrowserWindow({ - * width: 800, - * height: 600, - * webPreferences: { - * nodeIntegration: true - * } - * }); - * ``` - */ import "./index.css"; -console.log( - 'πŸ‘‹ This message is being logged by "renderer.ts", included via Vite', -); +const DEFAULT_SERVER = "https://beta.revolt.chat"; + +type NativeAPI = { + minimise: () => void; + maximise: () => void; + close: () => void; + navigate: (url: string) => Promise; + getServerUrl: () => Promise; +}; + +declare global { + interface Window { + native: NativeAPI; + } +} + +async function init() { + const optOfficial = document.getElementById( + "opt-official", + ) as HTMLButtonElement; + const optCustom = document.getElementById("opt-custom") as HTMLButtonElement; + const inputGroup = document.getElementById( + "custom-input-group", + ) as HTMLDivElement; + const serverUrlInput = document.getElementById( + "server-url-input", + ) as HTMLInputElement; + const customUrlPreview = document.getElementById( + "custom-url-preview", + ) as HTMLSpanElement; + const connectBtn = document.getElementById( + "connect-btn", + ) as HTMLButtonElement; + const urlError = document.getElementById("url-error") as HTMLSpanElement; + + // Window control bindings + document + .getElementById("btn-minimise") + ?.addEventListener("click", () => window.native.minimise()); + document + .getElementById("btn-maximise") + ?.addEventListener("click", () => window.native.maximise()); + document + .getElementById("btn-close") + ?.addEventListener("click", () => window.native.close()); + + let mode: "official" | "custom" = "official"; + + function setMode(next: "official" | "custom") { + mode = next; + optOfficial.classList.toggle("active", next === "official"); + optCustom.classList.toggle("active", next === "custom"); + inputGroup.classList.toggle("visible", next === "custom"); + if (next === "custom") { + serverUrlInput.focus(); + } + } + + // Try to pre-select the saved server + try { + const saved = await window.native.getServerUrl(); + if (saved && saved !== DEFAULT_SERVER) { + serverUrlInput.value = saved; + try { + customUrlPreview.textContent = new URL(saved).hostname; + } catch { + customUrlPreview.textContent = saved; + } + setMode("custom"); + } else { + setMode("official"); + } + } catch { + setMode("official"); + } + + optOfficial.addEventListener("click", () => { + setMode("official"); + urlError.textContent = ""; + }); + + optCustom.addEventListener("click", () => { + setMode("custom"); + }); + + serverUrlInput.addEventListener("input", () => { + urlError.textContent = ""; + const raw = serverUrlInput.value.trim(); + try { + customUrlPreview.textContent = new URL(raw).hostname || "Enter URL below"; + } catch { + customUrlPreview.textContent = raw ? "Invalid URL" : "Enter URL below"; + } + }); + + async function connect() { + urlError.textContent = ""; + + const url = + mode === "official" ? DEFAULT_SERVER : serverUrlInput.value.trim(); + + if (mode === "custom") { + if (!url) { + urlError.textContent = "Please enter a server URL."; + serverUrlInput.focus(); + return; + } + try { + const parsed = new URL(url); + if (!parsed.protocol.startsWith("http")) { + urlError.textContent = "URL must start with http:// or https://"; + serverUrlInput.focus(); + return; + } + } catch { + urlError.textContent = "Invalid URL β€” please check and try again."; + serverUrlInput.focus(); + return; + } + } + + connectBtn.disabled = true; + connectBtn.textContent = "Connecting…"; + + const ok = await window.native.navigate(url); + if (!ok) { + connectBtn.disabled = false; + connectBtn.textContent = "Connect"; + urlError.textContent = "Could not connect. Please try again."; + } + } + + connectBtn.addEventListener("click", connect); + + serverUrlInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") connect(); + }); +} + +init(); diff --git a/src/world/window.ts b/src/world/window.ts index 2e9957b..5e166bd 100644 --- a/src/world/window.ts +++ b/src/world/window.ts @@ -15,4 +15,12 @@ contextBridge.exposeInMainWorld("native", { close: () => ipcRenderer.send("close"), setBadgeCount: (count: number) => ipcRenderer.send("setBadgeCount", count), + + navigate: (url: string) => + ipcRenderer.invoke("navigate", url) as Promise, + + getServerUrl: () => + ipcRenderer.invoke("getServerUrl") as Promise, + + showServerPicker: () => ipcRenderer.invoke("showServerPicker") as Promise, });