--- a/src/main/screenShare.ts +++ b/src/main/screenShare.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { desktopCapturer, session, Streams } from "electron"; +import { app, desktopCapturer, session, Streams } from "electron"; +import { readFileSync, statSync, writeFileSync } from "fs"; +import { join } from "path"; import type { StreamPick } from "renderer/components/ScreenSharePicker"; import { IpcCommands, IpcEvents } from "shared/IpcEvents"; @@ -14,8 +16,68 @@ const isWayland = process.platform === "linux" && (process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY); +// renderer's PICK_ANOTHER ("Pick Another Window" in the stream dialog) +const PICK_ANOTHER = "vesktop-pick-another"; + +const runtimeDir = process.env.XDG_RUNTIME_DIR ?? "/tmp"; + +// FredOS: the XDPH custom picker (settings/hyprland.nix) auto-picks a running +// game unless this file holds a fresh timestamp, then shows the full picker. +function forceFullPicker() { + try { + writeFileSync( + join(runtimeDir, "xdph-pick-manually"), + String(Math.floor(Date.now() / 1000)) + ); + } catch {} +} + +// Wayland: the dialog opens before the portal answers and asks for its +// preview with this id; resolves to a JPEG data URL, or undefined if cancelled. +const PENDING_PREVIEW = "vesktop-pending-preview"; +let pendingPreview: Promise | undefined; + +// The FredOS picker grim-shots its selection to xdph-preview.jpg ~70ms after +// answering; use that, else Electron's thumbnail (~1s). No sources at all +// (picker closed) resolves undefined so the dialog closes. +function previewFor(sources: Promise, requestedAt: number) { + const file = join(runtimeDir, "xdph-preview.jpg"); + const jpeg = (data: Buffer) => "data:image/jpeg;base64," + data.toString("base64"); + + return new Promise(resolve => { + let done = false; + const finish = (url?: string) => { + if (!done) resolve(url); + done = true; + }; + + sources.then(s => finish(s?.[0] ? jpeg(s[0].thumbnail.toJPEG(85)) : undefined)); + + const poll = () => { + if (done) return; + try { + if (statSync(file).mtimeMs >= requestedAt) return finish(jpeg(readFileSync(file))); + } catch {} + setTimeout(poll, 25); + }; + poll(); + }); +} + +// Shift held while clicking share = full picker +let shiftDown = false; + export function registerScreenShareHandler() { + app.on("browser-window-created", (_, win) => { + win.webContents.on("before-input-event", (_, input) => { + if (input.key === "Shift") shiftDown = input.type === "keyDown"; + }); + win.on("blur", () => (shiftDown = false)); + }); + handle(IpcEvents.CAPTURER_GET_LARGE_THUMBNAIL, async (_, id: string) => { + if (id === PENDING_PREVIEW) return pendingPreview; + const sources = await desktopCapturer.getSources({ types: ["window", "screen"], thumbnailSize: { @@ -27,14 +89,39 @@ }); session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => { - // request full resolution on wayland right away because we always only end up with one result anyway - const width = isWayland ? 1920 : 176; + if (isWayland) { + // Electron only resolves getSources once the thumbnail exists, which on + // PipeWire takes a full ~1s refresh tick. Open the dialog right away and + // let it fetch the preview (PENDING_PREVIEW) when the portal answers. + if (shiftDown) forceFullPicker(); + while (true) { + const requestedAt = Date.now(); + const sources = desktopCapturer + .getSources({ types: ["window", "screen"], thumbnailSize: { width: 960, height: 540 } }) + .catch(err => console.error("Error during screenshare picker", err)); + pendingPreview = previewFor(sources, requestedAt); + + const stream = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { + screens: [{ id: PENDING_PREVIEW, name: "", url: "" }], + skipPicker: true + }).catch(() => null); + if (stream === null) return callback({}); + + const source = (await sources)?.[0]; + if (!source) return callback({}); + if (stream.id !== PICK_ANOTHER) return callback({ video: source }); + + // re-run the portal, this time with the full picker + forceFullPicker(); + } + } + const sources = await desktopCapturer .getSources({ types: ["window", "screen"], thumbnailSize: { - width, - height: width * (9 / 16) + width: 176, + height: 176 * (9 / 16) } }) .catch(err => console.error("Error during screenshare picker", err)); @@ -47,21 +134,6 @@ url: thumbnail.toDataURL() })); - if (isWayland) { - const video = data[0]; - if (video) { - const stream = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { - screens: [video], - skipPicker: true - }).catch(() => null); - - if (stream === null) return callback({}); - } - - callback(video ? { video: sources[0] } : {}); - return; - } - const choice = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { screens: data, skipPicker: false --- a/src/main/venmic.ts +++ b/src/main/venmic.ts @@ -6,6 +6,7 @@ import type { LinkData, Node, PatchBay as PatchBayType } from "@vencord/venmic"; import { app, ipcMain } from "electron"; +import { readFileSync } from "fs"; import { join } from "path"; import { IpcEvents } from "shared/IpcEvents"; import { STATIC_DIR } from "shared/paths"; @@ -66,6 +67,37 @@ ); } +// FredOS: the XDPH picker (settings/hyprland.nix) writes the shared window's +// pid here; suggest audio from that process or anything it spawned (Proton's +// game exe is usually the window's own process, gamescope's game a child). +function suggestedSources(targets: Node[]): Node[] { + let root: number; + try { + root = Number(readFileSync(join(process.env.XDG_RUNTIME_DIR ?? "/tmp", "xdph-selection-pid"), "utf8")); + } catch { + return []; + } + if (!root) return []; + + const fromRoot = (pid: number) => { + for (let depth = 0; pid > 1 && depth < 32; depth++) { + if (pid === root) return true; + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]); // ppid, after "(comm) state" + } catch { + return false; + } + } + return false; + }; + + const names = targets + .filter(t => t["application.name"] && fromRoot(Number(t["application.process.id"]))) + .map(t => t["application.name"]); + return [...new Set(names)].map(name => ({ "application.name": name })); +} + ipcMain.handle(IpcEvents.VIRT_MIC_LIST, () => { const audioPid = getRendererAudioServicePid(); @@ -75,7 +107,9 @@ ?.list(granularSelect ? ["node.name"] : undefined) .filter(s => s["application.process.id"] !== audioPid); - return targets ? { ok: true, targets, hasPipewirePulse } : { ok: false, isGlibCxxOutdated }; + return targets + ? { ok: true, targets, hasPipewirePulse, suggested: suggestedSources(targets) } + : { ok: false, isGlibCxxOutdated }; }); ipcMain.handle(IpcEvents.VIRT_MIC_START, (_, include: Node[]) => { --- a/src/preload/VesktopNative.ts +++ b/src/preload/VesktopNative.ts @@ -88,7 +88,8 @@ virtmic: { list: () => invoke< - { ok: false; isGlibCxxOutdated: boolean } | { ok: true; targets: Node[]; hasPipewirePulse: boolean } + | { ok: false; isGlibCxxOutdated: boolean } + | { ok: true; targets: Node[]; hasPipewirePulse: boolean; suggested: Node[] } >(IpcEvents.VIRT_MIC_LIST), start: (include: Node[]) => invoke(IpcEvents.VIRT_MIC_START, include), startSystem: (exclude: Node[]) => invoke(IpcEvents.VIRT_MIC_START_SYSTEM, exclude), --- a/src/renderer/components/ScreenSharePicker.tsx +++ b/src/renderer/components/ScreenSharePicker.tsx @@ -82,6 +82,10 @@ url: string; } +// Returned instead of a source id when "Pick Another Window" is clicked; main +// re-runs the portal with the full picker (keep in sync with main/screenShare.ts). +export const PICK_ANOTHER = "vesktop-pick-another"; + export let currentSettings: StreamSettings | null = null; const logger = new Logger("VesktopScreenShare"); @@ -351,23 +355,33 @@ source, settings, setSettings, - skipPicker + skipPicker, + close }: { source: Source; settings: StreamSettings; setSettings: Dispatch>; skipPicker: boolean; + close: () => void; }) { const Settings = useSettings(); const qualitySettings = State.store.screenshareQuality!; - const [thumb] = useAwaiter( - () => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)), + // Wayland opens this before the portal answers (no url yet): the preview + // arrives later, and none at all means the picker was cancelled. + const [thumb, , thumbPending] = useAwaiter( + () => + skipPicker && source.url + ? Promise.resolve(source.url) + : VesktopNative.capturer.getLargeThumbnail(source.id), { fallbackValue: source.url, deps: [source.id] } ); + useEffect(() => { + if (skipPicker && !thumbPending && !thumb) close(); + }, [thumbPending]); const openSettings = () => { openModal(props => ( @@ -385,7 +399,11 @@
What you're streaming - + {thumb ? ( + + ) : ( +
+ )} {source.name} @@ -451,6 +469,7 @@ granularSelect={Settings.audio?.granularSelect} setIncludeSources={sources => setSettings(s => ({ ...s, includeSources: sources }))} setExcludeSources={sources => setSettings(s => ({ ...s, excludeSources: sources }))} + selectionReady={!thumbPending} /> )} @@ -578,7 +597,8 @@ granularSelect, openSettings, setIncludeSources, - setExcludeSources + setExcludeSources, + selectionReady }: { includeSources?: AudioSources; excludeSources?: AudioSources; @@ -587,30 +607,38 @@ openSettings: () => void; setIncludeSources: (s: AudioSources) => void; setExcludeSources: (s: AudioSources) => void; + selectionReady: boolean; }) { const [audioSourcesSignal, refreshAudioSources] = useForceUpdater(true); const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(), { - fallbackValue: { ok: true, targets: [], hasPipewirePulse: true }, + fallbackValue: { ok: true, targets: [], hasPipewirePulse: true, suggested: [] }, deps: [audioSourcesSignal] }); const hasPipewirePulse = sources.ok ? sources.hasPipewirePulse : true; const [ignorePulseWarning, setIgnorePulseWarning] = useState(false); - // Once the first source list arrives, preselect whichever of the last - // stream's audio sources are running again (e.g. the same game). + // Once the shared window is known (its preview arrived), preselect audio: + // whatever that window's process plays (FredOS picker hint), else + // whichever of the last stream's sources are running again. const [restored, setRestored] = useState(false); useEffect(() => { - if (restored || loading || !sources.ok) return; + if (restored || !selectionReady) return; setRestored(true); - const last = State.store.screenshareAudio; - if (includeSources !== "None" || !last || last === "None") return; - if (last === "Entire System") return setIncludeSources(last); - - const running = last.filter(l => sources.targets.some(t => hasMatchingProps(l, t))); - if (running.length) setIncludeSources(running); - }, [loading]); + VesktopNative.virtmic.list().then(fresh => { + refreshAudioSources(); + if (!fresh.ok || includeSources !== "None") return; + if (fresh.suggested.length) return setIncludeSources(fresh.suggested); + + const last = State.store.screenshareAudio; + if (!last || last === "None") return; + if (last === "Entire System") return setIncludeSources(last); + + const running = last.filter(l => fresh.targets.some(t => hasMatchingProps(l, t))); + if (running.length) setIncludeSources(running); + }); + }, [selectionReady]); if (!sources.ok && sources.isGlibCxxOutdated) { return ( @@ -760,6 +788,7 @@ settings={settings} setSettings={setSettings} skipPicker={skipPicker} + close={close} /> )} @@ -829,6 +858,18 @@ Go Live + {skipPicker && ( + + )} + {selected && !skipPicker ? (