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.
- waymirror-0.1.0/.gitignore +8 -0
- waymirror-0.1.0/CHANGELOG.md +13 -0
- waymirror-0.1.0/LICENSE +21 -0
- waymirror-0.1.0/PKG-INFO +178 -0
- waymirror-0.1.0/README.md +150 -0
- waymirror-0.1.0/pyproject.toml +59 -0
- waymirror-0.1.0/src/waymirror/__init__.py +10 -0
- waymirror-0.1.0/src/waymirror/__main__.py +6 -0
- waymirror-0.1.0/src/waymirror/app.py +236 -0
- waymirror-0.1.0/src/waymirror/cli.py +99 -0
- waymirror-0.1.0/src/waymirror/data/hr.dobarkod.waymirror.desktop +11 -0
- waymirror-0.1.0/src/waymirror/data/hr.dobarkod.waymirror.svg +28 -0
- waymirror-0.1.0/src/waymirror/desktop.py +109 -0
- waymirror-0.1.0/src/waymirror/geometry.py +96 -0
- waymirror-0.1.0/src/waymirror/portal.py +165 -0
- waymirror-0.1.0/src/waymirror/preflight.py +42 -0
- waymirror-0.1.0/tests/test_geometry.py +117 -0
|
@@ -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).
|
waymirror-0.1.0/LICENSE
ADDED
|
@@ -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.
|
waymirror-0.1.0/PKG-INFO
ADDED
|
@@ -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,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()
|