@@ -62,7 +62,10 @@ import {
6262 readCursorTelemetryFile as readCursorTelemetryFileFrom ,
6363} from "../media/cursorSidecar" ;
6464import { findMediaLinksByFingerprint , registerMediaLinks } from "../media/mediaLinksRegistry" ;
65- import { LinuxNativeCaptureSession } from "../native-bridge/capture/linuxNativeCaptureSession" ;
65+ import {
66+ type LinuxCaptureSourceKind ,
67+ LinuxNativeCaptureSession ,
68+ } from "../native-bridge/capture/linuxNativeCaptureSession" ;
6669import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory" ;
6770import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession" ;
6871import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession" ;
@@ -539,48 +542,83 @@ let activeMacCaptureBounds: Rectangle | null = null;
539542let linuxNativeCaptureSession : LinuxNativeCaptureSession | null = null ;
540543let linuxNativeCaptureRecordingId : number | null = null ;
541544let linuxNativeCaptureCursorMode : CursorCaptureMode = "editable-overlay" ;
545+ /** What the portal granted for the running capture, for the tray's label. */
546+ let linuxNativeCaptureSourceLabel : string | null = null ;
542547/**
543- * The portal's restore token, kept between recordings .
548+ * A portal session negotiated ahead of the countdown, waiting to be armed .
544549 *
545- * Without it the compositor raises its source picker on EVERY recording, which
546- * on Wayland is the single most intrusive thing about capturing at all. The
547- * portal issues the token only after a successful session and honours it until
548- * the user revokes it in system settings, so it is safe to persist and useless
549- * to anyone else.
550+ * Held here rather than in the renderer because the helper is a child process of
551+ * THIS process: a renderer that reloads, or a countdown abandoned without a
552+ * cancel, would otherwise leak a live ScreenCast session — the compositor's
553+ * "screen is being shared" indicator with nothing recording behind it.
550554 */
551- const LINUX_RESTORE_TOKEN_FILE = "linux-capture-restore-token.json" ;
555+ let preparedLinuxCapture : { session : LinuxNativeCaptureSession ; outputPath : string } | null = null ;
552556
553- async function readLinuxRestoreToken ( ) : Promise < string | undefined > {
554- try {
555- const raw = await fs . readFile (
556- path . join ( app . getPath ( "userData" ) , LINUX_RESTORE_TOKEN_FILE ) ,
557- "utf-8" ,
558- ) ;
559- const parsed = JSON . parse ( raw ) as { restoreToken ?: unknown } ;
560- return typeof parsed . restoreToken === "string" && parsed . restoreToken
561- ? parsed . restoreToken
562- : undefined ;
563- } catch {
564- // Absent or unreadable: the picker appears, which is the old behaviour
565- // and not worth failing a recording over.
566- return undefined ;
557+ /**
558+ * Claims the prepared session when it matches the recording about to start.
559+ *
560+ * A mismatch means the prepare was for a recording that never happened, so it is
561+ * discarded rather than reused: arming it would record against the wrong output
562+ * path, and leaving it would strand a portal session.
563+ */
564+ function takePreparedLinuxSession ( outputPath : string ) : LinuxNativeCaptureSession | null {
565+ const prepared = preparedLinuxCapture ;
566+ if ( ! prepared ) {
567+ return null ;
568+ }
569+ preparedLinuxCapture = null ;
570+ if ( prepared . outputPath === outputPath ) {
571+ return prepared . session ;
567572 }
573+ console . warn ( "[native-linux] discarding a prepared session for a different recording" ) ;
574+ prepared . session . discard ( ) ;
575+ return null ;
568576}
569577
570- async function writeLinuxRestoreToken ( restoreToken ?: string ) {
571- if ( ! restoreToken ) {
578+ /** Tears down a prepared-but-unarmed session, e.g. an abandoned countdown. */
579+ function discardPreparedLinuxCapture ( reason : string ) {
580+ if ( ! preparedLinuxCapture ) {
572581 return ;
573582 }
574- try {
575- await fs . writeFile (
576- path . join ( app . getPath ( "userData" ) , LINUX_RESTORE_TOKEN_FILE ) ,
577- JSON . stringify ( { restoreToken } , null , 2 ) ,
578- "utf-8" ,
579- ) ;
580- } catch ( error ) {
581- console . warn ( "Could not persist the Linux portal restore token:" , error ) ;
583+ console . info ( `[native-linux] discarding the prepared capture: ${ reason } ` ) ;
584+ preparedLinuxCapture . session . discard ( ) ;
585+ preparedLinuxCapture = null ;
586+ }
587+
588+ /**
589+ * Names what the portal handed over, for the tray tooltip.
590+ *
591+ * There is no window title to show: the ScreenCast portal reports a kind and a
592+ * PipeWire node id, never a name. Reporting the kind is the most that can be
593+ * said honestly, and an unknown kind stays unknown — calling it "Screen" would
594+ * be the same guess that put a window's name on a full-screen recording.
595+ */
596+ function linuxSourceLabel ( kind ?: LinuxCaptureSourceKind ) : string {
597+ switch ( kind ) {
598+ case "window" :
599+ return "Window" ;
600+ case "monitor" :
601+ return "Screen" ;
602+ case "virtual" :
603+ return "Virtual display" ;
604+ default :
605+ return "Screen recording" ;
582606 }
583607}
608+ /**
609+ * NO PORTAL RESTORE TOKEN IS KEPT, AND THAT IS DELIBERATE.
610+ *
611+ * A token used to be persisted here so the compositor's picker would not appear
612+ * on every recording. It is gone because it made "record this window" record the
613+ * whole screen instead. A restore token is bound to the source it was minted
614+ * for, so once any monitor had been approved the portal restored that monitor on
615+ * every later run and stopped raising the picker at all — and `SelectSources`
616+ * has no parameter naming a source, so the app could not ask for anything else.
617+ * On Wayland the picker IS the source chooser; suppressing it left the user with
618+ * no way to change what they were recording.
619+ *
620+ * Answering the picker each time is the cost of being able to choose at all.
621+ */
584622
585623// ponytail: the sidecar readers used to live here, ~150 lines of parsing wedged
586624// between the capture state machine and the asset-path helpers, reachable only
@@ -1653,6 +1691,19 @@ export function registerIpcHandlers(
16531691 } ) ;
16541692
16551693 ipcMain . handle ( "open-source-selector" , async ( ) => {
1694+ // Nothing to open on Linux WHEN THE NATIVE HELPER IS THERE. The selector's
1695+ // own `desktopCapturer.getSources()` raises a portal dialog — a SECOND
1696+ // one, for a session that is thrown away — and whatever it returns cannot
1697+ // reach the helper, because `SelectSources` has no parameter naming a
1698+ // source. Refusing keeps that dialog from appearing at all.
1699+ //
1700+ // Without the helper the recorder falls back to Chromium's capture, which
1701+ // DOES consume a source id, so the picker has to stay reachable there or
1702+ // that path could never start.
1703+ if ( process . platform === "linux" && findPipeWireCursorHelperPath ( ) ) {
1704+ return { opened : false , reason : "portal-owns-selection" } ;
1705+ }
1706+
16561707 const access = await requestScreenAccess ( ) ;
16571708 if ( ! access . granted ) {
16581709 if ( process . platform === "darwin" && access . status !== "not-determined" ) {
@@ -1802,20 +1853,29 @@ export function registerIpcHandlers(
18021853 : { success : true , available : false , reason : "missing-helper" } ;
18031854 } ) ;
18041855
1856+ /**
1857+ * Raises the compositor's picker and stops there, holding the grant.
1858+ *
1859+ * Best-effort by contract: every failure returns `success: false` rather than
1860+ * throwing, because the caller's fallback is simply to start normally and get
1861+ * the picker after its countdown — the behaviour that shipped before this
1862+ * existed. Nothing downstream may depend on a prepare having succeeded.
1863+ */
18051864 ipcMain . handle (
1806- "start -native-linux-recording" ,
1865+ "prepare -native-linux-recording" ,
18071866 async ( _ , request : NativeLinuxRecordingRequest ) => {
1867+ if ( process . platform !== "linux" ) {
1868+ return { success : false , reason : "unsupported-platform" } ;
1869+ }
1870+ if ( linuxNativeCaptureSession ) {
1871+ return { success : false , reason : "already-recording" } ;
1872+ }
1873+ discardPreparedLinuxCapture ( "superseded by a new prepare" ) ;
1874+
18081875 try {
1809- if ( process . platform !== "linux" ) {
1810- return { success : false , error : "Native Linux capture requires Linux." } ;
1811- }
1812- if ( linuxNativeCaptureSession ) {
1813- return { success : false , error : "Native Linux capture is already running." } ;
1814- }
18151876 if ( ! findPipeWireCursorHelperPath ( ) ) {
1816- return { success : false , error : "Native Linux capture helper is not available. " } ;
1877+ return { success : false , reason : "missing- helper" } ;
18171878 }
1818-
18191879 const recordingId =
18201880 typeof request ?. recordingId === "number" && Number . isFinite ( request . recordingId )
18211881 ? request . recordingId
@@ -1825,7 +1885,6 @@ export function registerIpcHandlers(
18251885 normalizeCursorCaptureMode ( request ?. cursor ?. mode ) ?? "editable-overlay" ;
18261886
18271887 await fs . mkdir ( RECORDINGS_DIR , { recursive : true } ) ;
1828- const restoreToken = await readLinuxRestoreToken ( ) ;
18291888
18301889 const session = new LinuxNativeCaptureSession ( {
18311890 outputPath,
@@ -1843,28 +1902,112 @@ export function registerIpcHandlers(
18431902 } ,
18441903 } ,
18451904 maxCursorSamples : MAX_CURSOR_SAMPLES ,
1846- ... ( restoreToken ? { restoreToken } : { } ) ,
1905+ deferStart : true ,
18471906 } ) ;
18481907
1908+ await session . start ( ) ;
1909+ // The picker is up now. No timeout: a human is reading a dialog.
1910+ await session . waitUntilSourceSelected ( ) ;
1911+
1912+ preparedLinuxCapture = { session, outputPath } ;
1913+ return {
1914+ success : true ,
1915+ recordingId,
1916+ sourceKind : session . grantedSourceKind ?? null ,
1917+ } ;
1918+ } catch ( error ) {
1919+ console . warn ( "Could not prepare the native Linux capture:" , error ) ;
1920+ discardPreparedLinuxCapture ( "prepare failed" ) ;
1921+ return { success : false , error : String ( error ) } ;
1922+ }
1923+ } ,
1924+ ) ;
1925+
1926+ /** Drops a prepared session, e.g. when the countdown was cancelled. */
1927+ ipcMain . handle ( "cancel-native-linux-prepare" , async ( ) => {
1928+ discardPreparedLinuxCapture ( "cancelled by the renderer" ) ;
1929+ return { success : true } ;
1930+ } ) ;
1931+
1932+ ipcMain . handle (
1933+ "start-native-linux-recording" ,
1934+ async ( _ , request : NativeLinuxRecordingRequest ) => {
1935+ try {
1936+ if ( process . platform !== "linux" ) {
1937+ return { success : false , error : "Native Linux capture requires Linux." } ;
1938+ }
1939+ if ( linuxNativeCaptureSession ) {
1940+ return { success : false , error : "Native Linux capture is already running." } ;
1941+ }
1942+ if ( ! findPipeWireCursorHelperPath ( ) ) {
1943+ return { success : false , error : "Native Linux capture helper is not available." } ;
1944+ }
1945+
1946+ const recordingId =
1947+ typeof request ?. recordingId === "number" && Number . isFinite ( request . recordingId )
1948+ ? request . recordingId
1949+ : Date . now ( ) ;
1950+ const outputPath = path . join ( RECORDINGS_DIR , `${ RECORDING_FILE_PREFIX } ${ recordingId } .mp4` ) ;
1951+ const cursorCaptureMode =
1952+ normalizeCursorCaptureMode ( request ?. cursor ?. mode ) ?? "editable-overlay" ;
1953+
1954+ await fs . mkdir ( RECORDINGS_DIR , { recursive : true } ) ;
1955+
1956+ // A session prepared before the countdown, if there was one. Taking
1957+ // it here rather than requiring it is what keeps every caller
1958+ // working: a path that never prepared still gets a full start
1959+ // below, just with the picker after its countdown instead of
1960+ // before. Nothing has to know which path it is on.
1961+ const prepared = takePreparedLinuxSession ( outputPath ) ;
1962+ const session =
1963+ prepared ??
1964+ new LinuxNativeCaptureSession ( {
1965+ outputPath,
1966+ cursorMode : portalCursorMode ( cursorCaptureMode ) ,
1967+ fps : request . video . fps ,
1968+ ...( request . video . bitrate ? { bitrate : request . video . bitrate } : { } ) ,
1969+ audio : {
1970+ system : { enabled : request . audio . system . enabled } ,
1971+ microphone : {
1972+ enabled : request . audio . microphone . enabled ,
1973+ ...( request . audio . microphone . deviceName
1974+ ? { deviceName : request . audio . microphone . deviceName }
1975+ : { } ) ,
1976+ gain : request . audio . microphone . gain ,
1977+ } ,
1978+ } ,
1979+ maxCursorSamples : MAX_CURSOR_SAMPLES ,
1980+ } ) ;
1981+
18491982 console . info ( "[native-linux] starting capture" , {
18501983 outputPath,
1984+ prepared : Boolean ( prepared ) ,
18511985 cursor : { mode : cursorCaptureMode } ,
18521986 audio : request . audio ,
18531987 video : request . video ,
18541988 } ) ;
18551989
1856- await session . start ( ) ;
1857- // Blocks until the user answers the portal picker, which has no
1858- // upper bound — the countdown UI is already showing by now.
1990+ if ( ! prepared ) {
1991+ await session . start ( ) ;
1992+ // Blocks until the user answers the portal picker, which has no
1993+ // upper bound. On this path the countdown has already run.
1994+ await session . waitUntilSourceSelected ( ) ;
1995+ }
1996+ // Idempotent, and a no-op for a session that was not deferred.
1997+ session . arm ( ) ;
18591998 await session . waitUntilCapturing ( ) ;
18601999
18612000 linuxNativeCaptureSession = session ;
18622001 linuxNativeCaptureRecordingId = recordingId ;
18632002 linuxNativeCaptureCursorMode = cursorCaptureMode ;
18642003
1865- const source = selectedSource || { name : "Screen" } ;
2004+ // The portal's answer, not an in-app selection — on Wayland there
2005+ // is none to have. This used to read `selectedSource || { name:
2006+ // "Screen" }`, so the tray confidently displayed the name of a
2007+ // window the capture had never been told about.
2008+ linuxNativeCaptureSourceLabel = linuxSourceLabel ( session . grantedSourceKind ) ;
18662009 if ( onRecordingStateChange ) {
1867- onRecordingStateChange ( true , source . name ) ;
2010+ onRecordingStateChange ( true , linuxNativeCaptureSourceLabel ) ;
18682011 }
18692012
18702013 return { success : true , recordingId, path : outputPath } ;
@@ -1915,7 +2058,6 @@ export function registerIpcHandlers(
19152058 }
19162059
19172060 const result = await session . stop ( ) ;
1918- await writeLinuxRestoreToken ( result . restoreToken ) ;
19192061
19202062 // The helper collects cursor samples itself, from the same portal
19212063 // session that produced the pixels, so there is no separate sampler
@@ -1965,9 +2107,10 @@ export function registerIpcHandlers(
19652107 linuxNativeCaptureSession = null ;
19662108 linuxNativeCaptureRecordingId = null ;
19672109 linuxNativeCaptureCursorMode = "editable-overlay" ;
1968- const source = selectedSource || { name : "Screen" } ;
2110+ const stoppedLabel = linuxNativeCaptureSourceLabel ?? linuxSourceLabel ( ) ;
2111+ linuxNativeCaptureSourceLabel = null ;
19692112 if ( onRecordingStateChange ) {
1970- onRecordingStateChange ( false , source . name ) ;
2113+ onRecordingStateChange ( false , stoppedLabel ) ;
19712114 }
19722115 }
19732116 } ) ;
0 commit comments