waymirror 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - Initial release.
6
+ - Mirror a region of a Wayland screen into an ordinary GTK4 window via the
7
+ xdg-desktop-portal ScreenCast portal + PipeWire + GStreamer.
8
+ - `REGION` accepts `WxH+X+Y`, `left`, `right`, or nothing (whole monitor).
9
+ - Borderless, exact-size window locked to the region's aspect ratio (no
10
+ letterbox bars even when the compositor caps the height to the work area).
11
+ - `q`/`Esc` to quit; `--no-cursor`; persistent portal restore token.
12
+ - Optional desktop integration via the `waymirror-setup install` command
13
+ (launcher entry + icon in the user's XDG directories).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Senko Rasic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: waymirror
3
+ Version: 0.1.0
4
+ Summary: Mirror a region of a Wayland screen into a normal window.
5
+ Project-URL: Homepage, https://github.com/senko/waymirror
6
+ Project-URL: Repository, https://github.com/senko/waymirror
7
+ Project-URL: Issues, https://github.com/senko/waymirror/issues
8
+ Author-email: Senko Rasic <senko@senko.net>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: gnome,gstreamer,gtk4,mirror,pipewire,screen,screen-sharing,screencast,wayland,xdg-desktop-portal
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: X11 Applications :: GTK
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Desktop Environment :: Gnome
21
+ Classifier: Topic :: Multimedia :: Video :: Capture
22
+ Requires-Python: >=3.11
23
+ Provides-Extra: bindings
24
+ Requires-Dist: pygobject>=3.50; extra == 'bindings'
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # waymirror
30
+
31
+ Mirror a **region of a Wayland screen into an ordinary window** — so any meeting
32
+ app (Google Meet, Teams, Jitsi, Slack, …) can share *that window* and show your
33
+ selected region.
34
+
35
+ This is useful when your screencast app only lets you share either a whole screen
36
+ or a single window, but you want to share *part* of your screen - for example, if
37
+ you have a ultra-widescreen monitor.
38
+
39
+ It's the answer to "this app only lets me share a whole screen or a window, but I
40
+ want to share *part* of my screen." Point waymirror at a region; share the
41
+ waymirror window.
42
+
43
+ ## How it works
44
+
45
+
46
+ Waymirror:
47
+
48
+ 1. opens an **xdg-desktop-portal** `ScreenCast` session and gets a **PipeWire**
49
+ stream of a monitor (the compositor shows its own picker for *which* monitor;
50
+ you can't hand it a region, so we capture the whole monitor),
51
+ 2. **crops** the stream to your region with GStreamer's `videocrop`
52
+ (HiDPI-aware — the crop is computed from the negotiated buffer size vs. the
53
+ monitor's logical size),
54
+ 3. renders the result into a borderless **GTK4** window via `gtk4paintablesink`.
55
+
56
+ Pipeline: `pipewiresrc → videoconvert → videocrop → gtk4paintablesink`.
57
+
58
+ Because it relies on the desktop portal, waymirror works on **GNOME** and other
59
+ portal-supporting compositors (KDE, wlroots-based). It also runs under X11
60
+ sessions that provide the portal, but it's built for Wayland.
61
+
62
+ ## Requirements
63
+
64
+ The heavy lifting is done by **system** components (PyGObject, GStreamer plugins,
65
+ the GObject-Introspection typelibs, GTK4). These are **not** installable from PyPI
66
+ in a working way, so install them from your distribution first.
67
+
68
+ **Debian / Ubuntu** (verified on Debian 13 "trixie"):
69
+
70
+ ```bash
71
+ sudo apt install \
72
+ python3-gi gir1.2-gtk-4.0 gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 \
73
+ gstreamer1.0-pipewire gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
74
+ gstreamer1.0-gl gstreamer1.0-gtk4
75
+ ```
76
+
77
+ **Fedora** (package names may vary by release):
78
+
79
+ ```bash
80
+ sudo dnf install \
81
+ python3-gobject gtk4 \
82
+ gstreamer1-plugins-base gstreamer1-plugins-good \
83
+ pipewire-gstreamer gstreamer1-plugins-rs
84
+ ```
85
+
86
+ **Arch**:
87
+
88
+ ```bash
89
+ sudo pacman -S \
90
+ python-gobject gtk4 \
91
+ gst-plugins-base gst-plugins-good gst-plugin-pipewire gst-plugins-rs
92
+ ```
93
+
94
+ The pieces you need, whatever the package names: **PyGObject**, **GTK 4** +
95
+ typelib, **GStreamer** core + typelibs, and the elements `pipewiresrc`,
96
+ `videoconvert`, `videocrop`, and `gtk4paintablesink` (the last comes from the
97
+ GStreamer **Rust** plugins / `gstreamer1.0-gtk4`). waymirror checks these at
98
+ startup and tells you exactly what's missing.
99
+
100
+ ## Install
101
+
102
+ Because the bindings live on the system, install waymirror into an environment
103
+ that can see them. The easiest is pipx with system site packages:
104
+
105
+ ```bash
106
+ pipx install --system-site-packages waymirror
107
+ ```
108
+
109
+ With `uv` from source / for development:
110
+
111
+ ```bash
112
+ git clone https://github.com/senko/waymirror
113
+ cd waymirror
114
+ uv venv --python /usr/bin/python3 --system-site-packages
115
+ uv pip install -e .
116
+ ```
117
+
118
+ > The `--system-site-packages` flag (and `--python /usr/bin/python3` for uv) is
119
+ > what lets the venv use the distro's `gi`/GStreamer. A plain isolated venv won't
120
+ > find them.
121
+
122
+ Prefer pip to build the bindings instead? Install the build headers for your
123
+ distro and use the `bindings` extra: `pip install "waymirror[bindings]"` (you
124
+ still need the native GStreamer plugins).
125
+
126
+ ## Usage
127
+
128
+ ```bash
129
+ waymirror 800x600+100+100 # explicit region (+X+Y optional, defaults to +0+0)
130
+ waymirror left # exact left half of the selected monitor
131
+ waymirror right # exact right half
132
+ waymirror # whole selected monitor
133
+ waymirror right --no-cursor # hide the mouse pointer in the mirror
134
+ waymirror --help
135
+ ```
136
+
137
+ With `left`/`right`, waymirror reads the monitor size from the portal and splits
138
+ it in two — you don't need to know the resolution.
139
+
140
+ The first run shows the portal picker (choose the monitor your region is on); the
141
+ choice is remembered via a restore token in `~/.config/waymirror/restore-token`,
142
+ so later runs don't prompt.
143
+
144
+ ### The window
145
+
146
+ - **No title bar**, opens at exactly the region size.
147
+ - Locked to the region's **aspect ratio**; if it's resized (eg. to accomodate
148
+ the GNOME title bar), it keeps the aspect rate automatically.
149
+ - Quit with **`q`**, **`Esc`**, or `Ctrl-C`. Run several at once for several
150
+ regions.
151
+ - Move the window with `Super+drag`.
152
+ - `-v` / `WAYMIRROR_DEBUG=1` for verbose logging.
153
+
154
+ ## Desktop integration (optional)
155
+
156
+ waymirror is a CLI tool, but you can register a launcher entry + icon so GNOME
157
+ shows a proper name and icon for the window — in the dock/overview *and* in the
158
+ meeting app's window picker. It's a separate, opt-in command (installs into your
159
+ user XDG directories, no root needed):
160
+
161
+ ```bash
162
+ waymirror-setup install # ~/.local/share/applications + .../icons
163
+ waymirror-setup uninstall
164
+ ```
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ uv venv --python /usr/bin/python3 --system-site-packages
170
+ uv pip install -e ".[dev]"
171
+ python -m unittest discover -s tests # or: pytest
172
+ python -m waymirror left # run without installing
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT — see [LICENSE](LICENSE).
178
+
@@ -0,0 +1,150 @@
1
+ # waymirror
2
+
3
+ Mirror a **region of a Wayland screen into an ordinary window** — so any meeting
4
+ app (Google Meet, Teams, Jitsi, Slack, …) can share *that window* and show your
5
+ selected region.
6
+
7
+ This is useful when your screencast app only lets you share either a whole screen
8
+ or a single window, but you want to share *part* of your screen - for example, if
9
+ you have a ultra-widescreen monitor.
10
+
11
+ It's the answer to "this app only lets me share a whole screen or a window, but I
12
+ want to share *part* of my screen." Point waymirror at a region; share the
13
+ waymirror window.
14
+
15
+ ## How it works
16
+
17
+
18
+ Waymirror:
19
+
20
+ 1. opens an **xdg-desktop-portal** `ScreenCast` session and gets a **PipeWire**
21
+ stream of a monitor (the compositor shows its own picker for *which* monitor;
22
+ you can't hand it a region, so we capture the whole monitor),
23
+ 2. **crops** the stream to your region with GStreamer's `videocrop`
24
+ (HiDPI-aware — the crop is computed from the negotiated buffer size vs. the
25
+ monitor's logical size),
26
+ 3. renders the result into a borderless **GTK4** window via `gtk4paintablesink`.
27
+
28
+ Pipeline: `pipewiresrc → videoconvert → videocrop → gtk4paintablesink`.
29
+
30
+ Because it relies on the desktop portal, waymirror works on **GNOME** and other
31
+ portal-supporting compositors (KDE, wlroots-based). It also runs under X11
32
+ sessions that provide the portal, but it's built for Wayland.
33
+
34
+ ## Requirements
35
+
36
+ The heavy lifting is done by **system** components (PyGObject, GStreamer plugins,
37
+ the GObject-Introspection typelibs, GTK4). These are **not** installable from PyPI
38
+ in a working way, so install them from your distribution first.
39
+
40
+ **Debian / Ubuntu** (verified on Debian 13 "trixie"):
41
+
42
+ ```bash
43
+ sudo apt install \
44
+ python3-gi gir1.2-gtk-4.0 gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 \
45
+ gstreamer1.0-pipewire gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
46
+ gstreamer1.0-gl gstreamer1.0-gtk4
47
+ ```
48
+
49
+ **Fedora** (package names may vary by release):
50
+
51
+ ```bash
52
+ sudo dnf install \
53
+ python3-gobject gtk4 \
54
+ gstreamer1-plugins-base gstreamer1-plugins-good \
55
+ pipewire-gstreamer gstreamer1-plugins-rs
56
+ ```
57
+
58
+ **Arch**:
59
+
60
+ ```bash
61
+ sudo pacman -S \
62
+ python-gobject gtk4 \
63
+ gst-plugins-base gst-plugins-good gst-plugin-pipewire gst-plugins-rs
64
+ ```
65
+
66
+ The pieces you need, whatever the package names: **PyGObject**, **GTK 4** +
67
+ typelib, **GStreamer** core + typelibs, and the elements `pipewiresrc`,
68
+ `videoconvert`, `videocrop`, and `gtk4paintablesink` (the last comes from the
69
+ GStreamer **Rust** plugins / `gstreamer1.0-gtk4`). waymirror checks these at
70
+ startup and tells you exactly what's missing.
71
+
72
+ ## Install
73
+
74
+ Because the bindings live on the system, install waymirror into an environment
75
+ that can see them. The easiest is pipx with system site packages:
76
+
77
+ ```bash
78
+ pipx install --system-site-packages waymirror
79
+ ```
80
+
81
+ With `uv` from source / for development:
82
+
83
+ ```bash
84
+ git clone https://github.com/senko/waymirror
85
+ cd waymirror
86
+ uv venv --python /usr/bin/python3 --system-site-packages
87
+ uv pip install -e .
88
+ ```
89
+
90
+ > The `--system-site-packages` flag (and `--python /usr/bin/python3` for uv) is
91
+ > what lets the venv use the distro's `gi`/GStreamer. A plain isolated venv won't
92
+ > find them.
93
+
94
+ Prefer pip to build the bindings instead? Install the build headers for your
95
+ distro and use the `bindings` extra: `pip install "waymirror[bindings]"` (you
96
+ still need the native GStreamer plugins).
97
+
98
+ ## Usage
99
+
100
+ ```bash
101
+ waymirror 800x600+100+100 # explicit region (+X+Y optional, defaults to +0+0)
102
+ waymirror left # exact left half of the selected monitor
103
+ waymirror right # exact right half
104
+ waymirror # whole selected monitor
105
+ waymirror right --no-cursor # hide the mouse pointer in the mirror
106
+ waymirror --help
107
+ ```
108
+
109
+ With `left`/`right`, waymirror reads the monitor size from the portal and splits
110
+ it in two — you don't need to know the resolution.
111
+
112
+ The first run shows the portal picker (choose the monitor your region is on); the
113
+ choice is remembered via a restore token in `~/.config/waymirror/restore-token`,
114
+ so later runs don't prompt.
115
+
116
+ ### The window
117
+
118
+ - **No title bar**, opens at exactly the region size.
119
+ - Locked to the region's **aspect ratio**; if it's resized (eg. to accomodate
120
+ the GNOME title bar), it keeps the aspect rate automatically.
121
+ - Quit with **`q`**, **`Esc`**, or `Ctrl-C`. Run several at once for several
122
+ regions.
123
+ - Move the window with `Super+drag`.
124
+ - `-v` / `WAYMIRROR_DEBUG=1` for verbose logging.
125
+
126
+ ## Desktop integration (optional)
127
+
128
+ waymirror is a CLI tool, but you can register a launcher entry + icon so GNOME
129
+ shows a proper name and icon for the window — in the dock/overview *and* in the
130
+ meeting app's window picker. It's a separate, opt-in command (installs into your
131
+ user XDG directories, no root needed):
132
+
133
+ ```bash
134
+ waymirror-setup install # ~/.local/share/applications + .../icons
135
+ waymirror-setup uninstall
136
+ ```
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ uv venv --python /usr/bin/python3 --system-site-packages
142
+ uv pip install -e ".[dev]"
143
+ python -m unittest discover -s tests # or: pytest
144
+ python -m waymirror left # run without installing
145
+ ```
146
+
147
+ ## License
148
+
149
+ MIT — see [LICENSE](LICENSE).
150
+
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "waymirror"
3
+ dynamic = ["version"]
4
+ description = "Mirror a region of a Wayland screen into a normal window."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Senko Rasic", email = "senko@senko.net" }]
10
+ keywords = [
11
+ "wayland", "screen", "mirror", "screencast", "screen-sharing",
12
+ "pipewire", "gstreamer", "gtk4", "xdg-desktop-portal", "gnome",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: X11 Applications :: GTK",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Desktop Environment :: Gnome",
24
+ "Topic :: Multimedia :: Video :: Capture",
25
+ ]
26
+ # Runtime depends on SYSTEM packages (PyGObject + GStreamer plugins + GI
27
+ # typelibs + GTK4) that cannot be reliably installed from PyPI. See README.
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ # Convenience for users who prefer pip to build the Python bindings instead of
32
+ # the distro's python3-gi. You STILL need the native GStreamer plugins +
33
+ # typelibs from your distro.
34
+ bindings = ["PyGObject>=3.50"]
35
+ dev = ["pytest"]
36
+
37
+ [project.scripts]
38
+ waymirror = "waymirror.cli:main"
39
+ # optional desktop integration (launcher + icon); run after install:
40
+ # waymirror-setup install
41
+ waymirror-setup = "waymirror.desktop:main"
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/senko/waymirror"
45
+ Repository = "https://github.com/senko/waymirror"
46
+ Issues = "https://github.com/senko/waymirror/issues"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.version]
53
+ path = "src/waymirror/__init__.py"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/waymirror"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = ["src/waymirror", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
@@ -0,0 +1,10 @@
1
+ """waymirror - mirror a region of a Wayland screen into a normal window.
2
+
3
+ On Wayland (and GNOME in particular) you cannot read another window's pixels or
4
+ create a virtual monitor the way X11 tools do. waymirror instead asks the
5
+ xdg-desktop-portal ScreenCast portal for a PipeWire stream of a monitor, crops
6
+ it to the region you ask for, and shows it in an ordinary GTK4 window -- which a
7
+ meeting app can then share as a *window* (sharp), not as a blurred camera feed.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,236 @@
1
+ """GTK4 application: portal stream -> crop -> window."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ import gi
7
+
8
+ gi.require_version("Gst", "1.0")
9
+ gi.require_version("Gtk", "4.0")
10
+ gi.require_version("Gdk", "4.0")
11
+ from gi.repository import Gdk, GLib, Gio, Gst, Gtk # noqa: E402
12
+
13
+ from .geometry import compute_crop, fit_within_bounds, resolve_half
14
+ from .portal import (
15
+ CURSOR_MODE_EMBEDDED,
16
+ CURSOR_MODE_HIDDEN,
17
+ PortalScreenCast,
18
+ )
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ APP_ID = "hr.dobarkod.waymirror"
23
+
24
+
25
+ def _restore_token_path():
26
+ return os.path.join(GLib.get_user_config_dir(), "waymirror", "restore-token")
27
+
28
+
29
+ def _load_restore_token():
30
+ try:
31
+ with open(_restore_token_path(), encoding="utf-8") as f:
32
+ return f.read().strip() or None
33
+ except OSError:
34
+ return None
35
+
36
+
37
+ def _save_restore_token(token):
38
+ path = _restore_token_path()
39
+ try:
40
+ os.makedirs(os.path.dirname(path), exist_ok=True)
41
+ with open(path, "w", encoding="utf-8") as f:
42
+ f.write(token)
43
+ except OSError as e:
44
+ log.warning("could not save restore token: %s", e)
45
+
46
+
47
+ class WayMirrorApp(Gtk.Application):
48
+ def __init__(self, geometry, half, show_cursor):
49
+ # NON_UNIQUE so several windows (different regions) can coexist.
50
+ super().__init__(
51
+ application_id=APP_ID,
52
+ flags=Gio.ApplicationFlags.NON_UNIQUE,
53
+ )
54
+ self.geometry = geometry # (w, h, x, y) or None
55
+ self.half = half # "left"/"right", resolved once monitor size is known
56
+ self.show_cursor = show_cursor
57
+ self.window = None
58
+ self.picture = None
59
+ self.pipeline = None
60
+ self.portal = None
61
+ # target display size (logical px), known once we have the region; keeps
62
+ # the window the right shape (see _on_compute_size).
63
+ self.desired_w = None
64
+ self.desired_h = None
65
+
66
+ # -- lifecycle --------------------------------------------------------
67
+ def do_activate(self):
68
+ if self.window:
69
+ self.window.present()
70
+ return
71
+
72
+ self.picture = Gtk.Picture()
73
+ # COVER fills the whole window keeping aspect, so there are never any
74
+ # letterbox bars; with the aspect-locked window below, the cropped-off
75
+ # sliver is sub-pixel.
76
+ self.picture.set_content_fit(Gtk.ContentFit.COVER)
77
+ self.picture.set_can_shrink(True)
78
+
79
+ self.window = Gtk.ApplicationWindow(application=self)
80
+ self.window.set_decorated(False) # no title bar / window chrome
81
+ self.window.set_child(self.picture)
82
+
83
+ keys = Gtk.EventControllerKey()
84
+ keys.connect("key-pressed", self._on_key)
85
+ self.window.add_controller(keys)
86
+
87
+ # keep the window locked to the content's aspect ratio: when GNOME caps
88
+ # the height to the work area (top bar), shrink the width to match.
89
+ self.window.connect("realize", self._on_window_realize)
90
+
91
+ # the window is shown from _on_stream_ready, once we know the region
92
+ # size (left/right need the monitor size).
93
+ GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, self._quit) # SIGINT
94
+ GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, self._quit) # SIGTERM
95
+
96
+ bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
97
+ cursor_mode = CURSOR_MODE_EMBEDDED if self.show_cursor else CURSOR_MODE_HIDDEN
98
+ self.portal = PortalScreenCast(
99
+ bus, cursor_mode, self._on_stream_ready, self._on_portal_error,
100
+ restore_token=_load_restore_token(), save_token=_save_restore_token,
101
+ )
102
+ self.portal.start()
103
+
104
+ def do_shutdown(self):
105
+ if self.pipeline:
106
+ self.pipeline.set_state(Gst.State.NULL)
107
+ Gtk.Application.do_shutdown(self)
108
+
109
+ # -- window sizing ----------------------------------------------------
110
+ def _set_target(self, w, h):
111
+ self.desired_w = w
112
+ self.desired_h = h
113
+
114
+ def _present_window(self):
115
+ if self.geometry:
116
+ w, h, x, y = self.geometry
117
+ self.window.set_title(f"waymirror {w}x{h}+{x}+{y}")
118
+ else:
119
+ self.window.set_title("waymirror")
120
+ if self.desired_w and self.desired_h:
121
+ self.window.set_default_size(self.desired_w, self.desired_h)
122
+ else:
123
+ self.window.set_default_size(960, 540)
124
+ self.window.present()
125
+
126
+ def _on_window_realize(self, widget):
127
+ surface = widget.get_surface() # GdkToplevel once realized
128
+ if surface is not None:
129
+ surface.connect("compute-size", self._on_compute_size)
130
+
131
+ def _on_compute_size(self, _toplevel, size):
132
+ if not self.desired_w or not self.desired_h:
133
+ return
134
+ bounds_w, bounds_h = size.get_bounds()
135
+ w, h = fit_within_bounds(self.desired_w, self.desired_h, bounds_w, bounds_h)
136
+ size.set_size(w, h)
137
+ log.debug("compute-size bounds=(%s,%s) -> %dx%d", bounds_w, bounds_h, w, h)
138
+
139
+ # -- input ------------------------------------------------------------
140
+ def _on_key(self, _controller, keyval, _keycode, _state):
141
+ if keyval in (Gdk.KEY_q, Gdk.KEY_Q, Gdk.KEY_Escape):
142
+ self.quit()
143
+ return True
144
+ return False
145
+
146
+ def _quit(self, *_):
147
+ self.quit()
148
+ return GLib.SOURCE_REMOVE
149
+
150
+ # -- stream -----------------------------------------------------------
151
+ def _on_portal_error(self, message):
152
+ log.error("%s", message)
153
+ self.quit()
154
+
155
+ def _on_stream_ready(self, fd, node_id, monitor_rect):
156
+ log.debug("streaming node %s (fd %s), monitor=%s", node_id, fd, monitor_rect)
157
+
158
+ # resolve left/right into a concrete region now that we know the monitor
159
+ if self.half:
160
+ if not monitor_rect:
161
+ self._on_portal_error(
162
+ "portal did not report monitor size; use WxH+X+Y "
163
+ "instead of left/right"
164
+ )
165
+ return
166
+ self.geometry = resolve_half(self.half, monitor_rect)
167
+
168
+ # pick the display target (logical size) and show the window
169
+ if self.geometry:
170
+ self._set_target(self.geometry[0], self.geometry[1])
171
+ elif monitor_rect:
172
+ self._set_target(monitor_rect[2], monitor_rect[3])
173
+ self._present_window()
174
+
175
+ self.pipeline = Gst.Pipeline.new("waymirror")
176
+ src = Gst.ElementFactory.make("pipewiresrc")
177
+ src.set_property("fd", fd)
178
+ src.set_property("path", str(node_id))
179
+ conv = Gst.ElementFactory.make("videoconvert")
180
+ crop = Gst.ElementFactory.make("videocrop")
181
+ # gtk4paintablesink negotiates GPU memory (DMABuf/GLMemory), but
182
+ # videocrop can only crop raw system-memory frames -- force that here.
183
+ rawfilter = Gst.ElementFactory.make("capsfilter")
184
+ rawfilter.set_property("caps", Gst.Caps.from_string("video/x-raw"))
185
+ sink = Gst.ElementFactory.make("gtk4paintablesink")
186
+ for el in (src, conv, crop, rawfilter, sink):
187
+ self.pipeline.add(el)
188
+ src.link(conv)
189
+ conv.link(crop)
190
+ crop.link(rawfilter)
191
+ rawfilter.link(sink)
192
+
193
+ self.picture.set_paintable(sink.get_property("paintable"))
194
+
195
+ # crop must be computed from the *negotiated* buffer size (HiDPI aware),
196
+ # so wait for caps on the cropper's sink pad, then set it once.
197
+ if self.geometry:
198
+ crop_pad = crop.get_static_pad("sink")
199
+ crop_pad.add_probe(
200
+ Gst.PadProbeType.EVENT_DOWNSTREAM,
201
+ self._configure_crop, crop, monitor_rect,
202
+ )
203
+
204
+ bus = self.pipeline.get_bus()
205
+ bus.add_signal_watch()
206
+ bus.connect("message::error", self._on_gst_error)
207
+ bus.connect("message::eos", lambda *_: self._quit())
208
+
209
+ self.pipeline.set_state(Gst.State.PLAYING)
210
+
211
+ def _configure_crop(self, pad, info, crop, monitor_rect):
212
+ event = info.get_event()
213
+ if event.type != Gst.EventType.CAPS:
214
+ return Gst.PadProbeReturn.OK
215
+ s = event.parse_caps().get_structure(0)
216
+ ok_w, cw = s.get_int("width")
217
+ ok_h, ch = s.get_int("height")
218
+ if not (ok_w and ok_h):
219
+ return Gst.PadProbeReturn.OK
220
+
221
+ left, top, right, bottom = compute_crop(cw, ch, monitor_rect, self.geometry)
222
+ crop.set_property("left", left)
223
+ crop.set_property("top", top)
224
+ crop.set_property("right", right)
225
+ crop.set_property("bottom", bottom)
226
+ log.debug(
227
+ "buffer %dx%d, crop l=%d t=%d r=%d b=%d", cw, ch, left, top, right, bottom
228
+ )
229
+ return Gst.PadProbeReturn.REMOVE
230
+
231
+ def _on_gst_error(self, _bus, message):
232
+ err, debug = message.parse_error()
233
+ log.error("gstreamer error: %s", err.message)
234
+ if debug:
235
+ log.debug("%s", debug)
236
+ self.quit()