feat: initial commit
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user