Add UI to pick a server
Signed-off-by: Elereman <123456mayday@gmail.com>
This commit is contained in:
parent
b57faa2c59
commit
5052e278ac
|
|
@ -159,7 +159,12 @@ const config: ForgeConfig = {
|
||||||
target: "preload",
|
target: "preload",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
renderer: [],
|
renderer: [
|
||||||
|
{
|
||||||
|
name: "main_window",
|
||||||
|
config: "vite.renderer.config.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
// Fuses are used to enable/disable various Electron functionality
|
||||||
// at package time, before code signing the application
|
// at package time, before code signing the application
|
||||||
|
|
|
||||||
58
index.html
58
index.html
|
|
@ -1,12 +1,62 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Hello World!</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Stoat — Select Server</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>💖 Hello World!</h1>
|
<div class="titlebar" id="titlebar">
|
||||||
<p>Welcome to your Electron application.</p>
|
<span class="titlebar-title">Stoat Desktop</span>
|
||||||
|
<div class="window-controls">
|
||||||
|
<button class="wc-btn" id="btn-minimise" title="Minimise">–</button>
|
||||||
|
<button class="wc-btn" id="btn-maximise" title="Maximise">□</button>
|
||||||
|
<button class="wc-btn wc-close" id="btn-close" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">🦔</div>
|
||||||
|
<h1>Stoat Desktop</h1>
|
||||||
|
<p class="subtitle">Connect to a Revolt-compatible server</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-options">
|
||||||
|
<button class="server-option" id="opt-official" data-url="https://beta.revolt.chat">
|
||||||
|
<div class="server-info">
|
||||||
|
<span class="server-name">Official Server</span>
|
||||||
|
<span class="server-url">beta.revolt.chat</span>
|
||||||
|
</div>
|
||||||
|
<span class="check-icon">✓</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="server-option" id="opt-custom">
|
||||||
|
<div class="server-info">
|
||||||
|
<span class="server-name">Custom Server</span>
|
||||||
|
<span class="server-url" id="custom-url-preview">Enter URL below</span>
|
||||||
|
</div>
|
||||||
|
<span class="check-icon">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group" id="custom-input-group">
|
||||||
|
<label for="server-url-input">Server URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="server-url-input"
|
||||||
|
placeholder="https://your.server.tld"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<span class="input-error" id="url-error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="connect-btn" id="connect-btn">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/renderer.ts"></script>
|
<script type="module" src="/src/renderer.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare type DesktopConfig = {
|
||||||
spellchecker: boolean;
|
spellchecker: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
discordRpc: boolean;
|
discordRpc: boolean;
|
||||||
|
customServer: string;
|
||||||
windowState: {
|
windowState: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|
|
||||||
284
src/index.css
284
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 {
|
body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
margin: auto;
|
font-size: 14px;
|
||||||
max-width: 38rem;
|
line-height: 1.5;
|
||||||
padding: 2rem;
|
-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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/main.ts
15
src/main.ts
|
|
@ -1,13 +1,13 @@
|
||||||
import { IUpdateInfo, updateElectronApp } from "update-electron-app";
|
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 started from "electron-squirrel-startup";
|
||||||
|
|
||||||
import { autoLaunch } from "./native/autoLaunch";
|
import { autoLaunch } from "./native/autoLaunch";
|
||||||
import { config } from "./native/config";
|
import { config } from "./native/config";
|
||||||
import { initDiscordRpc } from "./native/discordRpc";
|
import { initDiscordRpc } from "./native/discordRpc";
|
||||||
import { initTray } from "./native/tray";
|
import { initTray } from "./native/tray";
|
||||||
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
|
import { getBuildURL, createMainWindow, loadServerPicker, mainWindow } from "./native/window";
|
||||||
|
|
||||||
// Squirrel-specific logic
|
// Squirrel-specific logic
|
||||||
// create/remove shortcuts on Windows when installing / uninstalling
|
// create/remove shortcuts on Windows when installing / uninstalling
|
||||||
|
|
@ -54,6 +54,10 @@ if (acquiredLock) {
|
||||||
initTray();
|
initTray();
|
||||||
initDiscordRpc();
|
initDiscordRpc();
|
||||||
|
|
||||||
|
globalShortcut.register("CmdOrCtrl+Shift+S", () => {
|
||||||
|
loadServerPicker(mainWindow);
|
||||||
|
});
|
||||||
|
|
||||||
// Windows specific fix for notifications
|
// Windows specific fix for notifications
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("chat.stoat.notifications");
|
app.setAppUserModelId("chat.stoat.notifications");
|
||||||
|
|
@ -87,9 +91,12 @@ if (acquiredLock) {
|
||||||
|
|
||||||
// ensure URLs launch in external context
|
// ensure URLs launch in external context
|
||||||
app.on("web-contents-created", (_, contents) => {
|
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) => {
|
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();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ const schema = {
|
||||||
discordRpc: {
|
discordRpc: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
} as JSONSchema.Boolean,
|
} as JSONSchema.Boolean,
|
||||||
|
customServer: {
|
||||||
|
type: "string",
|
||||||
|
} as JSONSchema.String,
|
||||||
windowState: {
|
windowState: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -60,6 +63,7 @@ const store = new Store({
|
||||||
spellchecker: true,
|
spellchecker: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
discordRpc: true,
|
discordRpc: true,
|
||||||
|
customServer: "",
|
||||||
windowState: {
|
windowState: {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|
@ -83,6 +87,7 @@ class Config {
|
||||||
spellchecker: this.spellchecker,
|
spellchecker: this.spellchecker,
|
||||||
hardwareAcceleration: this.hardwareAcceleration,
|
hardwareAcceleration: this.hardwareAcceleration,
|
||||||
discordRpc: this.discordRpc,
|
discordRpc: this.discordRpc,
|
||||||
|
customServer: this.customServer,
|
||||||
windowState: this.windowState,
|
windowState: this.windowState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +197,19 @@ class Config {
|
||||||
this.sync();
|
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() {
|
get windowState() {
|
||||||
return (
|
return (
|
||||||
store as never as { get(k: string): DesktopConfig["windowState"] }
|
store as never as { get(k: string): DesktopConfig["windowState"] }
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export async function initDiscordRpc() {
|
||||||
|
|
||||||
rpc.on("disconnected", reconnect);
|
rpc.on("disconnected", reconnect);
|
||||||
|
|
||||||
rpc.login({ clientId: "872068124005007420" });
|
await rpc.login({ clientId: "872068124005007420" });
|
||||||
} catch (err) {
|
} catch {
|
||||||
reconnect();
|
reconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import trayIconAsset from "../../assets/desktop/icon.png?asset";
|
||||||
import macOsTrayIconAsset from "../../assets/desktop/iconTemplate.png?asset";
|
import macOsTrayIconAsset from "../../assets/desktop/iconTemplate.png?asset";
|
||||||
import { version } from "../../package.json";
|
import { version } from "../../package.json";
|
||||||
|
|
||||||
import { mainWindow, quitApp } from "./window";
|
import { mainWindow, quitApp, getBuildURL, loadServerPicker } from "./window";
|
||||||
|
|
||||||
// internal tray state
|
// internal tray state
|
||||||
let tray: Tray = null;
|
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",
|
label: "Quit App",
|
||||||
type: "normal",
|
type: "normal",
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,22 @@ import windowIconAsset from "../../assets/desktop/icon.png?asset";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { updateTrayMenu } from "./tray";
|
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
|
// global reference to main window
|
||||||
export let mainWindow: BrowserWindow;
|
export let mainWindow: BrowserWindow;
|
||||||
|
|
||||||
// currently in-use build
|
// currently in-use server URL — initialized inside createMainWindow() to avoid
|
||||||
export const BUILD_URL = new URL(
|
// the circular config ↔ window import being evaluated before both modules are ready
|
||||||
app.commandLine.hasSwitch("force-server")
|
let _buildUrl: URL;
|
||||||
? app.commandLine.getSwitchValue("force-server")
|
|
||||||
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://beta.revolt.chat",
|
export function getBuildURL(): URL {
|
||||||
);
|
return _buildUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// internal window state
|
// internal window state
|
||||||
let shouldQuit = false;
|
let shouldQuit = false;
|
||||||
|
|
@ -30,12 +37,90 @@ let shouldQuit = false;
|
||||||
// load the window icon
|
// load the window icon
|
||||||
const windowIcon = nativeImage.createFromDataURL(windowIconAsset);
|
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 16V4m0 0L3 8m4-4l4 4"/><path d="M17 8v12m0 0l4-4m-4 4l-4-4"/></svg><span>Switch Server</span>';
|
||||||
|
|
||||||
|
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
|
* Create the main application window
|
||||||
*/
|
*/
|
||||||
export function createMainWindow() {
|
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)
|
// (CLI arg --hidden or config)
|
||||||
const startHidden =
|
const startHidden =
|
||||||
app.commandLine.hasSwitch("hidden") || config.startMinimisedToTray;
|
app.commandLine.hasSwitch("hidden") || config.startMinimisedToTray;
|
||||||
|
|
@ -46,7 +131,7 @@ export function createMainWindow() {
|
||||||
minHeight: 300,
|
minHeight: 300,
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
backgroundColor: "#191919",
|
backgroundColor: "#111114",
|
||||||
frame: !config.customFrame,
|
frame: !config.customFrame,
|
||||||
icon: windowIcon,
|
icon: windowIcon,
|
||||||
show: !startHidden,
|
show: !startHidden,
|
||||||
|
|
@ -83,8 +168,14 @@ export function createMainWindow() {
|
||||||
mainWindow.maximize();
|
mainWindow.maximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
// load the entrypoint
|
// show server picker on first launch / when no server is saved,
|
||||||
mainWindow.loadURL(BUILD_URL.toString());
|
// 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
|
// minimise window to tray
|
||||||
mainWindow.on("close", (event) => {
|
mainWindow.on("close", (event) => {
|
||||||
|
|
@ -141,8 +232,21 @@ export function createMainWindow() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// send the config
|
// send the config, then inject the switch-server button when on the chat page
|
||||||
mainWindow.webContents.on("did-finish-load", () => config.sync());
|
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
|
// configure spellchecker context menu
|
||||||
mainWindow.webContents.on("context-menu", (_, params) => {
|
mainWindow.webContents.on("context-menu", (_, params) => {
|
||||||
|
|
@ -193,13 +297,33 @@ export function createMainWindow() {
|
||||||
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize(),
|
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize(),
|
||||||
);
|
);
|
||||||
ipcMain.on("close", () => mainWindow.close());
|
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
|
* Quit the entire app
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
170
src/renderer.ts
170
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";
|
import "./index.css";
|
||||||
|
|
||||||
console.log(
|
const DEFAULT_SERVER = "https://beta.revolt.chat";
|
||||||
'👋 This message is being logged by "renderer.ts", included via Vite',
|
|
||||||
);
|
type NativeAPI = {
|
||||||
|
minimise: () => void;
|
||||||
|
maximise: () => void;
|
||||||
|
close: () => void;
|
||||||
|
navigate: (url: string) => Promise<boolean>;
|
||||||
|
getServerUrl: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,12 @@ contextBridge.exposeInMainWorld("native", {
|
||||||
close: () => ipcRenderer.send("close"),
|
close: () => ipcRenderer.send("close"),
|
||||||
|
|
||||||
setBadgeCount: (count: number) => ipcRenderer.send("setBadgeCount", count),
|
setBadgeCount: (count: number) => ipcRenderer.send("setBadgeCount", count),
|
||||||
|
|
||||||
|
navigate: (url: string) =>
|
||||||
|
ipcRenderer.invoke("navigate", url) as Promise<boolean>,
|
||||||
|
|
||||||
|
getServerUrl: () =>
|
||||||
|
ipcRenderer.invoke("getServerUrl") as Promise<string>,
|
||||||
|
|
||||||
|
showServerPicker: () => ipcRenderer.invoke("showServerPicker") as Promise<void>,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue