diff --git a/src/main/utils/http.ts b/src/main/utils/http.ts new file mode 100644 index 0000000..5a0a1a9 --- /dev/null +++ b/src/main/utils/http.ts @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3. + * Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! + */ + +import { createWriteStream } from "fs"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import { setTimeout } from "timers/promises"; + +interface FetchieOptions { + retryOnNetworkError?: boolean; +} + +export async function downloadFile(url: string, file: string, options: RequestInit = {}, fetchieOpts?: FetchieOptions) { + const res = await fetchie(url, options, fetchieOpts); + await pipeline( + // @ts-expect-error odd type error + Readable.fromWeb(res.body!), + createWriteStream(file, { + autoClose: true + }) + ); +} + +const ONE_MINUTE_MS = 1000 * 60; + +export async function fetchie(url: string, options?: RequestInit, { retryOnNetworkError }: FetchieOptions = {}) { + let res: Response | undefined; + + try { + res = await fetch(url, options); + } catch (err) { + if (retryOnNetworkError) { + console.error("Failed to fetch", url + ".", "Gonna retry with backoff."); + + for (let tries = 0, delayMs = 500; tries < 20; tries++, delayMs = Math.min(2 * delayMs, ONE_MINUTE_MS)) { + await setTimeout(delayMs); + try { + res = await fetch(url, options); + break; + } catch {} + } + } + + if (!res) throw new Error(`Failed to fetch ${url}\n${err}`); + } + + if (res.ok) return res; + + let msg = `Got non-OK response for ${url}: ${res.status} ${res.statusText}`; + + const reason = await res.text().catch(() => ""); + if (reason) msg += `\n${reason}`; + + throw new Error(msg); +} diff --git a/src/main/utils/ipcWrappers.ts b/src/main/utils/ipcWrappers.ts new file mode 100644 index 0000000..cd2824e --- /dev/null +++ b/src/main/utils/ipcWrappers.ts @@ -0,0 +1,30 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3. + * Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! + */ + +import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebFrameMain } from "electron"; +import { DISCORD_HOSTNAMES } from "main/constants"; +import { IpcEvents } from "shared/IpcEvents"; + +export function validateSender(frame: WebFrameMain) { + const { hostname, protocol } = new URL(frame.url); + if (protocol === "file:") return; + + if (!DISCORD_HOSTNAMES.includes(hostname)) throw new Error("ipc: Disallowed host " + hostname); +} + +export function handleSync(event: IpcEvents, cb: (e: IpcMainEvent, ...args: any[]) => any) { + ipcMain.on(event, (e, ...args) => { + validateSender(e.senderFrame); + e.returnValue = cb(e, ...args); + }); +} + +export function handle(event: IpcEvents, cb: (e: IpcMainInvokeEvent, ...args: any[]) => any) { + ipcMain.handle(event, (e, ...args) => { + validateSender(e.senderFrame); + return cb(e, ...args); + }); +} diff --git a/src/main/utils/makeLinksOpenExternally.ts b/src/main/utils/makeLinksOpenExternally.ts new file mode 100644 index 0000000..c69512e --- /dev/null +++ b/src/main/utils/makeLinksOpenExternally.ts @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3. + * Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! + */ + +import { BrowserWindow, shell } from "electron"; +import { DISCORD_HOSTNAMES } from "main/constants"; + +import { Settings } from "../settings"; +import { createOrFocusPopup, setupPopout } from "./popout"; +import { execSteamURL, isDeckGameMode, steamOpenURL } from "./steamOS"; + +export function handleExternalUrl(url: string, protocol?: string): { action: "deny" | "allow" } { + if (protocol == null) { + try { + protocol = new URL(url).protocol; + } catch { + return { action: "deny" }; + } + } + + switch (protocol) { + case "http:": + case "https:": + if (Settings.store.openLinksWithElectron) { + return { action: "allow" }; + } + // eslint-disable-next-line no-fallthrough + case "mailto:": + case "spotify:": + if (isDeckGameMode) { + steamOpenURL(url); + } else { + shell.openExternal(url); + } + break; + case "steam:": + if (isDeckGameMode) { + execSteamURL(url); + } else { + shell.openExternal(url); + } + break; + } + + return { action: "deny" }; +} + +export function makeLinksOpenExternally(win: BrowserWindow) { + win.webContents.setWindowOpenHandler(({ url, frameName, features }) => { + try { + var { protocol, hostname, pathname } = new URL(url); + } catch { + return { action: "deny" }; + } + + if (frameName.startsWith("DISCORD_") && pathname === "/popout" && DISCORD_HOSTNAMES.includes(hostname)) { + return createOrFocusPopup(frameName, features); + } + + if (url === "about:blank" || (frameName === "authorize" && DISCORD_HOSTNAMES.includes(hostname))) + return { action: "allow" }; + + return handleExternalUrl(url, protocol); + }); + + win.webContents.on("did-create-window", (win, { frameName }) => { + if (frameName.startsWith("DISCORD_")) setupPopout(win, frameName); + }); +} diff --git a/src/main/utils/popout.ts b/src/main/utils/popout.ts new file mode 100644 index 0000000..61c8cd9 --- /dev/null +++ b/src/main/utils/popout.ts @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3. + * Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! + */ + +import { BrowserWindow, BrowserWindowConstructorOptions } from "electron"; +import { Settings } from "main/settings"; + +import { handleExternalUrl } from "./makeLinksOpenExternally"; + +const ALLOWED_FEATURES = new Set([ + "width", + "height", + "left", + "top", + "resizable", + "movable", + "alwaysOnTop", + "frame", + "transparent", + "hasShadow", + "closable", + "skipTaskbar", + "backgroundColor", + "menubar", + "toolbar", + "location", + "directories", + "titleBarStyle" +]); + +const MIN_POPOUT_WIDTH = 320; +const MIN_POPOUT_HEIGHT = 180; +const DEFAULT_POPOUT_OPTIONS: BrowserWindowConstructorOptions = { + title: "Discord Popout", + backgroundColor: "#2f3136", + minWidth: MIN_POPOUT_WIDTH, + minHeight: MIN_POPOUT_HEIGHT, + frame: Settings.store.customTitleBar !== true, + titleBarStyle: process.platform === "darwin" ? "hidden" : undefined, + trafficLightPosition: + process.platform === "darwin" + ? { + x: 10, + y: 3 + } + : undefined, + webPreferences: { + nodeIntegration: false, + contextIsolation: true + }, + autoHideMenuBar: Settings.store.enableMenu +}; + +export const PopoutWindows = new Map(); + +function focusWindow(window: BrowserWindow) { + window.setAlwaysOnTop(true); + window.focus(); + window.setAlwaysOnTop(false); +} + +function parseFeatureValue(feature: string) { + if (feature === "yes") return true; + if (feature === "no") return false; + + const n = Number(feature); + if (!isNaN(n)) return n; + + return feature; +} + +function parseWindowFeatures(features: string) { + const keyValuesParsed = features.split(","); + + return keyValuesParsed.reduce((features, feature) => { + const [key, value] = feature.split("="); + if (ALLOWED_FEATURES.has(key)) features[key] = parseFeatureValue(value); + + return features; + }, {}); +} + +export function createOrFocusPopup(key: string, features: string) { + const existingWindow = PopoutWindows.get(key); + if (existingWindow) { + focusWindow(existingWindow); + return { action: "deny" }; + } + + return { + action: "allow", + overrideBrowserWindowOptions: { + ...DEFAULT_POPOUT_OPTIONS, + ...parseWindowFeatures(features) + } + }; +} + +export function setupPopout(win: BrowserWindow, key: string) { + win.setMenuBarVisibility(false); + + PopoutWindows.set(key, win); + + /* win.webContents.on("will-navigate", (evt, url) => { + // maybe prevent if not origin match + })*/ + + win.webContents.setWindowOpenHandler(({ url }) => handleExternalUrl(url)); + + win.once("closed", () => { + win.removeAllListeners(); + PopoutWindows.delete(key); + }); +} diff --git a/src/main/utils/steamOS.ts b/src/main/utils/steamOS.ts new file mode 100644 index 0000000..a5f236d --- /dev/null +++ b/src/main/utils/steamOS.ts @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3. + * Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! + */ + +import { BrowserWindow, dialog } from "electron"; +import { writeFile } from "fs/promises"; +import { join } from "path"; + +import { MessageBoxChoice } from "../constants"; +import { State } from "../settings"; + +// Bump this to re-show the prompt +const layoutVersion = 2; +// Get this from "show details" on the profile after exporting as a shared personal layout or using share with community +const layoutId = "3080264545"; // Vesktop Layout v2 +const numberRegex = /^[0-9]*$/; + +let steamPipeQueue = Promise.resolve(); + +export const isDeckGameMode = process.env.SteamOS === "1" && process.env.SteamGamepadUI === "1"; + +export function applyDeckKeyboardFix() { + if (!isDeckGameMode) return; + // Prevent constant virtual keyboard spam that eventually crashes Steam. + process.env.GTK_IM_MODULE = "None"; +} + +// For some reason SteamAppId is always 0 for non-steam apps so we do this insanity instead. +function getAppId(): string | null { + // /home/deck/.local/share/Steam/steamapps/shadercache/APPID/fozmediav1 + const path = process.env.STEAM_COMPAT_MEDIA_PATH; + if (!path) return null; + const pathElems = path?.split("/"); + const appId = pathElems[pathElems.length - 2]; + if (appId.match(numberRegex)) { + console.log(`Got Steam App ID ${appId}`); + return appId; + } + return null; +} + +export function execSteamURL(url: string) { + // This doesn't allow arbitrary execution despite the weird syntax. + steamPipeQueue = steamPipeQueue.then(() => + writeFile( + join(process.env.HOME || "/home/deck", ".steam", "steam.pipe"), + // replace ' to prevent argument injection + `'${process.env.HOME}/.local/share/Steam/ubuntu12_32/steam' '-ifrunning' '${url.replaceAll("'", "%27")}'\n`, + "utf-8" + ) + ); +} + +export function steamOpenURL(url: string) { + execSteamURL(`steam://openurl/${url}`); +} + +export async function showGamePage() { + const appId = getAppId(); + if (!appId) return; + await execSteamURL(`steam://nav/games/details/${appId}`); +} + +async function showLayout(appId: string) { + execSteamURL(`steam://controllerconfig/${appId}/${layoutId}`); +} + +export async function askToApplySteamLayout(win: BrowserWindow) { + const appId = getAppId(); + if (!appId) return; + if (State.store.steamOSLayoutVersion === layoutVersion) return; + const update = Boolean(State.store.steamOSLayoutVersion); + + // Touch screen breaks in some menus when native touch mode is enabled on latest SteamOS beta, remove most of the update specific text once that's fixed. + const { response } = await dialog.showMessageBox(win, { + message: `${update ? "Update" : "Apply"} Vesktop Steam Input Layout?`, + detail: `Would you like to ${update ? "Update" : "Apply"} Vesktop's recommended Steam Deck controller settings? +${update ? "Click yes using the touchpad" : "Tap yes"}, then press the X button or tap Apply Layout to confirm.${ + update ? " Doing so will undo any customizations you have made." : "" + } +${update ? "Click" : "Tap"} no to keep your current layout.`, + buttons: ["Yes", "No"], + cancelId: MessageBoxChoice.Cancel, + defaultId: MessageBoxChoice.Default, + type: "question" + }); + + if (State.store.steamOSLayoutVersion !== layoutVersion) { + State.store.steamOSLayoutVersion = layoutVersion; + } + + if (response === MessageBoxChoice.Cancel) return; + + await showLayout(appId); +}