Skip to content

Latest commit

 

History

1,363 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ws-scrcpy-web

⚠️ The Docker image is moving

The image is now published as bilbospocketses/ws-scrcpy-web, and mirrored at ghcr.io/bilbospocketses/ws-scrcpy-web.

jchapz30/ws-scrcpy-web is deprecated and will be deleted on 2026-12-09. Until then it receives every release, and both new coordinates already carry the full tag history — the digests are identical, so switching is a one-line change:

docker pull bilbospocketses/ws-scrcpy-web:beta

If you pull jchapz30/... on a schedule, update it before 2026-12-09.

ws-scrcpy-web

ws-scrcpy-web is a self-hosted, browser-based Android screen-mirroring app (independent project at bilbospocketses/ws-scrcpy-web, descended from NetrisTV/ws-scrcpy) that needs no client install beyond a browser. A local Node.js server uses ADB to push Genymobile's vanilla scrcpy-server onto the device and multiplexes its video/audio/control TCP sockets onto a single WebSocket via a 1-byte channel prefix. The browser client is a custom TypeScript protocol layer that demuxes the stream and decodes H.264/H.265/AV1/VP8/VP9 video and Opus/AAC/FLAC/PCM audio entirely through WebCodecs (no WASM fallbacks).

Input flows back as mouse, UHID keyboard, i16-fixed-point scroll, and a D-pad/Touch mode toggle for leanback TV apps, alongside extras like an ADB shell, file manager, mDNS scan, sleep/wake, and device labels. It ships self-contained (bundled Node + ADB, launcher scripts, in-app updater) and exposes a public WsScrcpy.startStream() UMD/ESM library plus an embed.html shim for embedding live streams into other apps.

Key Design Decisions

  • Vanilla scrcpy-server -- uses unmodified Genymobile scrcpy-server binaries. No Java patching, no custom forks. Drop in new versions as they release; the in-app dependency manager checks for and applies updates.
  • Node.js ADB proxy -- the server bridges ADB tunnels to WebSocket connections for the browser. The protocol layer is implemented in TypeScript.
  • WebCodecs only -- no WASM decoder fallbacks. Modern browsers only.
  • Pure browser code -- no Node.js Buffer polyfill or path-browserify in the browser bundle.
  • Focused feature set -- screen mirroring, touch/keyboard/UHID control, ADB shell, file management. No iOS support, no Chrome DevTools proxy.

Features

  • Real-time screen mirroring in the browser via WebSocket
  • Multi-codec video -- H.264, H.265 (HEVC), AV1 with automatic detection and smart encoder selection, plus VP8 and VP9 for devices whose encoder list has none of the three
  • Multi-codec audio -- Opus, AAC, FLAC, raw PCM via WebCodecs AudioDecoder
  • UHID keyboard/mouse -- hardware-level input via USB HID reports (pointer lock for mouse)
  • D-pad / Touch input modes -- toolbar toggle between D-pad mode (default, for TV apps like Peacock/Netflix) and Touch mode (for touch-aware apps). D-pad mode maps left-click to OK, scroll wheel to up/down, Shift+scroll to left/right
  • Scroll wheel support -- mouse wheel scrolling on the mirrored device, tuned for latent streams
  • Touch and keyboard input forwarding (classic scrcpy keycode mode)
  • Configure stream modal -- native <dialog> overlay with codec/encoder selection, device probe, and advanced settings
  • Redesigned toolbar -- compact controls with quick stats (FPS, resolution, codec), stream refresh button, and consistent alignment
  • Quality stats overlay -- real-time FPS, bitrate, resolution, codec, and encoder info
  • Stream modal -- native <dialog> overlay for the full mirroring experience (video, toolbar, audio, UHID input). Home page stays visible behind the backdrop — close the stream and you're right back at the device list
  • Viewport scaling -- video scales to fill available space with correct aspect ratio
  • Remote ADB shell -- native <dialog> terminal modal with xterm.js, close confirmation for active sessions, Escape/backdrop blocked (terminal needs both)
  • File browser -- native <dialog> modal with breadcrumb navigation, sortable columns (sticky header so size/date stay aligned when scrolling), SVG file type icons (6 types), configurable icon sizes, hover action icons sized to match, reserved actions column so columns never shift on hover, selection with bulk operations, drag-and-drop upload, download with progress, delete with confirmation, client-side filter
  • Programmatic stream API -- load ws-scrcpy.umd.js or ws-scrcpy.esm.js and call WsScrcpy.startStream(container, deviceId, options) to render a stream into any DOM element. Includes bundled TypeScript types (ws-scrcpy.d.ts). Also provides a thin /embed.html?device=<udid> wrapper for iframe consumers.
  • Device labels -- name your devices for easy identification, persisted per-user across sessions in the app's SQLite store, inline edit from device cards or during network scan
  • Network device discovery -- two-channel scan for ADB devices on the local network: mDNS advertisement for modern devices plus TCP port-5555 sweep for older devices that don't advertise. Configuration dialog auto-detects your gateway subnet and accepts additional subnets (CIDR, bare IP, or IP range); subnets persist across sessions. Streaming progress chip with cancel support; scan skips already-connected devices and dedupes mDNS+TCP hits. Manual-add fallback for single-IP cases.
  • Device disconnect -- disconnect network devices directly from the device card
  • Sleep/wake toggle -- turn devices on or off from the device card; state polled server-side and pushed via WebSocket so buttons stay in sync even when the device sleeps on a timer or via the physical remote
  • Dark/light theme -- toggle between dark (default) and light modes; first paint follows your OS preference, then your saved choice applies (persisted per-user in the app's SQLite store)
  • Responsive layout -- centered page container scales from mobile to 4K (up to 5 device cards)
  • In-app dependency updater -- check and update Node.js, ADB, and scrcpy-server from the home page
  • System tray icon -- quick-open browser, a mode-aware tooltip (local vs. service) and a clean exit. Windows: a standalone helper the launcher's supervisor auto-spawns and auto-recovers. Linux: a StatusNotifierItem icon inside the launcher — KDE Plasma and any desktop with a StatusNotifier host; stock GNOME (Fedora Workstation) has no host, so no icon appears there and Settings → Server → stop the server and close the app remains the exit path.
  • Server logging -- all server output logged to ws-scrcpy-web.log with timestamps, tag prefixes, and 5MB rotation

Service Mode

ws-scrcpy-web can run as a background service that starts at boot/login and survives logout. Install and uninstall are driven from the browser UI on both platforms, but the underlying mechanism is platform-specific:

  • Windows — a Servy-managed Windows service. Install takes a single UAC prompt; uninstall takes none — the service-mode Node hands off to a Rust operation-server that serves a "please wait" transition page while post-stop.bat runs servy-cli uninstall and spawns a fresh user-session launcher, then the browser auto-navigates to the new instance. No 30-second port sweep, no manual relaunch.
  • Linux — a systemd unit, in one of two scopes: user-scope (~/.config/systemd/user/, no sudo, starts at login) or system-scope (/etc/systemd/system/, one pkexec prompt, starts at boot). See Linux install for the scope walkthrough and the machine-wide /opt option.

Embedding

Cross-origin framing is refused by default. A page that tries to iframe ws-scrcpy-web without being allow-listed gets a browser refusal ("localhost refused to connect") — the response carries X-Frame-Options: SAMEORIGIN and no CSP frame-ancestors entry for it. Everything below assumes you have opted the host origin in first, either by adding it to frameAncestors in config.json or by approving the consent prompt the embedding app raises. Settings → Embedding shows what is currently approved and can revoke it.

Embedding from another site requires HTTPS. Browsers withhold a site-scoped cookie from anything a cross-site iframe requests, including the WebSocket handshake the stream rides on — so the app relaxes its cookies to SameSite=None; Secure; Partitioned as soon as frameAncestors is non-empty. Secure is not optional there, so the browser's connection to the app must be https, or the cookies stay site-scoped and the embedded stream closes with 1006 while the page itself renders fine. Terminating TLS at a reverse proxy is enough — forward X-Forwarded-Proto: https (see the recipe below) and the app will honour it, but only from a proxy on loopback, which is why the proxy should be the only thing that reaches the app. If the app is not reachable over https, ws-scrcpy-web.log says so once at the first cookie it issues.

Same-site embedding needs none of this. SameSite is judged on registrable domain plus scheme, ignoring port, so http://localhost:5159 framing http://localhost:8000 is same-site and has always worked — but http:// framing https:// is not, and neither is localhost framing 127.0.0.1.

Embedding: theme bridge

When ws-scrcpy-web is embedded in a cross-origin iframe (once allow-listed, per the note above), the host page can sync its dark/light theme via postMessage. ws-scrcpy-web's listener and handshake fire automatically on load — no extra wiring needed inside ws-scrcpy-web.

Protocol (all message types are namespaced with ws-scrcpy-web:):

Direction Message type Payload When
iframe → parent theme-ready {theme: 'dark' | 'light'} One-shot on load; re-sent on demand (see below)
iframe → parent theme-changed {theme: 'dark' | 'light'} When ws-scrcpy-web's in-app theme toggle changes the theme
parent → iframe theme {theme: 'dark' | 'light'} Host pushes a new theme to the iframe
parent → iframe theme-request {} Host asks the iframe to re-announce theme-ready

Minimum host integration:

// 1) Reply to the iframe's load handshake with your current theme.
window.addEventListener('message', (e) => {
    if (e.data?.type === 'ws-scrcpy-web:theme-ready') {
        const iframe = document.getElementById('ws-scrcpy-iframe');
        iframe.contentWindow.postMessage(
            { type: 'ws-scrcpy-web:theme', theme: getMyHostTheme() },
            e.origin,
        );
    }
    // 2) Sync your host theme when ws-scrcpy-web's in-app toggle fires.
    if (e.data?.type === 'ws-scrcpy-web:theme-changed') {
        if (e.data.theme === 'dark' || e.data.theme === 'light') {
            setMyHostTheme(e.data.theme);
        }
    }
});

// 3) When the host's theme changes, push it to the iframe.
function pushThemeToIframe(theme) {
    const iframe = document.getElementById('ws-scrcpy-iframe');
    if (!iframe?.contentWindow) return;
    const origin = new URL(iframe.src, location.href).origin;
    iframe.contentWindow.postMessage({ type: 'ws-scrcpy-web:theme', theme }, origin);
}

Race condition note. The iframe posts theme-ready once at module load. If your host attaches its message listener AFTER iframe load (e.g., inside iframe.onload), the one-shot post arrives before you're listening. Three ways to avoid losing it:

  1. Recommended: attach the host's message listener as early as possible — for JS-created iframes, before adding the element to the DOM or setting src; for static HTML iframes, in an inline <script> in <head> (so it runs before the iframe begins loading).
  2. Or: post {type: 'ws-scrcpy-web:theme-request'} to the iframe once you're ready — ws-scrcpy-web replies with a fresh theme-ready.
  3. Don't rely on iframe.onload as your listener-attach point; the handshake may already have fired by then.

Programmatic API. When the bundle loads as a UMD library, the helpers land on window.WsScrcpy.*:

WsScrcpy.getTheme();                       // 'dark' | 'light'
WsScrcpy.setTheme('light');                // applies + persists
WsScrcpy.installThemeEmbedListener();      // already called on load
WsScrcpy.notifyThemeReady();               // already called on load
WsScrcpy.notifyThemeChanged();             // called by in-app toggle button

ESM consumers can import the same names from the package entry.

Security: allowedOrigins. The default listener accepts theme messages from any origin (allowedOrigins: '*'). This is permissive by design so the helper is drop-in for any embedder — note that the ws-scrcpy-web app itself does not use that default: it scopes its own listener to same-origin plus whatever frameAncestors permits. allowedOrigins also accepts a function, evaluated per message, for callers that do not know their allowlist at install time. Locked-down deployments should call

WsScrcpy.installThemeEmbedListener({
    allowedOrigins: ['https://your-host.example'],
});

themselves and skip the auto-install. Currently the only way to override the auto-install is to fork src/app/index.ts (or shadow it via your bundler) and replace the installThemeEmbedListener() call with your locked-down options. A dedicated build flag may land in a future minor. Origin validation gates BOTH theme push messages AND theme-request pings — non-allowed origins cannot ask the iframe to re-announce theme-ready, preventing leak vectors.

Downloads

Get the latest release from the Releases page:

  • Windows MSI (recommended) — installs per-machine to C:\Program Files\WsScrcpyWeb\ with writable runtime state at C:\ProgramData\WsScrcpyWeb\. Requires admin (UAC) to install and to apply each subsequent update. Multi-user friendly; service mode and local mode share configuration.
  • Windows portable ZIP — unzip and run; no install required, no auto-updates. Useful for air-gapped setups.
  • Linux AppImage — download WsScrcpyWeb-linux-stable.AppImage (or WsScrcpyWeb-linux-beta.AppImage for the beta channel), chmod +x it, and run. See Linux install below.
  • Docker imagedocker pull bilbospocketses/ws-scrcpy-web:beta (the beta channel; :latest / :stable follow the first stable release). Every release also gets its own immutable :X.Y.Z[-beta.N] tag. See Docker below.

Release artifacts are currently unsigned (no Authenticode / codesign) — code-signing is under evaluation. Each release ships a SHA256SUMS file and Sigstore SLSA Provenance attestations for supply-chain verification.

For data-handling details, see our Privacy Policy.

Requirements

Required for building from source. See Self-Contained Deployment for standalone installations that bundle everything.

  • Node.js 24 LTS or later
  • ADB — nothing to install. The dependency manager downloads ADB into the app's own dependencies/ folder on first run and invokes it by absolute path; PATH is deliberately never consulted, so putting ADB on PATH will not make the app find it (see CONTRIBUTING)
  • Android device with USB debugging or wireless debugging enabled
  • A secure browsing context for streaminghttps://, or http://localhost / http://127.0.0.1. The video decoder the player uses (WebCodecs) is exposed only in a secure context, so reaching the app at http://<lan-ip>:8000 lists devices but offers no connect link; the device card explains this and names the loopback URL to open instead. Browser flags do not work around it — Chromium's --unsafely-treat-insecure-origin-as-secure was measured not to restore VideoDecoder. Put the app behind a TLS reverse proxy to stream from another machine.

Quick Start (Developer Mode)

For development and building from source:

npm install
npm start

Open http://localhost:8000 in your browser.

This mode requires Node.js on your system; ADB is fetched into dependencies/ on first run and resolved by absolute path, never from PATH. See Self-Contained Deployment for a standalone installation that bundles everything.

Optional: npm run fetch-prebuilts

Pre-populates the node-pty native binary for air-gapped or offline setups. Normally unnecessary — npm start and npm test trigger this implicitly on first run via the resolver and vitest globalSetup respectively.

Self-Contained Deployment

ws-scrcpy-web ships as a fully self-contained app with no system-wide installations required. There are three deployment paths, all of which keep all dependencies inside the install folder — no PATH changes, no global installs, no admin/root needed.

Deployment paths

Path Best for Notes
Docker image (bilbospocketses/ws-scrcpy-web) Home servers, NAS boxes, anything already running containers One docker run with a /data volume. Wireless ADB only; put it behind HTTPS to stream from another machine. See Docker.
Windows MSI (*.msi, recommended) Most Windows users; multi-user / service-mode setups Per-machine install to C:\Program Files\WsScrcpyWeb\. Writable state at C:\ProgramData\WsScrcpyWeb\ (Authenticated Users:Modify). Velopack auto-updates apply with one UAC prompt each.
Linux AppImage Most Linux users Single executable. Velopack-managed auto-updates. Optional systemd service mode.
Portable ZIP (Windows) / source build Air-gapped or no-install setups Extract and run; layout shown below.

What's in the box (portable / source layout)

ws-scrcpy-web/
  dist/                    -- compiled application (server + browser bundles)
    assets/
      scrcpy-server        -- Android-side binary, pushed to devices via ADB
    public/                -- browser UI (HTML, JS, CSS)
    index.js               -- server entry point
  dependencies/            -- populated automatically on first run
    node/                  -- Node.js runtime + node-pty native files
    adb/                   -- ADB platform-tools
  start.cmd                -- Windows launcher
  start.sh                 -- Linux launcher

(The MSI and AppImage paths use Velopack's own install layout — current/ plus a stable launcher stub — and you don't need to think about it.)

Windows note: the launcher writes runtime state (config, logs, downloaded deps) to %PROGRAMDATA%\WsScrcpyWeb\ regardless of where the app itself lives. On Windows, the dependencies/ folder under the install or repo directory is effectively unused at runtime — the real dependencies/ is %PROGRAMDATA%\WsScrcpyWeb\dependencies\. On Linux, runtime deps still live under the install/repo dependencies/ for now.

Initial setup

  1. Run start.cmd (Windows) or ./start.sh (Linux). The launcher script handles the rest.
  2. On first run, the in-app dependency manager automatically downloads Node.js, ADB platform-tools, and scrcpy-server into dependencies/ (Windows: %PROGRAMDATA%\WsScrcpyWeb\dependencies\; Linux: alongside the install). You'll see a progress banner; it takes a minute or two depending on your connection.
  3. Once dependencies are populated, the server starts. Open http://localhost:8000 in your browser.
  4. From the home page's Dependencies panel you can re-check or update Node.js, ADB, and scrcpy-server later with one click — they're independently swappable without rebuilding the app.

If you prefer to avoid the network fetch on first run (air-gapped setups, slow connections), you can pre-populate the dependencies folder manually. On Windows the target is %PROGRAMDATA%\WsScrcpyWeb\dependencies\; on Linux it's the dependencies/ folder next to dist/.

  • Node.js — extract a Node.js LTS Windows / Linux build into <deps>/node/ (the binary should be at <deps>/node/node.exe or <deps>/node/node).
  • ADB — extract Android platform-tools into <deps>/adb/.
  • scrcpy-server — drop the appropriate scrcpy-server-vX.Y.Z binary into dist/assets/.

The dependency manager skips downloads when it finds an existing valid copy.

What Updates Automatically (In-App Updater)

The Dependencies panel on the home page lets you check for updates and install them with one click. These runtime dependencies are standalone binaries that can be safely swapped without recompiling the application:

Dependency What it does How it updates
Node.js + node-pty Runs the server; provides ADB shell terminal Downloads new binary from nodejs.org. Paired update -- both must match. Requires app restart (handled automatically by the launcher script).
ADB (platform-tools) Communicates with Android devices Downloads latest zip from Google, extracts, swaps files. ADB server is stopped and restarted automatically. No app restart needed.
scrcpy-server Runs on Android devices to capture screen and audio Downloads new binary from Genymobile/scrcpy releases. Replaces file in dist/assets/. No restart needed -- new binary is pushed to devices on next connection.

What Requires a New Release (Build-Time Dependencies)

These dependencies are compiled into the dist/ output during the build process. They cannot be updated independently -- a new version of ws-scrcpy-web must be built and deployed:

Dependency Why it's compiled in Update approach
ws (WebSocket library) Bundled into dist/index.js by webpack. This is the core communication layer between browser and server -- too critical to hot-swap without testing. A bad ws update could silently break all connections. Checked before each release (see docs/TECHNICAL_GUIDE.md section 12). Updated, tested, and shipped as part of a new ws-scrcpy-web version.
@xterm/xterm (terminal renderer) Bundled into dist/public/bundle.js. Major versions can change APIs that affect the shell feature. Updated during release builds.
TypeScript, webpack, Biome, css-loader, etc. Build toolchain only -- not shipped to users at all. These compile the source into dist/ and are never present in a deployment. Updated by developers before building a new release.

How the Launcher Works

Production installs (MSI/AppImage) use a compiled Rust launcher (ws-scrcpy-web-launcher.exe on Windows; the AppImage's bundled launcher on Linux) that supervises Node.js and manages the full application lifecycle. Items marked (Windows) below are Windows-specific; the Linux launcher uses the platform equivalents (e.g. pkexec/polkit for privileged prompts):

  1. Supervisor loop -- spawns Node as a child process, monitors its exit code. Exit code 75 or a .restart marker triggers a respawn (used by the dependency updater after Node.js updates). Normal exit shuts down cleanly.
  2. Tray -- Windows: the tray supervisor spawns the standalone tray helper and polls every 10 seconds, respawning it if it crashes or is killed. Linux: the tray is a thread inside the launcher (ksni, StatusNotifierItem over D-Bus) for local and user-scope-service runs; it lives and dies with the instance, so there is nothing to respawn, and it stands down silently when the desktop has no StatusNotifier host.
  3. Privileged elevation -- Windows uses ShellExecuteExW with the runas verb (UAC) for service install / update apply, with no PowerShell intermediary; Linux uses pkexec/polkit for the equivalent graphical prompt.
  4. Operation-server -- during service uninstall or app update, spawns a minimal Rust HTTP server on the same port to serve a "please wait" transition page. The operation-server detects when the new instance is ready and winds down.
  5. Job object (Windows) -- all child processes (Node, tray, operation-server) are assigned to a Windows Job Object with KILL_ON_JOB_CLOSE, so nothing orphans if the launcher is killed.
  6. Single-instance guard -- prevents duplicate launcher instances (a named mutex on Windows; a file lock on Linux).

In dev mode, start.cmd / start.sh provide a simpler restart loop for the same purpose.

Linux install (AppImage)

Linux releases ship as a single self-contained AppImage built with Velopack. No package manager, no sudo (for user-scope installs), no system-wide changes.

  1. Download WsScrcpyWeb-linux-stable.AppImage (stable channel) or WsScrcpyWeb-linux-beta.AppImage (beta channel) from the Releases page.
  2. Make it executable: chmod +x WsScrcpyWeb-linux-stable.AppImage
  3. Run it: ./WsScrcpyWeb-linux-stable.AppImage
  4. It opens http://localhost:8000 in your browser on launch — if it doesn't, open it yourself.

The first-run welcome modal offers to install ws-scrcpy-web as a systemd service. Two scopes are available:

  • just for me (no sudo) — installs to ~/.config/systemd/user/WsScrcpyWeb.service. Starts at login. loginctl enable-linger is invoked best-effort so the service survives logout.
  • all users (requires sudo) — installs to /etc/systemd/system/WsScrcpyWeb.service. Starts at boot. From the desktop the install triggers a single pkexec graphical password prompt and runs the app once as root (pkexec WsScrcpyWeb.AppImage --install-system-service) to stage /opt, write the unit, and systemctl enable --now; the app then switches over to the service on its own. Falls back gracefully if pkexec isn't installed: the error tells you to install polkit (sudo dnf install polkit on Fedora) or pick user scope. On a headless server, install it directly from a root shell instead: sudo ./WsScrcpyWeb-linux-*.AppImage --install-system-service (also --uninstall-system-service [--keep-state] and --system-service-status).

You can also install/uninstall the service later from Settings → Service.

Settings → Server (Linux) has two further actions. Install for all users relocates the app to a machine-wide /opt install under a single pkexec prompt (the control greys out once it's installed system-wide). Uninstall… completely removes ws-scrcpy-web — including any installed user- or system-scope service and a machine-wide /opt install — in a single pass, with at most one pkexec prompt; a keep my settings & logs option preserves your config.json and logs (so a later reinstall reuses your saved port) while still removing the program and its bundled dependencies.

AppImage placement caveat

For a user-scope service the systemd unit's ExecStart= points at the AppImage where it lived at install time, so do not move or rename the AppImage after installing a user-scope service — it will fail to start on next login. If you need to relocate it, uninstall the service first, move the file, then re-install. A system-scope service is unaffected: the installer stages a copy of the AppImage to /opt/ws-scrcpy-web/ (labelled bin_t for SELinux) and points ExecStart= there, so moving your home AppImage does not break it.

Verifying the AppImage signature

AppImage signing is currently under evaluation; releases ship unsigned for now. Verify integrity via the SHA256SUMS file in the release. When a Linux signing path is wired in, this section will document the verification steps for detached signatures.

glibc requirement

The bundled node-pty native binary is built against glibc. Musl-based distros (Alpine and similar) are not supported. Run on glibc-based distros: Ubuntu, Debian, Fedora, Arch, openSUSE, etc. — anything that ships glibc 2.31+ should work.

libfuse2 — not required

The AppImage needs no host libfuse2. Packaging swaps in the static type-2 AppImage runtime (libfuse is statically linked), and the in-app updater uses Velopack's bundled type-2 runtime for the update mount — so on any glibc-based distro the app self-updates without a host libfuse2, and on Fedora 40+, Arch, and most distros it launches straight from a chmod +x (no libfuse2 install needed).

Ubuntu 23.10+ note (24.04 / 26.04). These releases ship an AppArmor profile that restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1) — the mechanism an AppImage uses to mount itself — which can block launch independently of libfuse. If the AppImage won't start on stock Ubuntu 24.04 or 26.04, run it extracted with APPIMAGE_EXTRACT_AND_RUN=1 ./WsScrcpyWeb-linux-*.AppImage, or (as admin) relax the restriction: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0. A built-in extract-and-run fallback is planned (see the smoke-test Module 2b).

Tray icon

On Linux the launcher shows a StatusNotifierItem tray icon wherever a StatusNotifier host is running (KDE Plasma out of the box; GNOME only with the AppIndicator extension). Hover for the mode (ws-scrcpy-web or ws-scrcpy-web (service)), left-click or Open ws-scrcpy-web to open the app, Exit… → stop the server and quit to stop it cleanly (the launcher sends Node SIGTERM, so the adb teardown runs). Where there is no host — stock GNOME, Fedora Workstation — the icon simply does not appear and launcher.log says so once; use Settings → Server → stop the server and close the app there. System-scope services have no desktop session and never show a tray.

Configuration

Almost all configuration is managed through the in-app Settings panel (gear icon, top-right of the home page). Settings persist to config.json next to the running app:

Field Default Where to change it
webPort 8000 (auto-shifts if busy) Settings → Server → Web port
installMode (set on first run) Welcome modal / Settings → Service
firstRunComplete false Set automatically after first-run modal
autoUpdate true Settings → Updates → Automatically download updates
updateCheckIntervalMinutes 60 Settings → Updates → Check interval
channel beta on a beta build, stable on a release build (derived from the build's own version when config.json does not say) Settings → Updates → Channel
githubOwner bilbospocketses Settings → Updates → GitHub owner (override for forks)
frameAncestors [] (nothing may frame the app) Settings → Embedding, or edit config.json
allowedHosts [] (localhost + IP literals only) config.json only — server-only, never exposed via the API

Not a stored field, but reached the same way: Settings → Server → stop the server and close the app cleanly stops the server and quits the app — the clean-exit path on desktops without a tray host (stock GNOME), disabled in service mode; where a StatusNotifier host exists (KDE Plasma) the tray's Exit… → stop the server and quit does the same.

Update channels are baked into the installation. Velopack tracks the channel (stable or beta) at the package level — it's part of the installed identity, not just a config preference. Changing the channel setting changes which feed the updater queries, but the in-app updater cannot cross from one channel to another. A beta installation will not successfully apply a stable update (or vice versa), even if the updater detects and downloads it. To switch channels, uninstall the current version and fresh-install the desired channel's MSI/AppImage. This is a Velopack platform constraint, not a ws-scrcpy-web limitation.

A few advanced switches are only available via environment variables:

Variable Purpose
DEPS_PATH Override the location of the dependencies/ folder (used by the installer to point at the per-user data dir while the app itself lives under current/).
VELOPACK_FEED_URL Force the Velopack auto-updater to use a custom feed URL (mostly useful for the local update-flow sandbox test).
ADB_PATH Override the path to the ADB executable (rarely needed; the dependency manager handles ADB by default).

Access control

ws-scrcpy-web is open by default — with login off, anyone who can reach the port can control connected devices, so run it only on a trusted local/LAN network. Opt-in login is available (Settings → Users): add a user with a password and the app requires a session from then on.

With login off, the LAN is trusted for the device surface. The server blocks cross-site (CSRF) and DNS-rebinding attacks with a Host allowlist, an Origin check, a per-launch token cookie, and a framing policy; by default it accepts only localhost and IP-literal hosts, and refuses cross-origin framing outright. Those defences stop a malicious web page and a rebound domain name. They are not a login: the token cookie is handed to anything that can fetch the page, so a client already on your network can drive devices. Turning login on is what makes that a boundary.

Administering the server is separate, and needs proof that you are the operator — you are on the machine itself (loopback), or you are a signed-in admin. Otherwise the admin routes answer 403: users, configuration, service control, updates and shutdown. The app shows a banner explaining which state it is in, with a Set up sign-in button; that banner is informational everywhere but actionable only from the machine itself, so it cannot be used to open the server up from off-box.

To allow admin from another machine without setting up sign-in, set WS_SCRCPY_ALLOW_REMOTE_ADMIN=1 in the server's environment (or "allowRemoteAdmin": true in config.json). That makes anyone who can reach the server an administrator, so use it only on a network you fully control. In a container nobody is ever on loopback, so a Dockerised deployment needs either that variable or sign-in enabled — see SECURITY.md for the docker exec one-liner.

Both cookies the app issues — the per-launch token and the login session — are HttpOnly and site-scoped (SameSite=Strict and Lax respectively). Allow-listing an embedder in frameAncestors relaxes both to SameSite=None; Secure; Partitioned, because a site-scoped cookie is never sent from a cross-site frame. This does not weaken the CSRF defence, which is the Origin/Host match, not SameSite: a cross-origin page still fails the Origin check on every /api call and every handshake. Partitioned (CHIPS) keys each cookie to the embedding top-level site, so an embedded session is its own session and does not ride on the one in your own tab.

To serve it on a domain name behind a TLS-terminating reverse proxy, add the domain(s) to a server-only allowedHosts array in config.json (read at startup, never exposed via the in-app API), and make sure the proxy forwards the original Host header:

{ "allowedHosts": ["devices.example.com"] }

To let another local app embed this one in an iframe, add its origin to frameAncestors in config.json, or approve the consent prompt the embedding app can raise (Settings → Embedding lists and revokes what you have approved). Cross-origin framing is refused until you do:

{ "frameAncestors": ["http://localhost:5159"] }

See SECURITY.md and docs/TECHNICAL_GUIDE.md §24 for the full access-control model.

Logging

The server logs all output to ws-scrcpy-web.log. Every line includes an ISO 8601 timestamp and a module tag (e.g., [ScrcpyConnection], [Server]). The log file rotates at 10 MB (per write), keeping one backup (.log.1). In dev (npm start, no launcher) console output is preserved in the terminal; under the launcher the console echo is suppressed and ws-scrcpy-web.log is the single source of truth.

Log file locations:

  • Installed (Windows / Velopack MSI): C:\ProgramData\WsScrcpyWeb\logs\ holds all logs — each rotated at 10 MB (one .1 backup):
    • ws-scrcpy-web.logcanonical Node-server log (the Logger file)
    • launcher.logcanonical launcher log (Rust common::log file); tray.log is the same for the tray helper
    • server.logthin crash-catcher: launcher redirects Node child stdout/stderr here, but Logger suppresses its own echo under the launcher (isTTY gate), so this file only fills on raw crashes / native failures
    • service.logthin crash-catcher (service mode only): service manager captures launcher stderr here, but the launcher suppresses normal lines under a service (is_terminal() gate), so this file only fills on raw launcher panics
  • Installed (Linux AppImage / systemd): logs live under the Linux data root — ~/.local/share/WsScrcpyWeb/logs/ for a user-scope install, /var/lib/ws-scrcpy-web/logs/ for a system-scope service — same file names and 10 MB rotation as Windows (ws-scrcpy-web.log, launcher.log; service.log only fills on raw launcher panics under the systemd unit).
  • Dev / npm start: ws-scrcpy-web.log lands at the project root (legacy dev fallback); server.log / service.log are absent.

See docs/TECHNICAL_GUIDE.md section 15 for details on the Logger utility and adding logging to new modules.

Docker

The image is published to Docker Hub as bilbospocketses/ws-scrcpy-web and mirrored to ghcr.io/bilbospocketses/ws-scrcpy-web on every release: :beta follows the beta channel (every 0.1.30-beta.N also gets its own immutable tag), and :latest / :stable will follow the first stable release. linux/amd64 only for now — Google publishes no arm64 Linux platform-tools, so an arm64 image would start and then fail on the first device. Pull from GHCR if Docker Hub's anonymous rate limit is a problem for you.

docker run -d --name ws-scrcpy-web -p 127.0.0.1:8000:8000 -v wsdata:/data bilbospocketses/ws-scrcpy-web:beta

(Bound to loopback on purpose; see the second note below before publishing it on a LAN interface.) The repo's docker-compose.yml is the developer and CI quickstart instead — it builds the image from source and publishes it on 127.0.0.1:8123. Everything mutable lives on /dataconfig.json, the SQLite store, and the dependencies the container downloads on first boot (adb, ~9 MB; the health check allows 180 s for that). Update with docker pull and re-create the container; the volume carries your state across.

Two things to know before relying on it:

  • Wireless ADB only. The container connects to devices over the network (adb connect <ip>:<port>); there is no USB pass-through, by design.
  • Streaming needs a secure context. The browser's video decoder (WebCodecs) is only available on https:// or localhost, so opening the container over plain http://<lan-ip>:8000 shows the device list but no connect link. The device card says so, and names the loopback URL to use instead. Open it on the serving machine, or put it behind a TLS reverse proxy.

Serving the container over HTTPS (reverse proxy)

The two notes above meet in the common case: you run the container on one machine and want to stream from another. A plain http://<lan-ip>:8000 will list devices but not stream, because the browser only exposes its video decoder in a secure context. The answer is a TLS-terminating reverse proxy on a name you control, in front of the loopback-bound container. Three things have to be true, and each one is a specific failure if it is not:

  1. Tell the app its name. allowedHosts in config.json (on the /data volume, e.g. docker exec ws-scrcpy-web sh -c 'cat /data/config.json') must list the domain, or every request arriving with that Host is refused as a possible DNS-rebinding attack. It is read at startup only, so restart the container after editing it. Details in SECURITY.md.
  2. Forward Host unchanged. The Origin check compares the browser's Origin against the request's own Host; a proxy that rewrites Host to localhost makes every API call look cross-origin and it is rejected.
  3. Pass WebSockets through. Device streams, the shell and the scan all ride on WebSocket upgrades; a proxy that does not forward the Upgrade / Connection headers serves the page and then nothing moves.

A fourth rule applies only if you also embed the app in a cross-site iframe: forward X-Forwarded-Proto: https, or the cookies stay site-scoped and the embedded stream cannot authenticate (see Embedding). Caddy sets it by default; the nginx block below sets it explicitly. The app honours the header only when the request arrives over loopback, so keep the container bound to 127.0.0.1 as below and nothing on the LAN can claim to be your proxy.

Caddy does all three by default, so the whole configuration is:

devices.example.com {
    reverse_proxy 127.0.0.1:8000
}

nginx needs the headers spelled out:

server {
    listen 443 ssl;
    server_name devices.example.com;
    # ssl_certificate / ssl_certificate_key as usual

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;                # (2) not "localhost"
        proxy_set_header Upgrade $http_upgrade;     # (3)
        proxy_set_header Connection "upgrade";      # (3)
        proxy_set_header X-Forwarded-Proto https;
        proxy_read_timeout 1h;                      # streams are long-lived
    }
}

and, in either case, { "allowedHosts": ["devices.example.com"] } in the container's config.json (1). Keep the container itself on 127.0.0.1:8000 as in the docker run above — the proxy is the only thing that should reach it. Traefik, HAProxy and the rest work the same way; the three rules are the whole contract.

Every published image passes a Docker Scout gate in the publish workflow — a fixable critical or high CVE fails the publish — and the Hub repository has Scout analysis enabled, so already-published images are re-evaluated as advisories land. For now, use the MSI, AppImage, or portable ZIP.

Acknowledgments

This project is based on ws-scrcpy by Sergey Volkov / Netris, JSC. See THIRD-PARTY-NOTICES.md for full license details.

Screen mirroring powered by scrcpy by Genymobile / Romain Vimont.

License

This project is licensed under the GNU General Public License v3.0.

About

Browser-based Android screen mirroring and tooling — spiritual successor to ws-scrcpy, powered by vanilla Genymobile/scrcpy

Topics

Resources

Contributing

Security policy

Stars

49 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages