diff --git a/src/main/appBadge.ts b/src/main/appBadge.ts index 66ad70b..969caf3 100644 --- a/src/main/appBadge.ts +++ b/src/main/appBadge.ts @@ -8,6 +8,8 @@ import { app, NativeImage, nativeImage } from "electron"; import { join } from "path"; import { BADGE_DIR } from "shared/paths"; +import { AppEvents } from "./events"; + const imgCache = new Map(); function loadBadge(index: number) { const cached = imgCache.get(index); @@ -21,7 +23,13 @@ function loadBadge(index: number) { let lastIndex: null | number = -1; +/** + * -1 = show unread indicator + * 0 = clear + */ export function setBadgeCount(count: number) { + AppEvents.emit("setTrayVariant", count !== 0 ? "trayUnread" : "tray"); + switch (process.platform) { case "linux": if (count === -1) count = 0; diff --git a/src/main/events.ts b/src/main/events.ts new file mode 100644 index 0000000..f331a53 --- /dev/null +++ b/src/main/events.ts @@ -0,0 +1,15 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { EventEmitter } from "events"; + +import { UserAssetType } from "./userAssets"; + +export const AppEvents = new EventEmitter<{ + appLoaded: []; + userAssetChanged: [UserAssetType]; + setTrayVariant: ["tray" | "trayUnread"]; +}>(); diff --git a/src/main/index.ts b/src/main/index.ts index ee00269..9952fe7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ */ import "./ipc"; +import "./userAssets"; import { app, BrowserWindow, nativeTheme } from "electron"; import { autoUpdater } from "electron-updater"; diff --git a/src/main/mainWindow.ts b/src/main/mainWindow.ts index 2f772c2..063ab29 100644 --- a/src/main/mainWindow.ts +++ b/src/main/mainWindow.ts @@ -8,45 +8,33 @@ import { app, BrowserWindow, BrowserWindowConstructorOptions, - dialog, Menu, MenuItemConstructorOptions, nativeTheme, screen, - session, - Tray + session } from "electron"; -import { EventEmitter } from "events"; -import { rm } from "fs/promises"; import { join } from "path"; import { IpcCommands, IpcEvents } from "shared/IpcEvents"; import { isTruthy } from "shared/utils/guards"; import { once } from "shared/utils/once"; import type { SettingsStore } from "shared/utils/SettingsStore"; -import { TRAY_ICON_PATH } from "../shared/paths"; import { createAboutWindow } from "./about"; import { initArRPC } from "./arrpc"; -import { - BrowserUserAgent, - DATA_DIR, - DEFAULT_HEIGHT, - DEFAULT_WIDTH, - MessageBoxChoice, - MIN_HEIGHT, - MIN_WIDTH, - VENCORD_FILES_DIR -} from "./constants"; +import { BrowserUserAgent, DEFAULT_HEIGHT, DEFAULT_WIDTH, MIN_HEIGHT, MIN_WIDTH, VENCORD_FILES_DIR } from "./constants"; +import { AppEvents } from "./events"; import { darwinURL } from "./index"; import { sendRendererCommand } from "./ipcCommands"; import { Settings, State, VencordSettings } from "./settings"; import { createSplashWindow, updateSplashMessage } from "./splash"; +import { destroyTray, initTray } from "./tray"; +import { clearData } from "./utils/clearData"; import { makeLinksOpenExternally } from "./utils/makeLinksOpenExternally"; import { applyDeckKeyboardFix, askToApplySteamLayout, isDeckGameMode } from "./utils/steamOS"; import { downloadVencordFiles, ensureVencordFiles } from "./utils/vencordLoader"; let isQuitting = false; -let tray: Tray; applyDeckKeyboardFix(); @@ -77,84 +65,6 @@ function makeSettingsListenerHelpers(o: SettingsStore) { const [addSettingsListener, removeSettingsListeners] = makeSettingsListenerHelpers(Settings); const [addVencordSettingsListener, removeVencordSettingsListeners] = makeSettingsListenerHelpers(VencordSettings); -function initTray(win: BrowserWindow) { - const onTrayClick = () => { - if (Settings.store.clickTrayToShowHide && win.isVisible()) win.hide(); - else win.show(); - }; - const trayMenu = Menu.buildFromTemplate([ - { - label: "Open", - click() { - win.show(); - } - }, - { - label: "About", - click: createAboutWindow - }, - { - label: "Repair Vencord", - async click() { - await downloadVencordFiles(); - app.relaunch(); - app.quit(); - } - }, - { - label: "Reset Vesktop", - async click() { - await clearData(win); - } - }, - { - type: "separator" - }, - { - label: "Restart", - click() { - app.relaunch(); - app.quit(); - } - }, - { - label: "Quit", - click() { - isQuitting = true; - app.quit(); - } - } - ]); - - tray = new Tray(join(TRAY_ICON_PATH, `${process.platform === "darwin" ? "trayTemplate" : "tray"}.png`)); - tray.setToolTip("Vesktop"); - tray.setContextMenu(trayMenu); - tray.on("click", onTrayClick); -} - -async function clearData(win: BrowserWindow) { - const { response } = await dialog.showMessageBox(win, { - message: "Are you sure you want to reset Vesktop?", - detail: "This will log you out, clear caches and reset all your settings!\n\nVesktop will automatically restart after this operation.", - buttons: ["Yes", "No"], - cancelId: MessageBoxChoice.Cancel, - defaultId: MessageBoxChoice.Default, - type: "warning" - }); - - if (response === MessageBoxChoice.Cancel) return; - - win.close(); - - await win.webContents.session.clearStorageData(); - await win.webContents.session.clearCache(); - await win.webContents.session.clearCodeCaches({}); - await rm(DATA_DIR, { force: true, recursive: true }); - - app.relaunch(); - app.quit(); -} - type MenuItemList = Array; function initMenuBar(win: BrowserWindow) { @@ -333,8 +243,8 @@ function initWindowBoundsListeners(win: BrowserWindow) { function initSettingsListeners(win: BrowserWindow) { addSettingsListener("tray", enable => { - if (enable) initTray(win); - else tray?.destroy(); + if (enable) initTray(win, q => (isQuitting = q)); + else destroyTray(); }); addSettingsListener("disableMinSize", disable => { @@ -476,7 +386,9 @@ function createMainWindow() { }); initWindowBoundsListeners(win); - if (!isDeckGameMode && (Settings.store.tray ?? true) && process.platform !== "darwin") initTray(win); + if (!isDeckGameMode && (Settings.store.tray ?? true) && process.platform !== "darwin") + initTray(win, q => (isQuitting = q)); + initMenuBar(win); makeLinksOpenExternally(win); initSettingsListeners(win); @@ -496,8 +408,6 @@ function createMainWindow() { const runVencordMain = once(() => require(join(VENCORD_FILES_DIR, "vencordDesktopMain.js"))); -const loadEvents = new EventEmitter(); - export function loadUrl(uri: string | undefined) { const branch = Settings.store.discordBranch; const subdomain = branch === "canary" || branch === "ptb" ? `${branch}.` : ""; @@ -505,7 +415,7 @@ export function loadUrl(uri: string | undefined) { // we do not rely on 'did-finish-load' because it fires even if loadURL fails which triggers early detruction of the splash mainWin .loadURL(`https://${subdomain}discord.com/${uri ? new URL(uri).pathname.slice(1) || "app" : "app"}`) - .then(() => loadEvents.emit("app-loaded")) + .then(() => AppEvents.emit("appLoaded")) .catch(error => retryUrl(error.url, error.code)); } @@ -532,7 +442,7 @@ export async function createWindows() { mainWin = createMainWindow(); - loadEvents.on("app-loaded", () => { + AppEvents.on("appLoaded", () => { splash?.destroy(); if (!startMinimized) { diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..f58a719 --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,92 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app, BrowserWindow, Menu, Tray } from "electron"; + +import { createAboutWindow } from "./about"; +import { AppEvents } from "./events"; +import { Settings } from "./settings"; +import { resolveAssetPath } from "./userAssets"; +import { clearData } from "./utils/clearData"; +import { downloadVencordFiles } from "./utils/vencordLoader"; + +let tray: Tray; +let trayVariant: "tray" | "trayUnread" = "tray"; + +AppEvents.on("userAssetChanged", async asset => { + if (tray && (asset === "tray" || asset === "trayUnread")) { + tray.setImage(await resolveAssetPath(trayVariant)); + } +}); + +AppEvents.on("setTrayVariant", async variant => { + if (trayVariant === variant) return; + + trayVariant = variant; + if (!tray) return; + + tray.setImage(await resolveAssetPath(trayVariant)); +}); + +export function destroyTray() { + tray?.destroy(); +} + +export async function initTray(win: BrowserWindow, setIsQuitting: (val: boolean) => void) { + const onTrayClick = () => { + if (Settings.store.clickTrayToShowHide && win.isVisible()) win.hide(); + else win.show(); + }; + + const trayMenu = Menu.buildFromTemplate([ + { + label: "Open", + click() { + win.show(); + } + }, + { + label: "About", + click: createAboutWindow + }, + { + label: "Repair Vencord", + async click() { + await downloadVencordFiles(); + app.relaunch(); + app.quit(); + } + }, + { + label: "Reset Vesktop", + async click() { + await clearData(win); + } + }, + { + type: "separator" + }, + { + label: "Restart", + click() { + app.relaunch(); + app.quit(); + } + }, + { + label: "Quit", + click() { + setIsQuitting(true); + app.quit(); + } + } + ]); + + tray = new Tray(await resolveAssetPath(trayVariant)); + tray.setToolTip("Vesktop"); + tray.setContextMenu(trayMenu); + tray.on("click", onTrayClick); +} diff --git a/src/main/userAssets.ts b/src/main/userAssets.ts new file mode 100644 index 0000000..51219ce --- /dev/null +++ b/src/main/userAssets.ts @@ -0,0 +1,110 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app, dialog, net, protocol } from "electron"; +import { copyFile, mkdir, rm } from "fs/promises"; +import { join } from "path"; +import { IpcEvents } from "shared/IpcEvents"; +import { STATIC_DIR } from "shared/paths"; +import { pathToFileURL } from "url"; + +import { DATA_DIR } from "./constants"; +import { AppEvents } from "./events"; +import { mainWin } from "./mainWindow"; +import { fileExistsAsync } from "./utils/fileExists"; +import { handle } from "./utils/ipcWrappers"; + +const CUSTOMIZABLE_ASSETS = ["splash", "tray", "trayUnread"] as const; +export type UserAssetType = (typeof CUSTOMIZABLE_ASSETS)[number]; + +const DEFAULT_ASSETS: Record = { + splash: "splash.webp", + tray: `tray/${process.platform === "darwin" ? "trayTemplate" : "tray"}.png`, + trayUnread: "tray/trayUnread.png" +}; + +const UserAssetFolder = join(DATA_DIR, "userAssets"); + +export async function resolveAssetPath(asset: UserAssetType) { + if (!CUSTOMIZABLE_ASSETS.includes(asset)) { + throw new Error(`Invalid asset: ${asset}`); + } + + const assetPath = join(UserAssetFolder, asset); + if (await fileExistsAsync(assetPath)) { + return assetPath; + } + + return join(STATIC_DIR, DEFAULT_ASSETS[asset]); +} + +app.whenReady().then(() => { + protocol.handle("vesktop", async req => { + if (!req.url.startsWith("vesktop://assets/")) { + return new Response(null, { status: 404 }); + } + + const asset = decodeURI(req.url) + .slice("vesktop://assets/".length) + .replace(/\?v=\d+$/, "") + .replace(/\/+$/, ""); + + // @ts-expect-error dumb types + if (!CUSTOMIZABLE_ASSETS.includes(asset)) { + return new Response(null, { status: 404 }); + } + + try { + const res = await net.fetch(pathToFileURL(join(UserAssetFolder, asset)).href); + if (res.ok) return res; + } catch {} + + return net.fetch(pathToFileURL(join(STATIC_DIR, DEFAULT_ASSETS[asset])).href); + }); +}); + +handle(IpcEvents.CHOOSE_USER_ASSET, async (_event, asset: UserAssetType, value?: null) => { + if (!CUSTOMIZABLE_ASSETS.includes(asset)) { + throw `Invalid asset: ${asset}`; + } + + const assetPath = join(UserAssetFolder, asset); + + if (value === null) { + try { + await rm(assetPath, { force: true }); + AppEvents.emit("userAssetChanged", asset); + return "ok"; + } catch (e) { + console.error(`Failed to remove user asset ${asset}:`, e); + return "failed"; + } + } + + const res = await dialog.showOpenDialog(mainWin, { + properties: ["openFile"], + title: `Select an image to use as ${asset}`, + defaultPath: app.getPath("pictures"), + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif", "svg"] + } + ] + }); + + if (res.canceled || !res.filePaths.length) return "cancelled"; + + try { + await mkdir(UserAssetFolder, { recursive: true }); + await copyFile(res.filePaths[0], assetPath); + AppEvents.emit("userAssetChanged", asset); + return "ok"; + } catch (e) { + console.error(`Failed to copy user asset ${asset}:`, e); + return "failed"; + } +}); diff --git a/src/main/utils/clearData.ts b/src/main/utils/clearData.ts new file mode 100644 index 0000000..6ce5c89 --- /dev/null +++ b/src/main/utils/clearData.ts @@ -0,0 +1,32 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app, BrowserWindow, dialog } from "electron"; +import { rm } from "fs/promises"; +import { DATA_DIR, MessageBoxChoice } from "main/constants"; + +export async function clearData(win: BrowserWindow) { + const { response } = await dialog.showMessageBox(win, { + message: "Are you sure you want to reset Vesktop?", + detail: "This will log you out, clear caches and reset all your settings!\n\nVesktop will automatically restart after this operation.", + buttons: ["Yes", "No"], + cancelId: MessageBoxChoice.Cancel, + defaultId: MessageBoxChoice.Default, + type: "warning" + }); + + if (response === MessageBoxChoice.Cancel) return; + + win.close(); + + await win.webContents.session.clearStorageData(); + await win.webContents.session.clearCache(); + await win.webContents.session.clearCodeCaches({}); + await rm(DATA_DIR, { force: true, recursive: true }); + + app.relaunch(); + app.quit(); +} diff --git a/src/main/utils/fileExists.ts b/src/main/utils/fileExists.ts new file mode 100644 index 0000000..ce73efb --- /dev/null +++ b/src/main/utils/fileExists.ts @@ -0,0 +1,13 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { access, constants } from "fs/promises"; + +export async function fileExistsAsync(path: string) { + return await access(path, constants.F_OK) + .then(() => true) + .catch(() => false); +} diff --git a/src/preload/VesktopNative.ts b/src/preload/VesktopNative.ts index 1525965..5847f65 100644 --- a/src/preload/VesktopNative.ts +++ b/src/preload/VesktopNative.ts @@ -42,7 +42,9 @@ export const VesktopNative = { fileManager: { showItemInFolder: (path: string) => invoke(IpcEvents.SHOW_ITEM_IN_FOLDER, path), getVencordDir: () => sendSync(IpcEvents.GET_VENCORD_DIR), - selectVencordDir: (value?: null) => invoke<"cancelled" | "invalid" | "ok">(IpcEvents.SELECT_VENCORD_DIR, value) + selectVencordDir: (value?: null) => invoke<"cancelled" | "invalid" | "ok">(IpcEvents.SELECT_VENCORD_DIR, value), + chooseUserAsset: (asset: string, value?: null) => + invoke<"cancelled" | "invalid" | "ok" | "failed">(IpcEvents.CHOOSE_USER_ASSET, asset, value) }, settings: { get: () => sendSync(IpcEvents.GET_SETTINGS), diff --git a/src/renderer/components/settings/Settings.tsx b/src/renderer/components/settings/Settings.tsx index 45e0a22..4b45b65 100644 --- a/src/renderer/components/settings/Settings.tsx +++ b/src/renderer/components/settings/Settings.tsx @@ -16,6 +16,7 @@ import { AutoStartToggle } from "./AutoStartToggle"; import { DeveloperOptionsButton } from "./DeveloperOptions"; import { DiscordBranchPicker } from "./DiscordBranchPicker"; import { NotificationBadgeToggle } from "./NotificationBadgeToggle"; +import { UserAssetsButton } from "./UserAssets"; import { VesktopSettingsSwitch } from "./VesktopSettingsSwitch"; import { WindowsTransparencyControls } from "./WindowsTransparencyControls"; @@ -82,7 +83,8 @@ const SettingsOptions: Record> description: "Adapt the splash window colors to your custom theme", defaultValue: true }, - WindowsTransparencyControls + WindowsTransparencyControls, + UserAssetsButton ], Behaviour: [ { diff --git a/src/renderer/components/settings/UserAssets.css b/src/renderer/components/settings/UserAssets.css new file mode 100644 index 0000000..5ad8cb0 --- /dev/null +++ b/src/renderer/components/settings/UserAssets.css @@ -0,0 +1,25 @@ +.vcd-user-assets { + display: flex; + margin-block: 1em 2em; + flex-direction: column; + gap: 1em; +} + +.vcd-user-assets-asset { + display: flex; + margin-top: 0.5em; + align-items: center; + gap: 1em; +} + +.vcd-user-assets-actions { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.vcd-user-assets-image { + height: 7.5em; + width: 7.5em; + object-fit: contain; +} \ No newline at end of file diff --git a/src/renderer/components/settings/UserAssets.tsx b/src/renderer/components/settings/UserAssets.tsx new file mode 100644 index 0000000..89b4cae --- /dev/null +++ b/src/renderer/components/settings/UserAssets.tsx @@ -0,0 +1,80 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2025 Vendicated and Vencord contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./UserAssets.css"; + +import { + ModalCloseButton, + ModalContent, + ModalHeader, + ModalRoot, + ModalSize, + openModal, + wordsFromCamel, + wordsToTitle +} from "@vencord/types/utils"; +import { Button, showToast, Text, useState } from "@vencord/types/webpack/common"; +import { UserAssetType } from "main/userAssets"; + +import { SettingsComponent } from "./Settings"; + +const CUSTOMIZABLE_ASSETS: UserAssetType[] = ["splash", "tray", "trayUnread"]; + +export const UserAssetsButton: SettingsComponent = () => { + return ; +}; + +function openAssetsModal() { + openModal(props => ( + + + + User Assets + + + + + +
+ {CUSTOMIZABLE_ASSETS.map(asset => ( + + ))} +
+
+
+ )); +} + +function Asset({ asset }: { asset: UserAssetType }) { + // cache busting + const [version, setVersion] = useState(Date.now()); + + const onChooseAsset = (value?: null) => async () => { + const res = await VesktopNative.fileManager.chooseUserAsset(asset, value); + if (res === "ok") { + setVersion(Date.now()); + } else if (res === "failed") { + showToast("Something went wrong. Please try again"); + } + }; + + return ( +
+ + {wordsToTitle(wordsFromCamel(asset))} + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/shared/IpcEvents.ts b/src/shared/IpcEvents.ts index 03126d2..2eaf2af 100644 --- a/src/shared/IpcEvents.ts +++ b/src/shared/IpcEvents.ts @@ -57,7 +57,9 @@ export const enum IpcEvents { IPC_COMMAND = "VCD_IPC_COMMAND", DEVTOOLS_OPENED = "VCD_DEVTOOLS_OPENED", - DEVTOOLS_CLOSED = "VCD_DEVTOOLS_CLOSED" + DEVTOOLS_CLOSED = "VCD_DEVTOOLS_CLOSED", + + CHOOSE_USER_ASSET = "VCD_CHOOSE_USER_ASSET" } export const enum IpcCommands { diff --git a/src/shared/paths.ts b/src/shared/paths.ts index ba2b537..30e0132 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -9,4 +9,3 @@ import { join } from "path"; export const STATIC_DIR = /* @__PURE__ */ join(__dirname, "..", "..", "static"); export const VIEW_DIR = /* @__PURE__ */ join(STATIC_DIR, "views"); export const BADGE_DIR = /* @__PURE__ */ join(STATIC_DIR, "badges"); -export const TRAY_ICON_PATH = /* @__PURE__ */ join(STATIC_DIR, "tray"); diff --git a/static/shiggy.gif b/static/shiggy.gif deleted file mode 100644 index fc56490..0000000 Binary files a/static/shiggy.gif and /dev/null differ diff --git a/static/splash.webp b/static/splash.webp new file mode 100644 index 0000000..a247191 Binary files /dev/null and b/static/splash.webp differ diff --git a/static/tray.png b/static/tray.png new file mode 100644 index 0000000..df78775 Binary files /dev/null and b/static/tray.png differ diff --git a/static/tray/badge.png b/static/tray/trayUnread.png similarity index 100% rename from static/tray/badge.png rename to static/tray/trayUnread.png diff --git a/static/views/splash.html b/static/views/splash.html index e8e944a..ffb3548 100644 --- a/static/views/splash.html +++ b/static/views/splash.html @@ -36,19 +36,14 @@ img { width: 128px; height: 128px; - image-rendering: pixelated; + object-fit: contain; }
- shiggy +

Loading Vesktop...

@@ -61,4 +56,4 @@ messageElement.textContent = message; }) }); - + \ No newline at end of file