diff --git a/gnome/src/extension.js b/gnome/src/extension.js index b762ee9..ac1ed4d 100644 --- a/gnome/src/extension.js +++ b/gnome/src/extension.js @@ -307,7 +307,6 @@ export default class BreezyDesktopExtension extends Extension { this._show_banner_binding = Globals.data_stream.bind_property('show-banner', this._overlay_content, 'show-banner', Gio.SettingsBindFlags.DEFAULT); this._show_banner_connection = Globals.data_stream.connect('notify::show-banner', this._handle_show_banner_update.bind(this)); this._was_show_banner = Globals.data_stream.show_banner; - Globals.logger.log_debug(`BreezyDesktopExtension _effect_enable - show_banner: ${this._was_show_banner}`); if (!this._was_show_banner) this._recenter_display(); this._custom_banner_enabled_binding = Globals.data_stream.bind_property('custom-banner-enabled', this._overlay_content, 'custom-banner-enabled', Gio.SettingsBindFlags.DEFAULT); diff --git a/gnome/src/virtualmonitorsactor.js b/gnome/src/virtualmonitorsactor.js index e03a4f0..138273d 100644 --- a/gnome/src/virtualmonitorsactor.js +++ b/gnome/src/virtualmonitorsactor.js @@ -468,7 +468,13 @@ export const VirtualMonitorEffect = GObject.registerClass({ // if we're the focused display, we'll double the timeline and wait for the first half to let other // displays ease out first this._distance_ease_focus = this._is_focused(); - const timeline_ms = this._distance_ease_focus ? 500 : 150; + const ease_out_timeline_ms = 150; + const pause_ms = 50; + const ease_in_timeline_ms = 500; // includes ease out and pause + const ease_in_begin_pct = (ease_out_timeline_ms + pause_ms) / ease_in_timeline_ms; + const timeline_ms = this._distance_ease_focus ? + ease_in_timeline_ms : + ease_out_timeline_ms; this._distance_ease_start = this._current_display_distance; this._distance_ease_timeline = Clutter.Timeline.new_for_actor(this.get_actor(), timeline_ms); @@ -478,10 +484,10 @@ export const VirtualMonitorEffect = GObject.registerClass({ let progress = this._distance_ease_timeline.get_progress(); if (this._distance_ease_focus) { // if we're the focused display, wait for the first half of the timeline to pass - if (progress < 0.5) return; + if (progress < ease_in_begin_pct) return; // treat the second half of the timeline as its own full progression - progress = (progress - 0.5) * 2; + progress = (progress - ease_in_begin_pct) / (1 - ease_in_begin_pct); // put this display in front as it starts to easy in this.is_closest = true; @@ -489,8 +495,8 @@ export const VirtualMonitorEffect = GObject.registerClass({ this.is_closest = false; } - this._current_display_distance = this._distance_ease_start + - progress * (this._distance_ease_target - this._distance_ease_start); + this._current_display_distance = this._distance_ease_start + + (1 - Math.cos(progress * Math.PI)) / 2 * (this._distance_ease_target - this._distance_ease_start); this._update_display_position_uniforms(); }).bind(this)); diff --git a/ui/bin/package b/ui/bin/package index d66e731..f41fe2d 100755 --- a/ui/bin/package +++ b/ui/bin/package @@ -43,6 +43,7 @@ cp $UI_BUILD_PATH/src/breezydesktop.gresource $PACKAGE_BREEZY_DIR cp -r po/mo/* $PACKAGE_LOCALE_DIR cp data/com.xronlinux.BreezyDesktop.gschema.xml $PACKAGE_SCHEMAS_DIR cp $UI_BUILD_PATH/src/breezydesktop $PACKAGE_BIN_DIR +cp $UI_BUILD_PATH/src/virtualdisplay $PACKAGE_BIN_DIR cp $UI_BUILD_PATH/data/com.xronlinux.BreezyDesktop.desktop $PACKAGE_APPS_DIR mkdir -p $PACKAGE_ICONS_DIR/64x64/apps diff --git a/ui/src/connecteddevice.py b/ui/src/connecteddevice.py index f7d3c7a..a695415 100644 --- a/ui/src/connecteddevice.py +++ b/ui/src/connecteddevice.py @@ -5,7 +5,7 @@ from .license import BREEZY_GNOME_FEATURES from .settingsmanager import SettingsManager from .shortcutdialog import bind_shortcut_settings from .statemanager import StateManager -from .virtualdisplay import VirtualMonitor +from .virtualdisplaymanager import VirtualDisplayManager from .xrdriveripc import XRDriverIPC import gettext import logging @@ -60,7 +60,6 @@ class ConnectedDevice(Gtk.Box): viewport_offset_y_scale = Gtk.Template.Child() viewport_offset_y_adjustment = Gtk.Template.Child() - def __init__(self): super(Gtk.Box, self).__init__() self.init_template() @@ -85,6 +84,7 @@ class ConnectedDevice(Gtk.Box): self.settings = SettingsManager.get_instance().settings self.desktop_settings = SettingsManager.get_instance().desktop_settings self.ipc = XRDriverIPC.get_instance() + self.virtual_display_manager = VirtualDisplayManager.get_instance() self.extensions_manager = ExtensionsManager.get_instance() self.settings.bind('display-distance', self.display_distance_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) @@ -139,6 +139,9 @@ class ConnectedDevice(Gtk.Box): self._refresh_use_optimal_monitor_config(self.use_optimal_monitor_config_switch, None) self.extensions_manager.connect('notify::breezy-enabled', self._handle_enabled_config) + self.virtual_display_manager.connect('notify::displays', self._on_virtual_displays_update) + self._on_virtual_displays_update(self.virtual_display_manager, None) + self.connect("destroy", self._on_widget_destroy) def _handle_monitor_wrapping_scheme_setting_changed(self, settings, val): @@ -206,10 +209,11 @@ class ConnectedDevice(Gtk.Box): reload_display_distance_toggle_button(widget) def on_add_virtual_display(self, width, height): - VirtualMonitor(width, height, self.on_virtual_display_ready).create() + logger.info(f"Adding virtual display {width}x{height}") + self.virtual_display_manager.create_virtual_display(width, height, 60) - def on_virtual_display_ready(self): - logger.info("Virtual display ready") + def _on_virtual_displays_update(self, virtual_display_manager, val): + logger.info(f"Found {len(virtual_display_manager.displays)} virtual displays") def _on_widget_destroy(self, widget): # self.state_manager.unbind_property('follow-mode', self.follow_mode_switch, 'active') diff --git a/ui/src/meson.build b/ui/src/meson.build index 9768ce5..f9f6e9b 100644 --- a/ui/src/meson.build +++ b/ui/src/meson.build @@ -26,6 +26,15 @@ configure_file( install_mode: 'r-xr-xr-x' ) +configure_file( + input: 'virtualdisplay.in', + output: 'virtualdisplay', + configuration: conf, + install: true, + install_dir: get_option('bindir'), + install_mode: 'r-xr-xr-x' +) + breezydesktop_sources = [ '../modules/PyXRLinuxDriverIPC/xrdriveripc.py', '__init__.py', @@ -48,6 +57,7 @@ breezydesktop_sources = [ 'statemanager.py', 'time.py', 'virtualdisplay.py', + 'virtualdisplaymanager.py', 'verify.py', 'window.py' ] diff --git a/ui/src/virtualdisplay.in b/ui/src/virtualdisplay.in new file mode 100755 index 0000000..d60a05e --- /dev/null +++ b/ui/src/virtualdisplay.in @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + +# virtualdisplay.in +# +# Copyright 2024 Unknown +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +import os +import sys + +lib_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib') +sys.path.insert(0, lib_dir) + +from logging.handlers import TimedRotatingFileHandler + +config_home = os.environ.get('XDG_CONFIG_HOME', '~/.config') +config_dir = os.path.expanduser(config_home) +state_home = os.environ.get('XDG_STATE_HOME', '~/.local/state') +state_dir = os.path.expanduser(state_home) +breezy_state_dir = os.path.join(state_dir, 'breezy_gnome') +log_dir = os.path.join(breezy_state_dir, 'logs/ui') +os.makedirs(log_dir, exist_ok=True) + +logger = logging.getLogger('breezy_ui') +logger.setLevel(logging.INFO) +logname = os.path.join(log_dir, "breezy_desktop.log") +handler = TimedRotatingFileHandler(logname, when="midnight", backupCount=30) +handler.suffix = "%Y%m%d" +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) +logger.addHandler(handler) + +import argparse +import sys +import signal +import pydbus +import time + +VERSION = '@VERSION@' + +xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.join(os.path.expanduser('~'), '.local', 'share')) +appdir = os.getenv('APPDIR', xdg_data_home) +locale_dir = os.path.join(appdir, 'locale') +pkgdatadir = os.path.join(appdir, 'breezydesktop') +sys.path.insert(1, pkgdatadir) + +import gi + +gi.require_version('GLib', '2.0') + +from gi.repository import GLib + +def graceful_shutdown(signum, frame): + global virtual_display_instance + global loop + + if virtual_display_instance is not None: + virtual_display_instance.terminate() + +def _on_display_closed(): + global loop + loop.quit() + +def create_display(width, height, framerate): + global virtual_display_instance + + virtual_display_instance = VirtualDisplay(width, height, framerate, _on_display_closed) + virtual_display_instance.create() + +if __name__ == "__main__": + from breezydesktop import virtualdisplay + from breezydesktop.virtualdisplay import VirtualDisplay + + global virtual_display_instance + global loop + + parser = argparse.ArgumentParser(description="Virtual display arguments") + parser.add_argument("--height", type=int, required=True, help="Height of the display") + parser.add_argument("--width", type=int, required=True, help="Width of the display") + parser.add_argument("--framerate", type=int, default=60, help="Framerate of the display") + args = parser.parse_args() + + signal.signal(signal.SIGTERM, graceful_shutdown) + signal.signal(signal.SIGINT, graceful_shutdown) + + loop = GLib.MainLoop() + + try: + GLib.idle_add(create_display, args.width, args.height, args.framerate) + loop.run() + except Exception as e: + logger.error(f"Error in main loop: {e}") + sys.exit(1) \ No newline at end of file diff --git a/ui/src/virtualdisplay.py b/ui/src/virtualdisplay.py index 1d09ab3..a8db102 100644 --- a/ui/src/virtualdisplay.py +++ b/ui/src/virtualdisplay.py @@ -1,10 +1,13 @@ #!/usr/bin/python3 +import argparse import logging import sys import signal import pydbus import gi +import time + gi.require_version('Gst', '1.0') from gi.repository import GLib, GObject, Gst @@ -13,32 +16,35 @@ logger = logging.getLogger('breezy_ui') screen_cast_iface = 'org.gnome.Mutter.ScreenCast' screen_cast_session_iface = 'org.gnome.Mutter.ScreenCast.Session' screen_cast_stream_iface = 'org.gnome.Mutter.ScreenCast.Session' -gst_pipeline_format = "pipewiresrc path=%u ! video/x-raw,max-framerate=60/1,width=%d,height=%d ! fakesink sync=false" +gst_pipeline_format = "pipewiresrc path=%u ! video/x-raw,max-framerate=%d/1,width=%d,height=%d ! fakesink sync=false" - -def _screen_cast_session(): - bus = pydbus.SessionBus() - screen_cast = bus.get(screen_cast_iface, '/org/gnome/Mutter/ScreenCast') - session_path = screen_cast.CreateSession([]) - logger.info("session path: %s" % session_path) - screen_cast_session = bus.get(screen_cast_iface, session_path) - - return screen_cast_session - -class VirtualMonitor: - def __init__(self, width, height, on_ready_cb): +class VirtualDisplay: + def __init__(self, width, height, framerate, on_closed_cb): self.width = width self.height = height - self.on_ready_cb = on_ready_cb + self.framerate = framerate + self.on_closed_cb = on_closed_cb Gst.init(None) + def _screen_cast_session(self): + bus = pydbus.SessionBus() + screen_cast = bus.get(screen_cast_iface, '/org/gnome/Mutter/ScreenCast') + session_path = screen_cast.CreateSession([]) + screen_cast_session = bus.get(screen_cast_iface, session_path) + + return screen_cast_session + + def _on_session_closed(self): + self.stream = None + self.terminate() + def create(self): - session = _screen_cast_session() + session = self._screen_cast_session() + session.onClosed = self._on_session_closed stream_path = session.RecordVirtual({ 'is-platform': GLib.Variant.new_boolean(True), }) - logger.info("stream path: %s" % stream_path) bus = pydbus.SessionBus() self.stream = bus.get(screen_cast_iface, stream_path) @@ -47,25 +53,38 @@ class VirtualMonitor: session.Start() def terminate(self): - if self.stream is not None: - self.stream.Stop() + try: + if self.stream is not None: + self.stream.Stop() + except Exception as e: + logger.error("Failed to stop stream: %s" % e) - if self.pipeline is not None: - self.pipeline.send_event(Gst.Event.new_eos()) - self.pipeline.set_state(Gst.State.NULL) + try: + if self.pipeline is not None: + self.pipeline.send_event(Gst.Event.new_eos()) + self.pipeline.set_state(Gst.State.NULL) + except Exception as e: + logger.error("Failed to stop pipeline: %s" % e) + + self.on_closed_cb() def _on_message(self, bus, message): type = message.type logger.info("message type: %s" % type) - if type == Gst.MessageType.EOS or type == Gst.MessageType.ERROR: + if type == Gst.MessageType.EOS: + self.pipeline = None + self.terminate() + elif type == Gst.MessageType.ERROR: + err, debug = message.parse_error() + logger.error("Error: %s" % err) + logger.error("Debug: %s" % debug) self.terminate() def _on_pipewire_stream_added(self, node_id): - logger.info("pipe wire stream added: %u" % node_id) - - self.pipeline = Gst.parse_launch(gst_pipeline_format % (node_id, self.width, self.height)) + self.pipeline = Gst.parse_launch(gst_pipeline_format % (node_id, self.framerate, self.width, self.height)) self.pipeline.set_state(Gst.State.PLAYING) self.pipeline.get_bus().connect('message', self._on_message) self.pipeline.set_state(Gst.State.PAUSED) - - self.on_ready_cb() \ No newline at end of file + + + diff --git a/ui/src/virtualdisplaymanager.py b/ui/src/virtualdisplaymanager.py new file mode 100644 index 0000000..0b0a1e0 --- /dev/null +++ b/ui/src/virtualdisplaymanager.py @@ -0,0 +1,122 @@ +import gi +import json +import os +import signal +import subprocess +import time +from pathlib import Path + +import logging +logger = logging.getLogger('breezy_ui') + +gi.require_version('GLib', '2.0') +from gi.repository import GLib, GObject + +xdg_bin_home = os.getenv('XDG_BIN_HOME', os.path.join(os.path.expanduser('~'), '.local', 'bin')) +bindir = os.getenv('BINDIR', xdg_bin_home) + +class VirtualDisplayManager(GObject.GObject): + __gproperties__ = { + 'displays': (object, 'Displays', 'A list of the displays', GObject.ParamFlags.READWRITE) + } + _instance = None + + @staticmethod + def get_instance(): + if not VirtualDisplayManager._instance: + VirtualDisplayManager._instance = VirtualDisplayManager() + + return VirtualDisplayManager._instance + + def __init__(self): + GObject.GObject.__init__(self) + + self.shm_path = Path("/dev/shm/breezy_virtual_displays.json") + self._load_displays() + self._prune_dead_display_processes() + + GLib.timeout_add_seconds(15, self._prune_dead_display_processes) + + def _process_dead(self, pid): + if (not os.path.exists(f"/proc/{pid}")): + return True + + try: + if (os.waitpid(pid, os.WNOHANG) == (pid, 0)): + return True + except ChildProcessError: + return True + + return False + + def _prune_dead_display_processes(self): + self.set_property('displays', [disp for disp in self.displays if not self._process_dead(disp['pid'])]) + self._save_processes() + + return GLib.SOURCE_CONTINUE + + def create_virtual_display(self, width, height, framerate) -> int: + self._create_virtual_display(width, height, framerate) + + def _create_virtual_display(self, width, height, framerate): + try: + process = subprocess.Popen( + [f"{bindir}/virtualdisplay", "--width", str(width), "--height", str(height), "--framerate", str(framerate)], + start_new_session=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + if process.returncode is not None: + logger.error(f"Failed to create virtual display: {process.stderr.read()}") + return + + self.displays.append({ + 'pid': process.pid, + 'width': width, + 'height': height + }) + self.set_property('displays', self.displays) + self._save_processes() + except Exception as e: + logger.error(f"Failed to create virtual display: {e}") + + def destroy_virtual_display(self, pid: str) -> bool: + try: + # Send SIGTERM to allow graceful shutdown + os.killpg(pid, signal.SIGTERM) + self.set_property('displays', [disp for disp in self.displays if disp['pid'] != pid]) + self._save_processes() + return True + except ProcessLookupError: + # Process already gone, delete pid from list + self.set_property('displays', [disp for disp in self.displays if disp['pid'] != pid]) + self._save_processes() + return True + except Exception as e: + print(f"Failed to kill process {pid}: {e}") + return False + + def _save_processes(self): + with open(self.shm_path, 'w') as f: + json.dump(self.displays, f) + + def _load_displays(self): + displays = [] + if self.shm_path.exists(): + try: + with open(self.shm_path, 'r') as f: + displays = json.load(f) + except Exception: + displays = [] + + self.set_property('displays', displays) + + def do_set_property(self, prop, value): + if prop.name == 'displays': + self.displays = value + + def do_get_property(self, prop): + if prop.name == 'displays': + return self.displays \ No newline at end of file diff --git a/ui/src/window.py b/ui/src/window.py index 03e3cb1..375fa96 100644 --- a/ui/src/window.py +++ b/ui/src/window.py @@ -43,6 +43,13 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): def __init__(self, skip_verification, **kwargs): super().__init__(**kwargs) + + self.connected_device = ConnectedDevice() + self.failed_verification = FailedVerification() + self.no_device = NoDevice() + self.no_driver = NoDriver() + self.no_extension = NoExtension() + self.no_license = NoLicense() self._skip_verification = skip_verification @@ -54,13 +61,6 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): self.state_manager.connect('notify::enabled-features-list', self._handle_state_update) self.settings.connect('changed::debug-no-device', self._handle_settings_update) - self.connected_device = ConnectedDevice() - self.failed_verification = FailedVerification() - self.no_device = NoDevice() - self.no_driver = NoDriver() - self.no_extension = NoExtension() - self.no_license = NoLicense() - self.license_action_needed_button.connect('clicked', self._on_license_button_clicked) self.missing_breezy_features_button.connect('clicked', self._on_license_button_clicked)