55 type CursorPauseRange ,
66 compactCursorTelemetryPauseRanges ,
77} from "../cursor/recording/cursorPauseRanges" ;
8+ import { HyprlandCursorTracker } from "../cursor/recording/hyprlandCursorTracker" ;
89import {
910 NdjsonLineReader ,
1011 PipeWireCursorAccumulator ,
@@ -56,7 +57,8 @@ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
5657
5758export interface LinuxCaptureConfig {
5859 outputPath : string ;
59- cursorMode : "metadata" | "embedded" ;
60+ cursorMode : "auto" | "metadata" | "embedded" | "hidden" ;
61+ sampleIntervalMs ?: number ;
6062 fps : number ;
6163 bitrate ?: number ;
6264 audio : {
@@ -119,6 +121,9 @@ export class LinuxNativeCaptureSession {
119121 private lastError : string | null = null ;
120122 private paused = false ;
121123 private helperOutputText = "" ;
124+ private effectiveCursorMode : "metadata" | "embedded" | "hidden" = "metadata" ;
125+ private cursorMetadataSupported : boolean | null = null ;
126+ private hyprlandCursor : HyprlandCursorTracker | null = null ;
122127 private cursorRecordingStartMs = 0 ;
123128 private cursorPauseStartedAtMs : number | null = null ;
124129 private cursorPauseRanges : CursorPauseRange [ ] = [ ] ;
@@ -317,6 +322,7 @@ export class LinuxNativeCaptureSession {
317322 if ( this . paused ) {
318323 return ;
319324 }
325+ this . hyprlandCursor ?. pause ( ) ;
320326 this . write ( "pause" ) ;
321327 this . paused = true ;
322328 this . cursorPauseStartedAtMs = Date . now ( ) ;
@@ -326,6 +332,7 @@ export class LinuxNativeCaptureSession {
326332 if ( ! this . paused ) {
327333 return ;
328334 }
335+ this . hyprlandCursor ?. resume ( ) ;
329336 this . write ( "resume" ) ;
330337 this . completeCursorPauseRange ( ) ;
331338 this . paused = false ;
@@ -344,6 +351,11 @@ export class LinuxNativeCaptureSession {
344351 return this . paused ;
345352 }
346353
354+ /** The cursor mode actually negotiated by the portal. */
355+ get cursorMode ( ) : LinuxCaptureConfig [ "cursorMode" ] {
356+ return this . negotiatedCursorMode ?? this . config . cursorMode ;
357+ }
358+
347359 /**
348360 * What the portal granted. Known from `stream-started`, so it is populated by
349361 * the time [`waitUntilCapturing`] resolves.
@@ -356,15 +368,6 @@ export class LinuxNativeCaptureSession {
356368 return this . sourceKind ;
357369 }
358370
359- /**
360- * The cursor mode the helper really negotiates, known once `start()` resolves.
361- * A portal without EMBEDDED gets METADATA instead (#1357), so the cursor has
362- * to be drawn by the editor even though the system cursor was asked for.
363- */
364- get cursorMode ( ) : LinuxCaptureConfig [ "cursorMode" ] {
365- return this . negotiatedCursorMode ?? this . config . cursorMode ;
366- }
367-
368371 /**
369372 * What the helper said in this session, for Save Diagnostics' `helperOutput.linux`:
370373 * its stderr as written and every stdout line except `cursor-sample`, which comes
@@ -449,6 +452,8 @@ export class LinuxNativeCaptureSession {
449452 }
450453
451454 private kill ( ) {
455+ this . hyprlandCursor ?. stop ( ) ;
456+ this . hyprlandCursor = null ;
452457 const child = this . process ;
453458 if ( ! child || child . killed ) {
454459 return ;
@@ -480,12 +485,42 @@ export class LinuxNativeCaptureSession {
480485
481486 private handleEvent ( payload : PipeWireHelperEvent ) {
482487 switch ( payload . event ) {
483- case "ready" :
484- if ( payload . cursorMode === "metadata" || payload . cursorMode === "embedded" ) {
488+ case "ready" : {
489+ if (
490+ payload . cursorMode === "metadata" ||
491+ payload . cursorMode === "embedded" ||
492+ payload . cursorMode === "hidden"
493+ ) {
485494 this . negotiatedCursorMode = payload . cursorMode ;
486495 }
496+ this . cursorMetadataSupported =
497+ typeof payload . cursorMetadataSupported === "boolean"
498+ ? payload . cursorMetadataSupported
499+ : null ;
500+ this . effectiveCursorMode =
501+ payload . cursorMode === "hidden"
502+ ? "hidden"
503+ : payload . cursorMode === "embedded"
504+ ? "embedded"
505+ : "metadata" ;
506+ const useHyprlandPositionTelemetry =
507+ this . config . cursorMode === "auto" &&
508+ ( this . effectiveCursorMode !== "metadata" || this . cursorMetadataSupported === false ) ;
509+ this . ensureHyprlandCursorTracker (
510+ useHyprlandPositionTelemetry || this . effectiveCursorMode === "hidden" ,
511+ ) ;
512+ console . info (
513+ "[capture-linux] cursor negotiation" ,
514+ JSON . stringify ( {
515+ requested : this . config . cursorMode ,
516+ effective : this . effectiveCursorMode ,
517+ metadataSupported : payload . cursorMetadataSupported ?? null ,
518+ hyprlandTracker : Boolean ( this . hyprlandCursor ) ,
519+ } ) ,
520+ ) ;
487521 this . resolveReady ( ) ;
488522 return ;
523+ }
489524
490525 case "source-selected" :
491526 if ( payload . sourceKind ) {
@@ -519,6 +554,24 @@ export class LinuxNativeCaptureSession {
519554 sourceKind : payload . sourceKind ?? null ,
520555 } ) ,
521556 ) ;
557+ // Some helper/portal combinations report the negotiated mode before
558+ // the stream details are available. Re-evaluate here so hidden-cursor
559+ // capture cannot silently lose its Hyprland position telemetry.
560+ this . ensureHyprlandCursorTracker (
561+ ( this . config . cursorMode === "auto" &&
562+ ( this . effectiveCursorMode !== "metadata" || this . cursorMetadataSupported === false ) ) ||
563+ this . effectiveCursorMode === "hidden" ,
564+ ) ;
565+ if ( this . hyprlandCursor && ! this . hyprlandCursor . hasCaptureGeometry ( ) ) {
566+ this . hyprlandCursor . setCaptureGeometry ( {
567+ position :
568+ payload . positionX != null && payload . positionY != null
569+ ? { x : payload . positionX , y : payload . positionY }
570+ : null ,
571+ width : payload . logicalWidth ?? payload . width ,
572+ height : payload . logicalHeight ?? payload . height ,
573+ } ) ;
574+ }
522575 return ;
523576
524577 case "audio-source" :
@@ -555,6 +608,22 @@ export class LinuxNativeCaptureSession {
555608 // picker is dropped rather than left pinned to the start.
556609 this . cursor . rebase ( payload . timestampMs ) ;
557610 this . cursorRecordingStartMs = payload . timestampMs ;
611+ this . ensureHyprlandCursorTracker (
612+ ( this . config . cursorMode === "auto" &&
613+ ( this . effectiveCursorMode !== "metadata" || this . cursorMetadataSupported === false ) ) ||
614+ this . effectiveCursorMode === "hidden" ,
615+ ) ;
616+ this . hyprlandCursor ?. rebase ( ) ;
617+ // Older helper builds and some portal backends may omit stream-started
618+ // geometry. The encoded frame dimensions are still sufficient for the
619+ // monitor-size/origin fallback in HyprlandCursorTracker.
620+ if ( this . hyprlandCursor && ! this . hyprlandCursor . hasCaptureGeometry ( ) ) {
621+ this . hyprlandCursor . setCaptureGeometry ( {
622+ position : null ,
623+ width : payload . width ,
624+ height : payload . height ,
625+ } ) ;
626+ }
558627 this . capturing = true ;
559628 console . info (
560629 "[capture-linux] capture started" ,
@@ -566,19 +635,20 @@ export class LinuxNativeCaptureSession {
566635 return ;
567636
568637 case "cursor-sample" :
569- this . cursor . addSample ( payload ) ;
638+ if ( this . effectiveCursorMode === "metadata" ) this . cursor . addSample ( payload ) ;
570639 return ;
571640
572- case "capture-stopped" :
641+ case "capture-stopped" : {
642+ const cursor =
643+ this . hyprlandCursor ?. stop ( ) ??
644+ compactCursorTelemetryPauseRanges ( this . cursor . toRecordingData ( ) , this . cursorPauseRanges ) ;
645+ this . hyprlandCursor = null ;
573646 this . stopped = {
574647 path : payload . path ,
575648 durationMs : payload . durationMs ,
576649 frames : payload . frames ,
577650 droppedFrames : payload . dropped ,
578- cursor : compactCursorTelemetryPauseRanges (
579- this . cursor . toRecordingData ( ) ,
580- this . cursorPauseRanges ,
581- ) ,
651+ cursor,
582652 ...( this . sourceKind ? { sourceKind : this . sourceKind } : { } ) ,
583653 ...( this . videoEncoder ? { videoEncoder : this . videoEncoder } : { } ) ,
584654 } ;
@@ -591,9 +661,12 @@ export class LinuxNativeCaptureSession {
591661 convertMs : payload . convertMs ,
592662 uploadMs : payload . uploadMs ,
593663 encodeMs : payload . encodeMs ,
664+ cursorProvider : cursor . provider ,
665+ cursorSamples : cursor . samples . length ,
594666 } ) ,
595667 ) ;
596668 return ;
669+ }
597670
598671 case "warning" :
599672 console . warn ( `[capture-linux] ${ payload . code } : ${ payload . message } ` ) ;
@@ -616,6 +689,21 @@ export class LinuxNativeCaptureSession {
616689 }
617690 }
618691
692+ private ensureHyprlandCursorTracker ( needed : boolean ) {
693+ if ( ! needed || this . hyprlandCursor ) return ;
694+ if ( ! HyprlandCursorTracker . isAvailable ( ) ) {
695+ console . warn (
696+ "[capture-linux] position-only cursor capture requested, but the Hyprland IPC socket is unavailable" ,
697+ ) ;
698+ return ;
699+ }
700+ this . hyprlandCursor = new HyprlandCursorTracker ( {
701+ sampleIntervalMs : this . config . sampleIntervalMs ?? 33 ,
702+ maxSamples : this . config . maxCursorSamples ,
703+ } ) ;
704+ this . hyprlandCursor . start ( ) ;
705+ }
706+
619707 private resolveReady ( ) {
620708 const resolve = this . readyResolve ;
621709 this . clearReadyState ( ) ;
0 commit comments