Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions adju-phone-camera/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
.pytest_cache/
build/
dist/
*.pem
80 changes: 80 additions & 0 deletions adju-phone-camera/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ADJU PHONE CAMERA

A Windows desktop app that receives an Android or iPhone camera feed over your local Wi-Fi. The app uses a private six-digit pairing PIN, a local WebSocket for signaling, and a direct WebRTC connection for video. It does not use cloud services, STUN/TURN servers, or an external video relay.

## Requirements

- Windows 10 or 11, 64-bit, and Python 3.10 or later.
- PC and phone on the same Wi-Fi/LAN; guest-network isolation can prevent them from seeing each other.
- A modern Android or iPhone browser. Camera permission is requested only after tapping **Start camera** on the phone.
- For camera access from a phone, use trusted HTTPS as described below. A plain `http://192.168.x.x` LAN URL is not a secure context and is not sufficient for `getUserMedia` on current Android browsers or Safari.

## Install and run

Open PowerShell in this directory:

```powershell
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python main.py
```

The app automatically creates a private certificate authority and a short-lived HTTPS certificate for the PC's current LAN IP. On first run, it shows:

- An HTTPS QR code for pairing and camera use on port `8765`.
- A certificate download link over the local network on port `8766`.
- The CA certificate's SHA-256 fingerprint, which must match the phone certificate before you trust it.

First, install and trust the CA certificate on the phone as described below. Then scan the HTTPS QR code, enter the six-digit PIN from the PC, choose a camera and resolution, and tap **Start camera**. Approve the browser's camera prompt. On the desktop, the preview shows incoming video and FPS; use **Save screenshot** or **Fullscreen preview** as needed. The HTTP listener on port `8766` only serves the public CA certificate; pairing and signaling are available over HTTPS/WSS on port `8765`.

The available video targets are 480p, 720p, and 1080p. Actual resolution and frame rate depend on the phone, browser, Wi-Fi, and operating system. Switch between front and back cameras on the phone page; stop the stream first to change settings.

## Install and trust the local certificate on the phone

The app stores its private CA key only on this PC under `%LOCALAPPDATA%\ADJU PHONE CAMERA\certificates`. It creates a server certificate for the current private LAN IP. Only the public CA certificate is downloadable; the private CA key is never served or bundled. A new server certificate is issued if the LAN IP changes, using the same CA.

1. Start the desktop server. On the phone, open the **CA download** link shown by the app, for example `http://192.168.1.20:8766/ca.cer`. This separate HTTP endpoint serves only the public certificate, not the camera page or signaling.
2. Before installing it, compare the certificate's SHA-256 fingerprint shown in the desktop app with the certificate details on the phone wherever the OS exposes its fingerprint. Only install it if the identity matches and you trust this PC's CA.
3. Install the downloaded certificate specifically as a **CA/root certificate**, then explicitly enable trust for it:
- **Android:** Settings > Security & privacy (names vary) > More security settings > Encryption & credentials > Install a certificate > CA certificate. Confirm the OS trust warning only after checking the fingerprint.
- **iPhone/iPad:** Open the downloaded configuration profile in Settings and install it. Then open Settings > General > About > Certificate Trust Settings and enable full trust for this root certificate. Menu names vary by iOS version.
4. Return to the app and scan its **HTTPS** QR code, such as `https://192.168.1.20:8765/`. The browser must show a secure page without a certificate warning. Enter the pairing PIN, tap **Start camera**, and grant camera permission.

If the phone OS or browser does not allow installing and trusting this private CA, camera access from that phone browser will not work. Use an organization-approved certificate/domain instead. Do not bypass certificate warnings or use plain HTTP as a camera-access workaround. When finished, remove the installed CA profile from the phone's certificate settings if you no longer need it; the private CA key remains on the PC. If you provide a custom TLS certificate with `python main.py --tls-cert CERT.pem --tls-key KEY.pem`, the app does not open its local CA download listener; configure your certificate's CA trust on the phone separately.

## Windows Firewall and network limits

Allow the app through Windows Defender Firewall on **Private networks only** when prompted. HTTPS signaling uses TCP port `8765`, the CA certificate download uses TCP port `8766`, and WebRTC media requires direct UDP connectivity. If creating rules manually, run PowerShell as Administrator and restrict access to the Private profile and local subnet:

```powershell
$python = (Get-Command python).Source
New-NetFirewallRule -DisplayName "ADJU PHONE CAMERA signaling" -Direction Inbound -Program $python -Action Allow -Profile Private -Protocol TCP -LocalPort 8765 -RemoteAddress LocalSubnet
New-NetFirewallRule -DisplayName "ADJU PHONE CAMERA CA download" -Direction Inbound -Program $python -Action Allow -Profile Private -Protocol TCP -LocalPort 8766 -RemoteAddress LocalSubnet
New-NetFirewallRule -DisplayName "ADJU PHONE CAMERA WebRTC" -Direction Inbound -Program $python -Action Allow -Profile Private -Protocol UDP -RemoteAddress LocalSubnet
```

Replace the executable with the packaged app path when using the PyInstaller build, and adjust both TCP ports if you chose a different port. Remove these named rules when you no longer need them. This app deliberately has no TURN relay fallback. Do not disable the firewall globally. Guest Wi-Fi, client/AP isolation, VPN policy, or restrictive router/firewall rules may block the phone-to-PC connection. Both devices must be on a network that permits peer-to-peer LAN traffic.

If the phone shows its camera preview but reports that it is still connecting, the camera permission and local capture have succeeded; the direct WebRTC video path is still negotiating. The PC shows **Phone paired** after PIN authentication and changes to **Receiving video** only after a frame arrives. If it stays at **Phone paired** or shows **WebRTC failed**, confirm the PC's Private-network UDP firewall rule, that the phone is not on guest Wi-Fi, and that both devices are still on the same local network. The phone's “answer received” status means signaling succeeded, not that video is already flowing.

## Build a Windows executable

From this directory with the virtual environment active:

```powershell
python -m pip install pyinstaller
pyinstaller --noconfirm --clean --onefile --windowed --name "ADJU PHONE CAMERA" --add-data "adju_phone_camera\web;adju_phone_camera\web" main.py
```

The executable is created under `dist`. Windows Firewall may prompt for network access the first time it runs. The HTTPS certificate and key remain user-supplied files and are not embedded.

## Tests

```powershell
python -m pip install -r requirements-dev.txt
python -m pytest
```

The test suite covers PIN validation and throttling. End-to-end video additionally requires a supported phone browser, camera permission, a trusted HTTPS setup, and LAN connectivity.
3 changes: 3 additions & 0 deletions adju-phone-camera/adju_phone_camera/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""ADJU PHONE CAMERA desktop application."""

__version__ = "0.1.0"
3 changes: 3 additions & 0 deletions adju-phone-camera/adju_phone_camera/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .main import main

main()
300 changes: 300 additions & 0 deletions adju-phone-camera/adju_phone_camera/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
"""CustomTkinter interface for ADJU PHONE CAMERA."""

from __future__ import annotations

import threading
import time
from pathlib import Path
from tkinter import filedialog, messagebox

import customtkinter as ctk
import qrcode
from PIL import Image, ImageTk

from .pairing import generate_pairing_pin
from .server import CameraServer, get_lan_ip
from .certificates import create_local_certificate, default_certificate_directory


class CameraApp(ctk.CTk):
def __init__(
self,
port: int = 8765,
tls_cert: Path | None = None,
tls_key: Path | None = None,
) -> None:
super().__init__()
self.title("ADJU PHONE CAMERA")
self.geometry("1080x760")
self.minsize(820, 620)
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

self.port = port
self._managed_certificate = None
if tls_cert is None and tls_key is None:
self._managed_certificate = create_local_certificate(default_certificate_directory(), get_lan_ip())
tls_cert = self._managed_certificate.certificate_path
tls_key = self._managed_certificate.private_key_path
self.tls_cert = tls_cert
self.tls_key = tls_key
self._ca_port = port + 1 if self._managed_certificate else None
self.pin = generate_pairing_pin()
self._image_lock = threading.Lock()
self._latest_frame: Image.Image | None = None
self._frame_revision = 0
self._rendered_revision = -1
self._frame_count = 0
self._fps = 0
self._fps_started = time.monotonic()
self._running = False
self._fullscreen = False
self._preview_photo: ImageTk.PhotoImage | None = None
self._server = CameraServer(
self.pin,
self._web_root(),
self._receive_frame,
self._set_status_from_server,
ca_download_path=(
self._managed_certificate.ca_download_path if self._managed_certificate else None
),
)

self._build_ui()
self._refresh_access_link()
self.protocol("WM_DELETE_WINDOW", self._close)
self.bind("<Escape>", lambda _event: self._toggle_fullscreen(False))
self.after(30, self._update_preview)

@staticmethod
def _web_root() -> Path:
import sys

bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
return Path(bundle_root) / "adju_phone_camera" / "web"
return Path(__file__).resolve().parent / "web"

def _build_ui(self) -> None:
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)

header = ctk.CTkFrame(self, corner_radius=14)
header.grid(row=0, column=0, padx=20, pady=(20, 12), sticky="ew")
header.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(header, text="ADJU PHONE CAMERA", font=ctk.CTkFont(size=24, weight="bold")).grid(
row=0, column=0, padx=20, pady=(16, 2), sticky="w"
)
ctk.CTkLabel(
header,
text="Use your phone as a camera over your local Wi-Fi — video stays on your network.",
text_color=("gray35", "gray75"),
).grid(row=1, column=0, padx=20, pady=(0, 16), sticky="w")
self.status_label = ctk.CTkLabel(header, text="Stopped", text_color="#f0ad4e")
self.status_label.grid(row=0, column=1, rowspan=2, padx=20)

controls = ctk.CTkFrame(self, corner_radius=14)
controls.grid(row=1, column=0, padx=20, pady=8, sticky="ew")
controls.grid_columnconfigure(1, weight=1)
self.start_button = ctk.CTkButton(controls, text="Start server", command=self._start_server)
self.start_button.grid(row=0, column=0, padx=(16, 10), pady=16)
self.stop_button = ctk.CTkButton(
controls, text="Stop server", command=self._stop_server, state="disabled", fg_color="#7d3434"
)
self.stop_button.grid(row=0, column=1, padx=8, pady=16, sticky="w")
self.quality_menu = ctk.CTkOptionMenu(
controls,
values=["480p", "720p", "1080p"],
command=self._quality_changed,
)
self.quality_menu.set("720p")
self.quality_menu.grid(row=0, column=2, padx=(8, 16), pady=16)

body = ctk.CTkFrame(self, corner_radius=14)
body.grid(row=2, column=0, padx=20, pady=8, sticky="nsew")
body.grid_columnconfigure(0, weight=1)
body.grid_rowconfigure(0, weight=1)

self.preview = ctk.CTkLabel(
body,
text="Start the server, scan the QR code, then pair and start the camera on your phone.",
justify="center",
font=ctk.CTkFont(size=16),
)
self.preview.grid(row=0, column=0, padx=16, pady=16, sticky="nsew")
self.preview.bind("<Double-Button-1>", lambda _event: self._toggle_fullscreen())

pairing = ctk.CTkFrame(body, width=250)
pairing.grid(row=0, column=1, padx=(0, 16), pady=16, sticky="ns")
pairing.grid_propagate(False)
ctk.CTkLabel(pairing, text="PAIR YOUR PHONE", font=ctk.CTkFont(weight="bold")).pack(pady=(18, 8))
self.qr_label = ctk.CTkLabel(pairing, text="QR will appear here")
self.qr_label.pack(padx=16, pady=8)
self.pin_label = ctk.CTkLabel(pairing, text=f"PIN {self.pin}", font=ctk.CTkFont(size=22, weight="bold"))
self.pin_label.pack(pady=8)
self.url_label = ctk.CTkLabel(pairing, text="Server is stopped", wraplength=220, justify="center")
self.url_label.pack(padx=14, pady=8)
self.ca_url_label = ctk.CTkLabel(pairing, text="", wraplength=220, justify="center", font=ctk.CTkFont(size=10))
self.ca_url_label.pack(padx=10, pady=(0, 3))
self.ca_fingerprint_label = ctk.CTkLabel(
pairing, text="", wraplength=220, justify="center", font=ctk.CTkFont(size=9)
)
self.ca_fingerprint_label.pack(padx=10, pady=(0, 5))
self.fps_label = ctk.CTkLabel(pairing, text="0 FPS")
self.fps_label.pack(pady=(8, 2))
ctk.CTkButton(pairing, text="Save screenshot", command=self._save_screenshot).pack(
padx=14, pady=8, fill="x"
)
ctk.CTkButton(pairing, text="Fullscreen preview", command=self._toggle_fullscreen).pack(
padx=14, pady=(0, 16), fill="x"
)

ctk.CTkLabel(
self,
text="Before scanning: download and trust the CA from the link beside the QR, then scan the HTTPS QR and pair using the PIN.",
text_color=("#8b5a00", "#f0c36d"),
).grid(row=3, column=0, padx=22, pady=(2, 14), sticky="w")

def _refresh_access_link(self) -> None:
selected_resolution = self.quality_menu.get().removesuffix("p")
lan_ip = get_lan_ip()
self._url = f"https://{lan_ip}:{self.port}/?resolution={selected_resolution}"
self.url_label.configure(text=self._url)
qr = qrcode.make(self._url).convert("RGB")
self._qr_photo = ctk.CTkImage(light_image=qr, dark_image=qr, size=(150, 150))
self.qr_label.configure(image=self._qr_photo, text="")
if self._managed_certificate:
self.ca_url_label.configure(
text=f"Before scanning, download the phone CA:\nhttp://{lan_ip}:{self._ca_port}/ca.cer"
)
self.ca_fingerprint_label.configure(
text=f"Compare SHA-256 fingerprint:\n{self._managed_certificate.fingerprint}"
)
else:
self.ca_url_label.configure(
text="Install and trust the CA for your HTTPS certificate on the phone first."
)
self.ca_fingerprint_label.configure(text="")

def _quality_changed(self, _value: str) -> None:
self._refresh_access_link()

def _start_server(self) -> None:
if self._running:
return
try:
self._server.start(
"0.0.0.0",
self.port,
self.tls_cert,
self.tls_key,
ca_port=self._ca_port,
)
except (OSError, ValueError) as error:
messagebox.showerror("Could not start server", str(error))
return
self._running = True
self.start_button.configure(state="disabled")
self.stop_button.configure(state="normal")
self._set_status("Starting local server")
self.after(500, self._check_server_start)

def _check_server_start(self) -> None:
if not self._running:
return
if not self._server.is_alive:
self._running = False
self.start_button.configure(state="normal")
self.stop_button.configure(state="disabled")
self._set_status("Server failed to start")
detail = self._server._startup_error or "Check whether the HTTPS and CA download ports are available."
messagebox.showerror(
"Could not start server",
f"The local server did not start.\n\n{detail}",
)
return
if self._server.is_ready:
self._set_status("Waiting for phone")
return
self.after(250, self._check_server_start)

def _stop_server(self) -> None:
if not self._running:
return
self._running = False
self._server.stop()
self.start_button.configure(state="normal")
self.stop_button.configure(state="disabled")
self._set_status("Stopped")
self.preview.configure(image="", text="Server stopped.")
self._preview_photo = None
with self._image_lock:
self._latest_frame = None

def _set_status_from_server(self, status: str) -> None:
self.after(0, lambda: self._set_status(status))

def _set_status(self, status: str) -> None:
if not self.winfo_exists():
return
self.status_label.configure(text=status)

def _receive_frame(self, image: Image.Image) -> None:
with self._image_lock:
self._latest_frame = image
self._frame_revision += 1
self._frame_count += 1
elapsed = time.monotonic() - self._fps_started
if elapsed >= 1:
self._fps = round(self._frame_count / elapsed)
self._frame_count = 0
self._fps_started = time.monotonic()

def _update_preview(self) -> None:
if not self.winfo_exists():
return
with self._image_lock:
frame = self._latest_frame
revision = self._frame_revision
fps = self._fps
if frame is not None and revision != self._rendered_revision:
preview_width = max(self.preview.winfo_width(), 320)
preview_height = max(self.preview.winfo_height(), 240)
image = frame.copy()
image.thumbnail((preview_width, preview_height), Image.Resampling.LANCZOS)
self._preview_photo = ImageTk.PhotoImage(image)
self.preview.configure(image=self._preview_photo, text="")
self._rendered_revision = revision
self.fps_label.configure(text=f"{fps} FPS")
self.after(30, self._update_preview)

def _save_screenshot(self) -> None:
with self._image_lock:
frame = self._latest_frame.copy() if self._latest_frame is not None else None
if frame is None:
messagebox.showinfo("No video yet", "Pair a phone and start its camera before saving a screenshot.")
return
filename = filedialog.asksaveasfilename(
title="Save camera screenshot",
defaultextension=".png",
filetypes=[("PNG image", "*.png"), ("JPEG image", "*.jpg;*.jpeg")],
)
if filename:
try:
frame.save(filename)
except OSError as error:
messagebox.showerror("Could not save screenshot", str(error))

def _toggle_fullscreen(self, enabled: bool | None = None) -> None:
self._fullscreen = not self._fullscreen if enabled is None else enabled
self.attributes("-fullscreen", self._fullscreen)

def _close(self) -> None:
self._running = False
self._server.stop()
self.destroy()


def run_app(port: int, tls_cert: Path | None, tls_key: Path | None) -> None:
app = CameraApp(port=port, tls_cert=tls_cert, tls_key=tls_key)
app.mainloop()
Loading