diff options
Diffstat (limited to 'js/dbusServices/screencast/screencastService.js')
-rw-r--r-- | js/dbusServices/screencast/screencastService.js | 498 |
1 files changed, 498 insertions, 0 deletions
diff --git a/js/dbusServices/screencast/screencastService.js b/js/dbusServices/screencast/screencastService.js new file mode 100644 index 0000000..eb3dc88 --- /dev/null +++ b/js/dbusServices/screencast/screencastService.js @@ -0,0 +1,498 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ScreencastService */ + +imports.gi.versions.Gst = '1.0'; +imports.gi.versions.Gtk = '4.0'; + +const { Gio, GLib, Gst, Gtk } = imports.gi; + +const { loadInterfaceXML, loadSubInterfaceXML } = imports.misc.dbusUtils; +const { ServiceImplementation } = imports.dbusService; + +const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast'); + +const IntrospectIface = loadInterfaceXML('org.gnome.Shell.Introspect'); +const IntrospectProxy = Gio.DBusProxy.makeProxyWrapper(IntrospectIface); + +const ScreenCastIface = loadSubInterfaceXML( + 'org.gnome.Mutter.ScreenCast', 'org.gnome.Mutter.ScreenCast'); +const ScreenCastSessionIface = loadSubInterfaceXML( + 'org.gnome.Mutter.ScreenCast.Session', 'org.gnome.Mutter.ScreenCast'); +const ScreenCastStreamIface = loadSubInterfaceXML( + 'org.gnome.Mutter.ScreenCast.Stream', 'org.gnome.Mutter.ScreenCast'); +const ScreenCastProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastIface); +const ScreenCastSessionProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastSessionIface); +const ScreenCastStreamProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastStreamIface); + +const DEFAULT_PIPELINE = 'videoconvert chroma-mode=GST_VIDEO_CHROMA_MODE_NONE dither=GST_VIDEO_DITHER_NONE matrix-mode=GST_VIDEO_MATRIX_MODE_OUTPUT_ONLY n-threads=%T ! queue ! vp8enc cpu-used=16 max-quantizer=17 deadline=1 keyframe-mode=disabled threads=%T static-threshold=1000 buffer-size=20000 ! queue ! webmmux'; +const DEFAULT_FRAMERATE = 30; +const DEFAULT_DRAW_CURSOR = true; + +const PipelineState = { + INIT: 0, + PLAYING: 1, + FLUSHING: 2, + STOPPED: 3, +}; + +const SessionState = { + INIT: 0, + ACTIVE: 1, + STOPPED: 2, +}; + +var Recorder = class { + constructor(sessionPath, x, y, width, height, filePath, options, + invocation, + onErrorCallback) { + this._startInvocation = invocation; + this._dbusConnection = invocation.get_connection(); + this._onErrorCallback = onErrorCallback; + this._stopInvocation = null; + + this._x = x; + this._y = y; + this._width = width; + this._height = height; + this._filePath = filePath; + + try { + const dir = Gio.File.new_for_path(filePath).get_parent(); + dir.make_directory_with_parents(null); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) + throw e; + } + + this._pipelineString = DEFAULT_PIPELINE; + this._framerate = DEFAULT_FRAMERATE; + this._drawCursor = DEFAULT_DRAW_CURSOR; + + this._applyOptions(options); + this._watchSender(invocation.get_sender()); + + this._initSession(sessionPath); + } + + _applyOptions(options) { + for (const option in options) + options[option] = options[option].deepUnpack(); + + if (options['pipeline'] !== undefined) + this._pipelineString = options['pipeline']; + if (options['framerate'] !== undefined) + this._framerate = options['framerate']; + if ('draw-cursor' in options) + this._drawCursor = options['draw-cursor']; + } + + _addRecentItem() { + const file = Gio.File.new_for_path(this._filePath); + Gtk.RecentManager.get_default().add_item(file.get_uri()); + } + + _watchSender(sender) { + this._nameWatchId = this._dbusConnection.watch_name( + sender, + Gio.BusNameWatcherFlags.NONE, + null, + this._senderVanished.bind(this)); + } + + _unwatchSender() { + if (this._nameWatchId !== 0) { + this._dbusConnection.unwatch_name(this._nameWatchId); + this._nameWatchId = 0; + } + } + + _senderVanished() { + this._unwatchSender(); + + this.stopRecording(null); + } + + _notifyStopped() { + this._unwatchSender(); + if (this._onStartedCallback) + this._onStartedCallback(this, false); + else if (this._onStoppedCallback) + this._onStoppedCallback(this); + else + this._onErrorCallback(this); + } + + _onSessionClosed() { + switch (this._pipelineState) { + case PipelineState.STOPPED: + break; + default: + this._pipeline.set_state(Gst.State.NULL); + log(`Unexpected pipeline state: ${this._pipelineState}`); + break; + } + this._notifyStopped(); + } + + _initSession(sessionPath) { + this._sessionProxy = new ScreenCastSessionProxy(Gio.DBus.session, + 'org.gnome.Mutter.ScreenCast', + sessionPath); + this._sessionProxy.connectSignal('Closed', this._onSessionClosed.bind(this)); + } + + _startPipeline(nodeId) { + if (!this._ensurePipeline(nodeId)) + return; + + const bus = this._pipeline.get_bus(); + bus.add_watch(bus, this._onBusMessage.bind(this)); + + this._pipeline.set_state(Gst.State.PLAYING); + this._pipelineState = PipelineState.PLAYING; + + this._onStartedCallback(this, true); + this._onStartedCallback = null; + } + + startRecording(onStartedCallback) { + this._onStartedCallback = onStartedCallback; + + const [streamPath] = this._sessionProxy.RecordAreaSync( + this._x, this._y, + this._width, this._height, + { + 'is-recording': GLib.Variant.new('b', true), + 'cursor-mode': GLib.Variant.new('u', this._drawCursor ? 1 : 0), + }); + + this._streamProxy = new ScreenCastStreamProxy(Gio.DBus.session, + 'org.gnome.ScreenCast.Stream', + streamPath); + + this._streamProxy.connectSignal('PipeWireStreamAdded', + (proxy, sender, params) => { + const [nodeId] = params; + this._startPipeline(nodeId); + }); + this._sessionProxy.StartSync(); + this._sessionState = SessionState.ACTIVE; + } + + stopRecording(onStoppedCallback) { + this._pipelineState = PipelineState.FLUSHING; + this._onStoppedCallback = onStoppedCallback; + this._pipeline.send_event(Gst.Event.new_eos()); + } + + _stopSession() { + this._sessionProxy.StopSync(); + this._sessionState = SessionState.STOPPED; + } + + _onBusMessage(bus, message, _) { + switch (message.type) { + case Gst.MessageType.EOS: + this._pipeline.set_state(Gst.State.NULL); + this._addRecentItem(); + + switch (this._pipelineState) { + case PipelineState.FLUSHING: + this._pipelineState = PipelineState.STOPPED; + break; + default: + break; + } + + switch (this._sessionState) { + case SessionState.ACTIVE: + this._stopSession(); + break; + case SessionState.STOPPED: + this._notifyStopped(); + break; + default: + break; + } + + break; + default: + break; + } + return true; + } + + _substituteThreadCount(pipelineDescr) { + const numProcessors = GLib.get_num_processors(); + const numThreads = Math.min(Math.max(1, numProcessors), 64); + return pipelineDescr.replaceAll('%T', numThreads); + } + + _ensurePipeline(nodeId) { + const framerate = this._framerate; + const needsCopy = + Gst.Registry.get().check_feature_version('pipewiresrc', 0, 3, 57) && + !Gst.Registry.get().check_feature_version('videoconvert', 1, 20, 4); + + let fullPipeline = ` + pipewiresrc path=${nodeId} + always-copy=${needsCopy} + do-timestamp=true + keepalive-time=1000 + resend-last=true ! + video/x-raw,max-framerate=${framerate}/1 ! + ${this._pipelineString} ! + filesink location="${this._filePath}"`; + fullPipeline = this._substituteThreadCount(fullPipeline); + + try { + this._pipeline = Gst.parse_launch_full(fullPipeline, + null, + Gst.ParseFlags.FATAL_ERRORS); + } catch (e) { + log(`Failed to create pipeline: ${e}`); + this._notifyStopped(); + } + return !!this._pipeline; + } +}; + +var ScreencastService = class extends ServiceImplementation { + static canScreencast() { + const elements = [ + 'pipewiresrc', + 'filesink', + ...DEFAULT_PIPELINE.split('!').map(e => e.trim().split(' ').at(0)), + ]; + return Gst.init_check(null) && + elements.every(e => Gst.ElementFactory.find(e) != null); + } + + constructor() { + super(ScreencastIface, '/org/gnome/Shell/Screencast'); + + this.hold(); // gstreamer initializing can take a bit + this._canScreencast = ScreencastService.canScreencast(); + + Gst.init(null); + Gtk.init(); + + this.release(); + + this._recorders = new Map(); + this._senders = new Map(); + + this._lockdownSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.lockdown', + }); + + this._proxy = new ScreenCastProxy(Gio.DBus.session, + 'org.gnome.Mutter.ScreenCast', + '/org/gnome/Mutter/ScreenCast'); + + this._introspectProxy = new IntrospectProxy(Gio.DBus.session, + 'org.gnome.Shell.Introspect', + '/org/gnome/Shell/Introspect'); + } + + get ScreencastSupported() { + return this._canScreencast; + } + + _removeRecorder(sender) { + this._recorders.delete(sender); + if (this._recorders.size === 0) + this.release(); + } + + _addRecorder(sender, recorder) { + this._recorders.set(sender, recorder); + if (this._recorders.size === 1) + this.hold(); + } + + _getAbsolutePath(filename) { + if (GLib.path_is_absolute(filename)) + return filename; + + const videoDir = + GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS) || + GLib.get_home_dir(); + + return GLib.build_filenamev([videoDir, filename]); + } + + _generateFilePath(template) { + let filename = ''; + let escape = false; + + [...template].forEach(c => { + if (escape) { + switch (c) { + case '%': + filename += '%'; + break; + case 'd': { + const datetime = GLib.DateTime.new_now_local(); + const datestr = datetime.format('%Y-%m-%d'); + + filename += datestr; + break; + } + + case 't': { + const datetime = GLib.DateTime.new_now_local(); + const datestr = datetime.format('%H-%M-%S'); + + filename += datestr; + break; + } + + default: + log(`Warning: Unknown escape ${c}`); + } + + escape = false; + } else if (c === '%') { + escape = true; + } else { + filename += c; + } + }); + + if (escape) + filename += '%'; + + return this._getAbsolutePath(filename); + } + + ScreencastAsync(params, invocation) { + let returnValue = [false, '']; + + if (this._lockdownSettings.get_boolean('disable-save-to-disk')) { + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + const sender = invocation.get_sender(); + + if (this._recorders.get(sender)) { + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + const [sessionPath] = this._proxy.CreateSessionSync({}); + + const [fileTemplate, options] = params; + const [screenWidth, screenHeight] = this._introspectProxy.ScreenSize; + const filePath = this._generateFilePath(fileTemplate); + + let recorder; + + try { + recorder = new Recorder( + sessionPath, + 0, 0, + screenWidth, screenHeight, + filePath, + options, + invocation, + _recorder => this._removeRecorder(sender)); + } catch (error) { + log(`Failed to create recorder: ${error.message}`); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + this._addRecorder(sender, recorder); + + try { + recorder.startRecording( + (_, result) => { + if (result) { + returnValue = [true, filePath]; + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } else { + this._removeRecorder(sender); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } + }); + } catch (error) { + log(`Failed to start recorder: ${error.message}`); + this._removeRecorder(sender); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } + } + + ScreencastAreaAsync(params, invocation) { + let returnValue = [false, '']; + + if (this._lockdownSettings.get_boolean('disable-save-to-disk')) { + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + const sender = invocation.get_sender(); + + if (this._recorders.get(sender)) { + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + const [sessionPath] = this._proxy.CreateSessionSync({}); + + const [x, y, width, height, fileTemplate, options] = params; + const filePath = this._generateFilePath(fileTemplate); + + let recorder; + + try { + recorder = new Recorder( + sessionPath, + x, y, + width, height, + filePath, + options, + invocation, + _recorder => this._removeRecorder(sender)); + } catch (error) { + log(`Failed to create recorder: ${error.message}`); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + return; + } + + this._addRecorder(sender, recorder); + + try { + recorder.startRecording( + (_, result) => { + if (result) { + returnValue = [true, filePath]; + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } else { + this._removeRecorder(sender); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } + }); + } catch (error) { + log(`Failed to start recorder: ${error.message}`); + this._removeRecorder(sender); + invocation.return_value(GLib.Variant.new('(bs)', returnValue)); + } + } + + StopScreencastAsync(params, invocation) { + const sender = invocation.get_sender(); + + const recorder = this._recorders.get(sender); + if (!recorder) { + invocation.return_value(GLib.Variant.new('(b)', [false])); + return; + } + + recorder.stopRecording(() => { + this._removeRecorder(sender); + invocation.return_value(GLib.Variant.new('(b)', [true])); + }); + } +}; |