Show a single Windows application window in any web browser on your local network — live, view-only, no client software.
WindowStream is a small Windows tray application written in Python. It captures the contents of one chosen window and serves it as an MJPEG stream over HTTP. Open http://<PC-address>:8080/ in the browser of a tablet, phone or another computer and you see what that window shows on the PC, updating in real time. Nothing can be controlled from the viewer: it is a one-way, read-only mirror of a single window.
It was built for a concrete need — showing a map application running on a work PC on an Android tablet over Wi-Fi — but it works with any window.
UI language. The tray menu, notifications, console messages and the web viewer are in English by default. Set
"language": "ru"in the settings file for Russian (see Interface language). Every tray menu item is listed in both languages in Tray icon and menu. User manuals for the ready-made.exeare available in Russian (user_manual_ru.md), English (user_manual_en.md) and German (user_manual_de.md).
- Features
- How it works
- Requirements
- Installation
- Quick start
- Usage
- Capture modes
- Performance tuning
- HTTP endpoints and stream format
- Building a standalone executable
- Networking and security
- Troubleshooting
- Known limitations
- Project layout and code map
- License
Streaming
- Streams the client area of one window as MJPEG (multipart/x-mixed-replace) — works in any modern browser, no plugins.
- Strictly view-only: the viewer cannot send any input to the PC.
- Two capture modes: screen(copy of what is visible on screen) andprintwindow(asks the window itself to render, so other windows on top do not appear in the stream).
- Change detection: a CRC32 of the raw frame is compared with the previous one, so an unchanged picture costs almost no CPU and no network traffic.
- Fast JPEG encoding through libjpeg-turbo (PyTurboJPEG), with an automatic fallback to Pillow.
- Adjustable frame rate, JPEG quality and scale.
- Several viewers can watch at the same time.
Tray application
- Lives in the system tray; colour-coded icon (grey — stopped, green — streaming, amber — paused).
- Menu: start, pause/resume, stop, pick the window from a list of open windows, bring the window to the front, toggle start with Windows, open the settings file, exit.
- Tooltip shows the real capture rate, the rate of new frames, the number of connected viewers and the address to open.
- Notifications for start, errors and waiting for the window.
- Bilingual interface (English by default, Russian on request).
Convenience
- Settings are stored in a JSON file; command-line switches override it, and --savewrites them back.
- The chosen window is remembered by title and by process, so it is found again even if its title changes (for example when another document is opened).
- Start with Windows (per-user, no administrator rights). If the window is not open yet at logon, the program waits for it.
- Only one copy runs at a time per settings file; a second launch says the program is already running and exits.
- Single-file .exebuild with PyInstaller (script included).
Web viewer
- Rounded "bezel" frame filling the whole browser window; the picture is fitted with the aspect ratio kept.
- Status badge in the top-right corner: Live / Paused / No connection; the frame glow follows the state.
- Pinch-to-zoom (up to ×8), drag to pan, double-tap to zoom in/out, mouse wheel on desktop.
- Live fps and latency readout (long-press to hide/show).
- The badge and buttons auto-hide during normal streaming and reappear on tap; they stay visible on pause and connection loss.
- Fullscreen button; the browser tab title follows the streamed window's title.
- Automatic reconnection when the PC side is restarted, stopped or the network drops.
Windows PC Viewer (tablet / phone / PC)
┌─────────────────────────────────────────────────────────────────────────────────────┐ ┌───────────────────────────┐
│ target window │ │ browser │
│ │ screen copy (mss) or PrintWindow │ │ GET / (page) │
│ ▼ │ │ GET /status (1 Hz) │
│ capture thread ──► CRC32 changed? ──► JPEG │ HTTP │ GET /stream (MJPEG) │
│ │ │ ─────► │ │ │
│ ▼ │ │ ▼ │
│ FrameBuffer │ │ parse frames, show, zoom │
│ │ │ └───────────────────────────┘
│ HTTP server threads (one per viewer) │
│ │
│ tray icon (main thread) ◄─► Broadcaster (start / pause / stop, window switching) │
└─────────────────────────────────────────────────────────────────────────────────────┘
Threads
Frame buffer. The capture thread publishes the latest JPEG together with a sequence number and the capture timestamp into a FrameBuffer guarded by a condition variable. Each /stream connection waits for a new sequence number and writes the newest frame; a slow viewer therefore skips frames instead of building up a queue, and one slow viewer does not slow down the others.
Keep-alive. If nothing changes for 5 seconds, the server re-sends the latest frame with the same sequence number. This keeps the connection alive and lets the page tell "no new frame" from "connection lost".
Latency estimate. The page polls /status once per second. The response contains the PC clock (t, milliseconds). From the round-trip time the page estimates the clock offset between the PC and the viewer (it keeps the sample with the smallest round trip out of the last 30) and computes latency = now − capture_timestamp + offset for every new frame. This is capture-to-receive time; decoding and painting add a little on top.
To run the prebuilt .exe: Windows 10 or 11 (64-bit). Nothing else — Python is not needed.
To run from source:
- Windows 10 / 11
- Python 3.9 or newer (64-bit)
- Packages (also listed in requirements.txt):
Viewer: any modern browser that supports fetch streaming and Pointer Events (current versions of Chrome, Edge, Firefox and other Chromium-based browsers, including the Android ones), on the same network as the PC.
- Download WindowStream.exefrom the latest release (each release lists a SHA-256 checksum;--versionshows the version you have).
- Put it in a permanent folder (for example C:\Tools\WindowStream\). Do not run it from a temporary or Downloads folder if you plan to use Start with Windows.
- Run it. On first start Windows may show a SmartScreen warning (the file is not code-signed) and a firewall prompt — allow access on private networks.
git clone https://github.com/eugenyh/WindowStream.git
cd WindowStream
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txtOptional but recommended — libjpeg-turbo. PyTurboJPEG needs the native library. Download the official libjpeg-turbo installer for Visual C++ (64-bit) from the project's releases page and install it to the default folder C:\libjpeg-turbo64. Without it the program prints a notice and encodes with Pillow, which works but uses more CPU.
Run:
python window_stream.pyTo run without a console window use pythonw window_stream.py.
- Start the program. A grey icon appears in the notification area (it may be under the ^ Show hidden icons arrow).
- Right-click the icon → Window: not selected → choose the window to share. The broadcast starts immediately and the icon turns green.
- Hover the icon: the tooltip shows the address, for example http://192.168.1.20:8080/.
- Open that address in the browser of the tablet (same network).
- Done. Next time the program starts, it remembers the window and begins streaming by itself.
Or, from the command line, in one step:
python window_stream.py --title "Untitled - Map" --mode printwindow --saveIcon states
Tooltip (streaming):
Window broadcast: live
Capture 10 fps · new frames 3 fps · clients 1
http://192.168.1.20:8080/
- Capture — how many times per second the window is actually grabbed.
- new frames — how many changed frames per second are encoded and sent. It is 0while the picture does not change; that is normal.
- clients — number of open viewer connections.
Tray menu
Notifications (Windows toast / balloon)
The window to stream is described by a title (a full title or any part of it) and optionally a process name (for example myapp.exe). Selecting a window from the tray menu stores both.
Matching rules, in order of priority (case-insensitive):
- the title equals the saved title;
- the title starts with the saved text;
- the title contains the saved text;
- if a process name is saved and no title matched: the largest visible window of that process (so the stream survives a title change, e.g. when another document is opened in the same application).
If a process is saved, only windows of that process are considered.
The following are never matched or listed: console windows (classic console, Windows Terminal), the desktop, tool windows, cloaked/hidden UWP windows and — in the menu — the program's own windows and windows smaller than 50×50 px (unless minimized). The menu shows up to 40 windows, sorted by title.
Tip: run WindowStream.exe --list from a console to print handle, process and title of every window.
Status badge (top right)
While live, the badge shows N fps · M ms (received new frames per second · estimated latency). In Russian mode the labels are Транслируется / Пауза / Нет связи and мс.
Controls
The browser tab title equals the streamed window's title and follows it when it changes.
For a sharp picture when zoomed in, use
--scale 1.0(the default). With a smaller scale the frame is already downsized on the PC.
window_stream.py [options]
WindowStream.exe [options]
In the windowed .exe there is no console of its own: --list, --help and --version attach to the console they were started from (the program looks for it up the chain of parent processes, because a --onefile exe is started through a bootloader). The exe is a windowed program, so cmd.exe does not wait for it and the prompt may return before the text appears; use start /wait "" WindowStream.exe --list to wait. If no console is found (for example, started by double-click), the text is shown in a message box.
Location: %APPDATA%\WindowStream\settings.json. It is created on the first run from the effective parameters of that run. Use the tray item Open settings file to edit it; restart the program after editing.
{
"title": "Untitled - Map",
"process": "mapapp.exe",
"port": 8080,
"fps": 15.0,
"quality": 75,
"scale": 1.0,
"mode": "printwindow",
"no_diff": false,
"no_turbo": false,
"autostart_broadcast": true,
"language": "en"
}Precedence: command-line options override the file. Boolean switches can only turn a feature on from the command line; to turn one off, edit the file. --save stores title, process, port, fps, quality, scale, mode, no_diff and no_turbo (not autostart_broadcast or language). A malformed file is ignored (defaults are used); wrong values of individual keys are skipped.
All user-visible text lives in the I18N dictionary in window_stream.py (en and ru), looked up with T(key, **kw). The language is chosen once at startup from the language key of the settings file; an unknown value falls back to English. There is no command-line switch for it, so it is never written by --save.
The web page uses @@key@@ tokens that are replaced with the web_* strings when the page is served, so the viewer follows the same language as the tray. To add a language, add a complete set of keys to I18N (the key sets of all languages must match).
The tray item Start with Windows adds or removes the value WindowStream under
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
No administrator rights are needed and it applies to the current user only.
- From the .exe: the value is the quoted path to the executable.
- From source: pythonw.exe "<path>\window_stream.py"(no console window).
- A non-default --configpath is appended automatically.
- If the .exeis moved, the registry path is refreshed the next time the program is started from its new location.
- At logon the window may not be open yet. The program then waits and retries every 5 seconds until it appears.
A named mutex (Local\WindowStream_<hash of the settings-file path>) prevents starting a second copy that uses the same settings file. The second copy shows "The program is already running." and exits. --list and --help still work while another copy is running.
To stream two windows at once, run two copies with different --config files and different ports.
Recommendation: printwindow is the default; if the picture is black or wrong for your application, use screen (--mode screen --save) and keep the window in front. When you select a window from the tray menu, it is restored and brought to the front automatically, which matters for screen mode.
The captured area is always the client area (without the title bar and borders), at real pixel size (the process is DPI-aware).
CPU is spent mainly on JPEG encoding and, when scaling, on resizing. Network use is proportional to frame size × new frames per second.
Notes:
- With change detection on (default), a static picture costs almost nothing. --no-diffre-encodes every frame and is only useful for comparison.
- With scale = 1.0TurboJPEG encodes the raw capture buffer directly (fastest path). Withscale ≠ 1.0the frame is first resized with Pillow (bilinear).
- The TurboJPEG encoder uses the fast-DCT flag and default 4:2:2 chroma subsampling.
The server is a standard-library ThreadingHTTPServer (HTTP/1.0) listening on 0.0.0.0:<port>.
Stream format. Each part carries two extra headers used by the viewer; a plain <img src="…/stream"> should still work, because browsers ignore part headers they do not know.
--frame
Content-Type: image/jpeg
Content-Length: 123456
X-Seq: 42
X-Ts: 1759140000123.4
<JPEG bytes>
- X-Seq— frame sequence number; a repeated number is a keep-alive re-send.
- X-Ts— capture time on the PC (Unix time, milliseconds).
Timing constants: server keep-alive 5 s; page polls /status every 1 s; stream reconnect 1.5 s; watchdog aborts a silent stream after 12 s; the UI auto-hides after 4 s.
Build on Windows (PyInstaller cannot cross-compile).
- Install the runtime dependencies (see Installation) and, ideally, libjpeg-turbo at C:\libjpeg-turbo64(build_exe.batalso looks inD:\libjpeg-turbo64).
- Put window_stream.py,build_exe.batandmake_icon.pyin one folder and run:
build_exe.batThe script installs PyInstaller, generates icon.ico, and runs:
python -m PyInstaller --noconfirm --clean --onefile --noconsole ^
--name WindowStream --icon icon.ico ^
--hidden-import pystray._win32 ^
--exclude-module tkinter --exclude-module matplotlib ^
--add-binary "C:\libjpeg-turbo64\bin\turbojpeg.dll;." window_stream.pyResult: dist\WindowStream.exe.
Details worth knowing:
- turbojpeg.dllis bundled into the executable and located at runtime through- sys._MEIPASS, so target machines need nothing installed. If the DLL is missing at build time the script warns you and the build falls back to Pillow encoding.
- --hidden-import pystray._win32is required:- pystraypicks its backend dynamically and PyInstaller does not detect it.
- --noconsoleproduces a GUI executable with no- stdout; diagnostics are shown as tray notifications. For- --list/- --helpthe program attaches to the parent console.
- A --onefileexecutable unpacks itself to a temporary folder on every launch (1–3 s) and is ~40–60 MB because of numpy and Pillow. Use--onedirfor faster start-up.
- Unsigned PyInstaller binaries are sometimes flagged by antivirus software (false positives are common with pywin32). Sign the executable or add an exclusion if needed.
- The .batfile must have Windows (CRLF) line endings.
Publishing a release. The built dist\WindowStream.exe is not stored in git (dist/ is ignored); attach it to a GitHub release instead:
- Set __version__inwindow_stream.py(semantic versioning, e.g.1.0.0), commit and merge tomain.
- Build with build_exe.batand check the result:dist\WindowStream.exe --versionfrom a console.
- On GitHub: Releases → Draft a new release, create the tag v<version>(it must match__version__) onmain, attachWindowStream.exe, add the SHA-256 (Get-FileHash dist\WindowStream.exein PowerShell) and the change notes.
- The server listens on all interfaces, without authentication and without encryption (plain HTTP). Anyone who can reach the port can watch the window. Use it only on trusted networks.
- Windows Firewall will ask on first start — allow private networks only. To restrict further, create an inbound rule for the program/port limited to your local subnet.
- Many Wi-Fi routers offer client isolation (AP isolation) which blocks device-to-device traffic; disable it for the network you use.
- Addresses 169.254.x.xare link-local (Windows could not obtain an address from a DHCP server). They work only if the viewer is also in that range. Prefer a normal address such as192.168.x.x/10.x.x.x; it is listed first when available.
- The tray tooltip shows the preferred address; ipconfiglists all of them.
- The viewer is read-only, but the content of the window is exposed to everyone who connects. Do not stream windows with confidential data on shared networks.
- To reach it from outside the LAN, use a VPN. Do not expose the port to the Internet.
- One window per program instance; no audio; no input from the viewer; no multi-user access control.
- Only the client area is captured (no title bar).
- Minimized windows are not captured; the last frame remains on the viewer.
- Regions of a window outside the desktop are not updated (Windows/application behaviour).
- printwindowdoes not work correctly with every GPU-rendered application; DRM-protected video is black in both modes.
- Plain HTTP only; no authentication.
- Settings changes made by editing the file take effect after a restart.
- The user interface is available in English and Russian only (languagesetting); the German user manual describes the English interface.
window_stream.py the whole application (single file)
requirements.txt runtime dependencies (CRLF line endings)
build_exe.bat PyInstaller build script (CRLF line endings)
make_icon.py generates icon.ico for the executable
tests/ unit tests (standard library only, run on any OS)
README.md this file
user_manual_ru.md user manual for the ready-made exe (Russian)
user_manual_en.md user manual for the ready-made exe (English)
user_manual_de.md user manual for the ready-made exe (German)
LICENSE MIT license text
Main parts of window_stream.py:
The web viewer is a single self-contained HTML document embedded in the script as a raw string; it can be edited in place. page_for() replaces the <title>Map</title> placeholder and the @@…@@ tokens, so keep those placeholders intact when editing PAGE.
python -m unittest discover -s tests -v
The tests cover the pure logic (interface strings in both languages, the web page, settings, frame buffer, window matching). Windows-only modules are stubbed on other systems, so no extra packages are needed.
MIT License — see LICENSE.