feat: initial commit

This commit is contained in:
izzy
2025-09-30 10:31:37 -05:00
commit 7ab6795a88
31 changed files with 7913 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import AutoLaunch from "auto-launch";
import { ipcMain } from "electron";
import { mainWindow } from "./window";
export const autoLaunch = new AutoLaunch({
name: "Revolt",
});
ipcMain.on("isAutostart?", () =>
autoLaunch
.isEnabled()
.then((enabled) => mainWindow.webContents.send("isAutostart", enabled)),
);
ipcMain.on("setAutostart", (state) =>
state ? autoLaunch.enable() : autoLaunch.disable(),
);
+66
View File
@@ -0,0 +1,66 @@
import dbus from "@homebridge/dbus-native";
import { resolve } from "node:path";
import { NativeImage, app, nativeImage } from "electron";
import { mainWindow } from "./window";
// internal state
const nativeIcons: Record<number, NativeImage> = {};
let sessionBus: dbus.MessageBus | null;
export async function setBadgeCount(count: number) {
switch (process.platform) {
case "win32":
case "linux":
if (count === 0) {
mainWindow.setOverlayIcon(null, "No Notifications");
break;
}
if (!nativeIcons[count])
nativeIcons[count] = nativeImage.createFromPath(
resolve(process.resourcesPath, `${Math.min(count, 10)}.ico`),
);
mainWindow.setOverlayIcon(
nativeIcons[count],
count === -1 ? `Unread Messages` : `${count} Notifications`,
);
break;
// @ts-expect-error this is `linux` block
case "_": // todo: try to get this to work
// send D-Bus message
// @ts-expect-error undocumented API
if (!sessionBus) sessionBus = dbus.sessionBus();
// @ts-expect-error undocumented API
sessionBus.connection.message({
// @ts-expect-error undocumented API
type: dbus.messageType.signal,
serial: 1,
path: "/",
interface: "com.canonical.Unity.LauncherEntry",
member: "Update",
signature: "sa{sv}",
body: [
process.env.container === "1"
? "application://chat.stoat.stoat-desktop.desktop" // flatpak handling
: "application://stoat-desktop.desktop",
[
["count", ["x", Math.min(count, 0)]],
["count-visible", ["b", count !== 0]],
],
],
});
break;
case "darwin":
app.dock.setBadge(
count === -1 ? "•" : count === 0 ? "" : count.toString(),
);
break;
}
}
+168
View File
@@ -0,0 +1,168 @@
import { type JSONSchema } from "json-schema-typed";
import { ipcMain } from "electron";
import Store from "electron-store";
import { destroyDiscordRpc, initDiscordRpc } from "./discordRpc";
import { mainWindow } from "./window";
const schema = {
firstLaunch: {
type: "boolean",
} as JSONSchema.Boolean,
customFrame: {
type: "boolean",
} as JSONSchema.Boolean,
minimiseToTray: {
type: "boolean",
} as JSONSchema.Boolean,
spellchecker: {
type: "boolean",
} as JSONSchema.Boolean,
hardwareAcceleration: {
type: "boolean",
} as JSONSchema.Boolean,
discordRpc: {
type: "boolean",
} as JSONSchema.Boolean,
windowState: {
type: "object",
properties: {
// x: {
// type: 'number'
// } as JSONSchema.Number,
// y: {
// type: 'number'
// } as JSONSchema.Number,
// width: {
// type: 'number'
// } as JSONSchema.Number,
// height: {
// type: 'number'
// } as JSONSchema.Number,
isMaximised: {
type: "boolean",
} as JSONSchema.Boolean,
},
} as JSONSchema.Object,
};
const store = new Store({
schema,
defaults: {
firstLaunch: true,
customFrame: true,
minimiseToTray: true,
spellchecker: true,
hardwareAcceleration: true,
discordRpc: true,
windowState: {
isMaximised: false,
},
} as DesktopConfig,
});
/**
* Shim for `electron-store` because typings are broken
*/
class Config {
get firstLaunch() {
return (store as never as { get(k: string): boolean }).get("firstLaunch");
}
set firstLaunch(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"firstLaunch",
value,
);
}
get customFrame() {
return (store as never as { get(k: string): boolean }).get("customFrame");
}
set customFrame(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"customFrame",
value,
);
}
get minimiseToTray() {
return (store as never as { get(k: string): boolean }).get(
"minimiseToTray",
);
}
set minimiseToTray(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"minimiseToTray",
value,
);
}
get spellchecker() {
return (store as never as { get(k: string): boolean }).get("spellchecker");
}
set spellchecker(value: boolean) {
mainWindow.webContents.session.setSpellCheckerEnabled(value);
(store as never as { set(k: string, value: boolean): void }).set(
"spellchecker",
value,
);
}
get hardwareAcceleration() {
return (store as never as { get(k: string): boolean }).get(
"hardwareAcceleration",
);
}
set hardwareAcceleration(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"hardwareAcceleration",
value,
);
}
get discordRpc() {
return (store as never as { get(k: string): boolean }).get("discordRpc");
}
set discordRpc(value: boolean) {
if (value) {
initDiscordRpc();
} else {
destroyDiscordRpc();
}
(store as never as { set(k: string, value: boolean): void }).set(
"discordRpc",
value,
);
}
get windowState() {
return (
store as never as { get(k: string): DesktopConfig["windowState"] }
).get("windowState");
}
set windowState(value: DesktopConfig["windowState"]) {
(
store as never as {
set(k: string, value: DesktopConfig["windowState"]): void;
}
).set("windowState", value);
}
}
export const config = new Config();
ipcMain.on("config", (newConfig) =>
Object.entries(newConfig).forEach(
([key, value]) => (config[key as keyof DesktopConfig] = value),
),
);
+42
View File
@@ -0,0 +1,42 @@
import { Client } from "discord-rpc";
import { config } from "./config";
// internal state
let rpc: Client;
export async function initDiscordRpc() {
if (!config.discordRpc) return;
try {
rpc = new Client({ transport: "ipc" });
rpc.on("ready", () =>
rpc.setActivity({
state: "stoat.chat",
details: "Chatting with others",
largeImageKey: "qr",
// largeImageText: "Communication is critical use Revolt.",
largeImageText: "",
buttons: [
{
label: "Join Stoat",
url: "https://stoat.chat/",
},
],
}),
);
rpc.on("disconnected", reconnect);
rpc.login({ clientId: "872068124005007420" });
} catch (err) {
reconnect();
}
}
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
export async function destroyDiscordRpc() {
rpc?.destroy();
}
+60
View File
@@ -0,0 +1,60 @@
import { resolve } from "node:path";
import { Menu, Tray, nativeImage } from "electron";
import { version } from "../../package.json";
import { mainWindow, quitApp } from "./window";
// internal tray state
let tray: Tray = null;
// load the tray icon
const trayIcon = nativeImage.createFromPath(
resolve(process.resourcesPath, "icon.png"),
);
// trayIcon.setTemplateImage(true);
export function initTray() {
tray = new Tray(trayIcon);
updateTrayMenu();
tray.setToolTip("Stoat for Desktop");
tray.setImage(trayIcon);
}
export function updateTrayMenu() {
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Stoat for Desktop", type: "normal", enabled: false },
{
label: "Version",
type: "submenu",
submenu: Menu.buildFromTemplate([
{
label: version,
type: "normal",
enabled: false,
},
]),
},
{ type: "separator" },
{
label: mainWindow.isVisible() ? "Hide App" : "Show App",
type: "normal",
click() {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
},
},
{
label: "Quit App",
type: "normal",
click: quitApp,
},
]),
);
}
+174
View File
@@ -0,0 +1,174 @@
import { join, resolve } from "node:path";
import {
BrowserWindow,
Menu,
MenuItem,
app,
ipcMain,
nativeImage,
} from "electron";
import { setBadgeCount } from "./badges";
import { config } from "./config";
import { updateTrayMenu } from "./tray";
// 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"),
);
// internal window state
let shouldQuit = false;
// load the window icon
const windowIcon = nativeImage.createFromPath(
resolve(process.resourcesPath, "icon.png"),
);
console.info(resolve(process.resourcesPath, "icon.png"));
// windowIcon.setTemplateImage(true);
/**
* Create the main application window
*/
export function createMainWindow() {
// create the window
mainWindow = new BrowserWindow({
minWidth: 300,
minHeight: 300,
width: 800,
height: 600,
backgroundColor: "#191919",
frame: !config.customFrame,
icon: windowIcon,
webPreferences: {
// relative to `.vite/build`
preload: join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
spellcheck: true,
},
});
// maximise the window if it was maximised before
if (config.windowState.isMaximised) {
mainWindow.maximize();
}
// load the entrypoint
mainWindow.loadURL(BUILD_URL.toString());
// minimise window to tray
mainWindow.on("close", (event) => {
if (!shouldQuit && config.minimiseToTray) {
event.preventDefault();
mainWindow.hide();
}
});
// update tray menu when window is shown/hidden
mainWindow.on("show", updateTrayMenu);
mainWindow.on("hide", updateTrayMenu);
// keep track of window state
function generateState() {
config.windowState = {
isMaximised: mainWindow.isMaximized(),
};
}
mainWindow.on("maximize", generateState);
mainWindow.on("unmaximize", generateState);
// rebind zoom controls to be more sensible
mainWindow.webContents.on("before-input-event", (event, input) => {
if (input.control && input.key === "=") {
// zoom in (+)
event.preventDefault();
mainWindow.webContents.setZoomLevel(
mainWindow.webContents.getZoomLevel() + 1,
);
} else if (input.control && input.key === "-") {
// zoom out (-)
event.preventDefault();
mainWindow.webContents.setZoomLevel(
mainWindow.webContents.getZoomLevel() - 1,
);
}
});
// configure spellchecker context menu
mainWindow.webContents.on("context-menu", (_, params) => {
const menu = new Menu();
// add all suggestions
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => mainWindow.webContents.replaceMisspelling(suggestion),
}),
);
}
// allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: "Add to dictionary",
click: () =>
mainWindow.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord,
),
}),
);
}
// add an option to toggle spellchecker
menu.append(
new MenuItem({
label: "Toggle spellcheck",
click() {
config.spellchecker = !config.spellchecker;
},
}),
);
// show menu if we've generated enough entries
if (menu.items.length > 0) {
menu.popup();
}
});
// push world events to the window
ipcMain.on("minimise", () => mainWindow.minimize());
ipcMain.on("maximise", () =>
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize(),
);
ipcMain.on("close", () => mainWindow.close());
// mainWindow.webContents.openDevTools();
let i = 0;
setInterval(() => setBadgeCount((++i % 30) + 1), 1000);
}
/**
* Quit the entire app
*/
export function quitApp() {
shouldQuit = true;
mainWindow.close();
}
// Ensure global app quit works properly
app.on("before-quit", () => {
shouldQuit = true;
});