Skip to content
Merged
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
97 changes: 85 additions & 12 deletions pymobiledevice3/remote/core_device/screen_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from pymobiledevice3.remote.core_device.orientation_service import OrientationService
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
from pymobiledevice3.services.accessibilityaudit import AccessibilityAudit
from pymobiledevice3.services.power_assertion import PowerAssertionService

# Named iOS hardware buttons → (usage_page, usage_code, hold_seconds).
# Mirrors the table in cli/developer/core_device.py so the browser viewer
Expand Down Expand Up @@ -554,6 +555,14 @@ def __init__(
self._accessibility: Optional[AccessibilityAudit] = None
self._accessibility_lock = asyncio.Lock()

# Background task that holds an IOPMAssertion on the device so
# iOS auto-lock doesn't kick in mid-session. Without this the
# display sleeps after the user's auto-lock timeout (typically
# 30 s -- 2 min) and the encoder stops emitting AUs; restarts
# also can't recover because a locked device won't start a
# fresh DisplayService session cleanly.
self._keep_awake_task: Optional[asyncio.Task] = None

# ----- per-session UDP receiver -----------------------------------------
async def _udp_recv_and_depacketize(self, sock: socket.socket) -> None:
sock.setblocking(False)
Expand Down Expand Up @@ -1002,11 +1011,14 @@ async def _stop_audio_stream(self) -> None:
with contextlib.suppress(Exception):
sock.close()
if svc is not None:
with contextlib.suppress(Exception):
if sid is not None:
await svc.stop_media_stream(sid)
with contextlib.suppress(Exception):
await svc.close()
# Bound the device-side RPCs. A wedged CoreDevice daemon
# can hang the XPC response wait indefinitely, holding
# _audio_lock and preventing recovery.
if sid is not None:
with contextlib.suppress(asyncio.TimeoutError, Exception):
await asyncio.wait_for(svc.stop_media_stream(sid), timeout=2.0)
with contextlib.suppress(asyncio.TimeoutError, Exception):
await asyncio.wait_for(svc.close(), timeout=2.0)

async def _ensure_audio_stream(self) -> None:
async with self._audio_lock:
Expand Down Expand Up @@ -1097,11 +1109,18 @@ async def _stop_active_stream(self) -> None:
with contextlib.suppress(Exception):
sock_to_close.close()
if svc is not None:
with contextlib.suppress(Exception):
if sid is not None:
await svc.stop_media_stream(sid)
with contextlib.suppress(Exception):
await svc.close()
# Bound the device-side RPCs. The stall watchdog calls us
# precisely when the CoreDevice daemon has stopped feeding
# AUs -- i.e. the state in which stop_media_stream / close
# are most likely to hang on their XPC response wait. An
# unbounded await here holds _stream_lock forever and
# leaves /codec and /stream.bin replying 503 with no path
# to recovery short of restarting the server.
if sid is not None:
with contextlib.suppress(asyncio.TimeoutError, Exception):
await asyncio.wait_for(svc.stop_media_stream(sid), timeout=2.0)
with contextlib.suppress(asyncio.TimeoutError, Exception):
await asyncio.wait_for(svc.close(), timeout=2.0)

async def _ensure_fresh_stream(self, force: bool = False) -> None:
async with self._stream_lock:
Expand Down Expand Up @@ -2012,8 +2031,57 @@ async def _stall_watchdog(self) -> None:
_MAX_STALL_RESTARTS,
)
self._last_restart_t = now
with contextlib.suppress(Exception):
await self._ensure_fresh_stream(force=True)
# Bound the whole restart so a hang on the device side
# (cleanup or start) can't hold _stream_lock forever and
# silently block subsequent /codec and /stream.bin paths.
# The inner _stop_active_stream already bounds its own
# XPC calls; this is the outer safety net covering the
# start side too (connect / start_video_stream).
try:
await asyncio.wait_for(self._ensure_fresh_stream(force=True), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("stall-watchdog restart did not complete within 10s")
except Exception:
logger.exception("stall-watchdog restart failed")

async def _keep_awake_loop(self) -> None:
"""Hold a PreventUserIdleSystemSleep IOPMAssertion on the device
so it doesn't auto-lock mid-session.

Without this iOS sleeps the display after the user's auto-lock
timer (often 2 minutes) and the screen-capture pipeline halts:
AUs stop arriving, the stall watchdog fires, and the restart
also can't complete because a locked device won't start a fresh
DisplayService session cleanly. End result is the server going
silently unresponsive until the user manually wakes the device.

Renewal cadence is well under the requested timeout so a single
slow renewal cycle can't drop the assertion."""
assertion_timeout = 300 # 5 min — long enough that a missed renew is harmless
refresh_interval = 120 # 2 min — well under timeout
try:
if self._lockdown is None:
self._lockdown = await _create_lockdown_usbmux(self._rsd.udid)
while True:
try:
svc = PowerAssertionService(self._lockdown)
async with svc.create_power_assertion(
"PreventUserIdleSystemSleep",
"pymobiledevice3.serve-web",
assertion_timeout,
"serve-web keeping device awake for screen mirroring",
):
pass
except Exception:
logger.debug("keep-awake renew failed; will retry", exc_info=True)
try:
await asyncio.sleep(refresh_interval)
except asyncio.CancelledError:
return
except asyncio.CancelledError:
return
except Exception:
logger.warning("keep-awake loop crashed (device may auto-lock)", exc_info=True)

async def _eager_stream_start(self) -> None:
"""Bring the device-side streams up at server boot.
Expand Down Expand Up @@ -2053,6 +2121,7 @@ async def serve(self) -> None:
)
watchdog = asyncio.create_task(self._stall_watchdog())
decoder_refresh = asyncio.create_task(self._decoder_refresh_loop())
self._keep_awake_task = asyncio.create_task(self._keep_awake_loop())
# Eagerly start the HID worker so queued /touch requests are
# processed even before the device-stream is fully up.
self._hid_worker_task = asyncio.create_task(self._hid_worker())
Expand Down Expand Up @@ -2112,6 +2181,10 @@ async def _bounded(coro, label, timeout=3.0):
decoder_refresh.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await decoder_refresh
if self._keep_awake_task is not None:
self._keep_awake_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._keep_awake_task
logger.debug("shutdown: cancelling eager_start")
eager_start.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
Expand Down
7 changes: 7 additions & 0 deletions pymobiledevice3/resources/serve_web/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@
/* Status / log overlay: pinned to bottom-left so it doesn't intercept
clicks on the left-tray toggle. Capped width so a long log line
doesn't push it across the canvas. */
/* Live FPS readout, useful for spotting decoder stalls — the underlying
counter is the same frameCount the offline-overlay watches, sampled
once a second. */
#fps{position:fixed;top:8px;left:8px;font-size:12px;opacity:.75;
background:#0008;color:#fff;padding:2px 8px;border-radius:4px;
font-variant-numeric:tabular-nums;user-select:none;pointer-events:none;
z-index:10}
#status{position:fixed;bottom:8px;left:8px;font-size:11px;opacity:.7;
background:#0008;padding:4px 8px;border-radius:4px;white-space:pre;
width:240px;max-width:30vw;max-height:120px;overflow:hidden;
Expand Down
1 change: 1 addition & 0 deletions pymobiledevice3/resources/serve_web/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ <h3>Accessibility</h3>
</aside>
</div>

<div id="fps">— fps</div>
<div id="status">connecting...</div>
<div id="help-overlay" class="hidden" aria-hidden="true">
<div id="help-modal" role="dialog" aria-modal="true" aria-label="Shortcuts and tips">
Expand Down
23 changes: 22 additions & 1 deletion pymobiledevice3/resources/serve_web/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,33 @@ function fitCanvasToViewport() {
}
window.addEventListener('resize', fitCanvasToViewport);
const statusEl = document.getElementById('status');
const fpsEl = document.getElementById('fps');
let frameCount = 0;
const lines = ['connecting...'];
function log(msg) { lines.push(msg); if (lines.length > 8) lines.shift(); render(); }
function log(msg) {
// Mirror to devtools so long lines aren't truncated by the
// bottom-left status panel (which is intentionally compact).
try { console.log('[serve-web]', msg); } catch (_) {}
lines.push(msg); if (lines.length > 8) lines.shift(); render();
}
function render() { statusEl.textContent = `frames: ${frameCount}\n` + lines.join('\n'); }
setInterval(render, 250);

// FPS readout. Sample frameCount once per second; the displayed number
// is the frame delta over the last 1 s. A stall shows up immediately
// as "0 fps" while the stream is still nominally "connected", which is
// what the status panel's `frames: N` counter doesn't make obvious.
let _fpsLastFc = 0;
let _fpsLastT = (typeof performance !== 'undefined') ? performance.now() : Date.now();
setInterval(() => {
const now = (typeof performance !== 'undefined') ? performance.now() : Date.now();
const dtSec = (now - _fpsLastT) / 1000;
const dFrames = frameCount - _fpsLastFc;
_fpsLastT = now;
_fpsLastFc = frameCount;
if (fpsEl) fpsEl.textContent = (dtSec > 0 ? (dFrames / dtSec) : 0).toFixed(1) + ' fps';
}, 1000);

function hex(u8, n=24) {
let s = '';
for (let i = 0; i < Math.min(u8.length, n); i++) s += u8[i].toString(16).padStart(2,'0');
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ plumbum
pyimg4>=0.8.8
pyiosbackup>=0.2.4
typer>=0.23.2,<0.24.0;python_version<'3.10'
typer>=0.26.0;python_version>='3.10'
typer>=0.25;python_version>='3.10'
typer-injector>=0.2.0
defusedxml
pywin32 ; platform_system == "Windows"
Expand Down
Loading