Add virtual display creation to a separate Python script

UI kicks off and tracks all virtual display processes, even across sessions
This commit is contained in:
wheaney 2025-02-20 14:16:36 -08:00
parent fd40c6ca84
commit 854d74d983
9 changed files with 314 additions and 45 deletions

View File

@ -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_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._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; 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(); 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); this._custom_banner_enabled_binding = Globals.data_stream.bind_property('custom-banner-enabled', this._overlay_content, 'custom-banner-enabled', Gio.SettingsBindFlags.DEFAULT);

View File

@ -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 // if we're the focused display, we'll double the timeline and wait for the first half to let other
// displays ease out first // displays ease out first
this._distance_ease_focus = this._is_focused(); 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_start = this._current_display_distance;
this._distance_ease_timeline = Clutter.Timeline.new_for_actor(this.get_actor(), timeline_ms); 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(); let progress = this._distance_ease_timeline.get_progress();
if (this._distance_ease_focus) { if (this._distance_ease_focus) {
// if we're the focused display, wait for the first half of the timeline to pass // 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 // 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 // put this display in front as it starts to easy in
this.is_closest = true; this.is_closest = true;
@ -489,8 +495,8 @@ export const VirtualMonitorEffect = GObject.registerClass({
this.is_closest = false; this.is_closest = false;
} }
this._current_display_distance = this._distance_ease_start + this._current_display_distance = this._distance_ease_start +
progress * (this._distance_ease_target - this._distance_ease_start); (1 - Math.cos(progress * Math.PI)) / 2 * (this._distance_ease_target - this._distance_ease_start);
this._update_display_position_uniforms(); this._update_display_position_uniforms();
}).bind(this)); }).bind(this));

View File

@ -43,6 +43,7 @@ cp $UI_BUILD_PATH/src/breezydesktop.gresource $PACKAGE_BREEZY_DIR
cp -r po/mo/* $PACKAGE_LOCALE_DIR cp -r po/mo/* $PACKAGE_LOCALE_DIR
cp data/com.xronlinux.BreezyDesktop.gschema.xml $PACKAGE_SCHEMAS_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/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 cp $UI_BUILD_PATH/data/com.xronlinux.BreezyDesktop.desktop $PACKAGE_APPS_DIR
mkdir -p $PACKAGE_ICONS_DIR/64x64/apps mkdir -p $PACKAGE_ICONS_DIR/64x64/apps

View File

@ -5,7 +5,7 @@ from .license import BREEZY_GNOME_FEATURES
from .settingsmanager import SettingsManager from .settingsmanager import SettingsManager
from .shortcutdialog import bind_shortcut_settings from .shortcutdialog import bind_shortcut_settings
from .statemanager import StateManager from .statemanager import StateManager
from .virtualdisplay import VirtualMonitor from .virtualdisplaymanager import VirtualDisplayManager
from .xrdriveripc import XRDriverIPC from .xrdriveripc import XRDriverIPC
import gettext import gettext
import logging import logging
@ -60,7 +60,6 @@ class ConnectedDevice(Gtk.Box):
viewport_offset_y_scale = Gtk.Template.Child() viewport_offset_y_scale = Gtk.Template.Child()
viewport_offset_y_adjustment = Gtk.Template.Child() viewport_offset_y_adjustment = Gtk.Template.Child()
def __init__(self): def __init__(self):
super(Gtk.Box, self).__init__() super(Gtk.Box, self).__init__()
self.init_template() self.init_template()
@ -85,6 +84,7 @@ class ConnectedDevice(Gtk.Box):
self.settings = SettingsManager.get_instance().settings self.settings = SettingsManager.get_instance().settings
self.desktop_settings = SettingsManager.get_instance().desktop_settings self.desktop_settings = SettingsManager.get_instance().desktop_settings
self.ipc = XRDriverIPC.get_instance() self.ipc = XRDriverIPC.get_instance()
self.virtual_display_manager = VirtualDisplayManager.get_instance()
self.extensions_manager = ExtensionsManager.get_instance() self.extensions_manager = ExtensionsManager.get_instance()
self.settings.bind('display-distance', self.display_distance_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) 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._refresh_use_optimal_monitor_config(self.use_optimal_monitor_config_switch, None)
self.extensions_manager.connect('notify::breezy-enabled', self._handle_enabled_config) 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) self.connect("destroy", self._on_widget_destroy)
def _handle_monitor_wrapping_scheme_setting_changed(self, settings, val): def _handle_monitor_wrapping_scheme_setting_changed(self, settings, val):
@ -206,10 +209,11 @@ class ConnectedDevice(Gtk.Box):
reload_display_distance_toggle_button(widget) reload_display_distance_toggle_button(widget)
def on_add_virtual_display(self, width, height): 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): def _on_virtual_displays_update(self, virtual_display_manager, val):
logger.info("Virtual display ready") logger.info(f"Found {len(virtual_display_manager.displays)} virtual displays")
def _on_widget_destroy(self, widget): def _on_widget_destroy(self, widget):
# self.state_manager.unbind_property('follow-mode', self.follow_mode_switch, 'active') # self.state_manager.unbind_property('follow-mode', self.follow_mode_switch, 'active')

View File

@ -26,6 +26,15 @@ configure_file(
install_mode: 'r-xr-xr-x' 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 = [ breezydesktop_sources = [
'../modules/PyXRLinuxDriverIPC/xrdriveripc.py', '../modules/PyXRLinuxDriverIPC/xrdriveripc.py',
'__init__.py', '__init__.py',
@ -48,6 +57,7 @@ breezydesktop_sources = [
'statemanager.py', 'statemanager.py',
'time.py', 'time.py',
'virtualdisplay.py', 'virtualdisplay.py',
'virtualdisplaymanager.py',
'verify.py', 'verify.py',
'window.py' 'window.py'
] ]

108
ui/src/virtualdisplay.in Executable file
View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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)

View File

@ -1,10 +1,13 @@
#!/usr/bin/python3 #!/usr/bin/python3
import argparse
import logging import logging
import sys import sys
import signal import signal
import pydbus import pydbus
import gi import gi
import time
gi.require_version('Gst', '1.0') gi.require_version('Gst', '1.0')
from gi.repository import GLib, GObject, Gst 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_iface = 'org.gnome.Mutter.ScreenCast'
screen_cast_session_iface = 'org.gnome.Mutter.ScreenCast.Session' screen_cast_session_iface = 'org.gnome.Mutter.ScreenCast.Session'
screen_cast_stream_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"
class VirtualDisplay:
def _screen_cast_session(): def __init__(self, width, height, framerate, on_closed_cb):
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):
self.width = width self.width = width
self.height = height self.height = height
self.on_ready_cb = on_ready_cb self.framerate = framerate
self.on_closed_cb = on_closed_cb
Gst.init(None) 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): def create(self):
session = _screen_cast_session() session = self._screen_cast_session()
session.onClosed = self._on_session_closed
stream_path = session.RecordVirtual({ stream_path = session.RecordVirtual({
'is-platform': GLib.Variant.new_boolean(True), 'is-platform': GLib.Variant.new_boolean(True),
}) })
logger.info("stream path: %s" % stream_path)
bus = pydbus.SessionBus() bus = pydbus.SessionBus()
self.stream = bus.get(screen_cast_iface, stream_path) self.stream = bus.get(screen_cast_iface, stream_path)
@ -47,25 +53,38 @@ class VirtualMonitor:
session.Start() session.Start()
def terminate(self): def terminate(self):
if self.stream is not None: try:
self.stream.Stop() 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: try:
self.pipeline.send_event(Gst.Event.new_eos()) if self.pipeline is not None:
self.pipeline.set_state(Gst.State.NULL) 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): def _on_message(self, bus, message):
type = message.type type = message.type
logger.info("message type: %s" % 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() self.terminate()
def _on_pipewire_stream_added(self, node_id): 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.framerate, self.width, self.height))
self.pipeline = Gst.parse_launch(gst_pipeline_format % (node_id, self.width, self.height))
self.pipeline.set_state(Gst.State.PLAYING) self.pipeline.set_state(Gst.State.PLAYING)
self.pipeline.get_bus().connect('message', self._on_message) self.pipeline.get_bus().connect('message', self._on_message)
self.pipeline.set_state(Gst.State.PAUSED) self.pipeline.set_state(Gst.State.PAUSED)
self.on_ready_cb()

View File

@ -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

View File

@ -43,6 +43,13 @@ class BreezydesktopWindow(Gtk.ApplicationWindow):
def __init__(self, skip_verification, **kwargs): def __init__(self, skip_verification, **kwargs):
super().__init__(**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 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.state_manager.connect('notify::enabled-features-list', self._handle_state_update)
self.settings.connect('changed::debug-no-device', self._handle_settings_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.license_action_needed_button.connect('clicked', self._on_license_button_clicked)
self.missing_breezy_features_button.connect('clicked', self._on_license_button_clicked) self.missing_breezy_features_button.connect('clicked', self._on_license_button_clicked)