Files
halobestie-clone/.dev/wsl_tcp_relay.py
Ramadhan Sjamsani 3a0cdf5c4e Phase 5/6 polish: end-session flow, notif sound on API 33+, Xendit webview
Customer end-of-session (figma §6):
- PricingBottomSheet: ghost "cukup, akhiri sesi" CTA + dedup divider
- chat_screen._runEndSessionFlow chains ConfirmEndStep1 → ConfirmEndStep2
  → ClosingMessageSheet (or "lewati saja" → close + /home). The four
  popup/sheet widgets already existed; this commit just wires them
- showModalBottomSheet: showDragHandle=false to suppress the Material 3
  auto-injected handle that was stacking with our own pill

Notification sound on API 33+:
- Bump channel halobestie_chat_v1 → halobestie_chat_v2, created from
  native Kotlin in MainActivity.kt with AudioAttributes contentType
  CONTENT_TYPE_SONIFICATION. flutter_local_notifications' default of
  CONTENT_TYPE_UNKNOWN was causing Android 13 to silently drop audio
  focus while the notification still posted (isNoisy=true). Both apps
- Backend FCM payload channelId updated to v2
- AndroidManifest meta-data: default_notification_icon + color → brand
  silhouette tinted pink instead of generic Android bell. Both apps

Customer pairing reliability:
- pairing_notifier: applyPairedFromPush({sessionId, mitraName}) unsticks
  searching screen when WS push failed and FCM/active-session-poll is
  the first signal. Idempotent across PairingSearchingData,
  PairingTargetedWaitingData, PairingErrorData (covers ALREADY_ACTIVE)
- notification_service: dispatches every FCM data payload to an
  onDataMessage callback (foreground + tap + cold-start). main.dart
  wires that to applyPairedFromPush on type=='paired'. Foreground
  'paired' no longer renders a local banner — screen self-advances
- main.dart activeSession listener also calls applyPairedFromPush when
  a session appears server-side while pairing is in a waiting state.
  Covers stale ALREADY_ACTIVE recovery without a full page refresh

Auth refresh token race:
- auth_notifier._refreshFromStorage shares a single in-flight Future
  across all callers (Auth.build + 401-retry path). Backend rotates
  refresh tokens, so concurrent callers using the same stored token
  would race → loser 401s → catch wipes flutter_secure_storage → user
  appears logged out after kill+reopen

Polish:
- method_pick_screen: resizeToAvoidBottomInset=false — prevents the
  one-frame overflow when entering with the previous screen's keyboard
  still animating out
- bestie_history: BestieHistoryItem now carries `status` (backend
  already returns it). Removed _rawHistoryProvider that fetched the
  same endpoint just to read status; the two providers could go out
  of sync mid-rebuild and throw RangeError(length) on indexing

Xendit Stage 8 (carried from WIP):
- xendit_checkout_screen: embedded webview hosting Xendit's invoice
  page (intercepts halobestie:// deeplink + return-page URLs for
  deterministic pop)
- waiting_payment_screen: auto-pushes the webview when the backend
  payload includes xendit_invoice_url; spinner card + "Buka ulang
  halaman pembayaran" CTA for the QR-fallback path
- pubspec: webview_flutter ^4.13.0

Maestro infra:
- subflows/onboarding_returning_user: drop the "Mulai" carousel wait
  (splash auto-advances since 2026-05-26); tap phone-field hint
  instead of point; drop hideKeyboard (sends BACK → /home when the
  IME isn't actually up)
- New flow ts-customer-06-01-end_session_via_timeup_sheet: drives
  the full path to the chat-expired banner. Last step blocked by a
  Maestro+Flutter gesture quirk on the perpanjang ElevatedButton
  (raw `adb input tap` works at the same coords). Documented in
  memory; deeplink fixture or manual verify recommended
- ChatExpiredBanner button wrapped with Semantics(identifier:
  'chat_extend_button', button: true, onTap: …) — good hygiene for
  future tests even though it doesn't fix the dadb tap issue

.dev/: tracked wsl_emulator_bridge.ps1 + wsl_tcp_relay.py for
Maestro-on-WSL setup (Windows-side netsh portproxy + WSL-side
loopback relays). Both referenced from existing CLAUDE.md notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:45:46 +08:00

135 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""WSL→Windows TCP relay for the Windows-side adb server.
Two modes:
static: wsl_tcp_relay.py <listen-port> <target-host> <target-port>
Relays 127.0.0.1:<listen-port> -> <target-host>:<target-port>.
dynamic: wsl_tcp_relay.py --watch-adb <target-host>
Polls `adb forward --list` (using the env-configured adb server),
and for each host-side TCP port in the listing, ensures a relay
127.0.0.1:<port> -> <target-host>:<port> exists. Adds/removes
relays as forwards appear/disappear.
The dynamic mode handles `flutter run` choosing an ephemeral host port (and
sometimes rotating it across reconnect attempts) when adb is shared via
ADB_SERVER_SOCKET — flutter's adb-forward request lands on the Windows adb
server, which binds the listener on 0.0.0.0 of the Windows side. WSL-loopback
has nothing on that port, so flutter (connecting to 127.0.0.1:<port> on WSL)
fails. This relay fills that gap dynamically.
"""
from __future__ import annotations
import asyncio
import os
import sys
from typing import Dict, Set
async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while data := await reader.read(65536):
writer.write(data)
await writer.drain()
except (ConnectionResetError, BrokenPipeError, OSError):
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
def _handler(target_host: str, target_port: int):
async def handle(local_r, local_w):
try:
remote_r, remote_w = await asyncio.open_connection(target_host, target_port)
except Exception as exc:
print(f"[relay] connect {target_host}:{target_port} failed: {exc}", file=sys.stderr, flush=True)
local_w.close()
return
await asyncio.gather(_pipe(local_r, remote_w), _pipe(remote_r, local_w))
return handle
async def run_static(listen_port: int, target_host: str, target_port: int) -> None:
server = await asyncio.start_server(_handler(target_host, target_port), "127.0.0.1", listen_port)
print(f"[relay] static 127.0.0.1:{listen_port} -> {target_host}:{target_port}", flush=True)
async with server:
await server.serve_forever()
async def _adb_forward_ports() -> Set[int]:
adb = os.environ.get("ADB", "adb")
proc = await asyncio.create_subprocess_exec(
adb, "forward", "--list",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
ports: Set[int] = set()
for line in out.decode("utf-8", "replace").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1].startswith("tcp:"):
try:
ports.add(int(parts[1][4:]))
except ValueError:
pass
return ports
async def run_dynamic(target_host: str, poll_seconds: float = 1.0) -> None:
active: Dict[int, asyncio.AbstractServer] = {}
print(f"[relay] dynamic watching adb forwards -> {target_host} (poll={poll_seconds}s)", flush=True)
while True:
try:
target_ports = await _adb_forward_ports()
except Exception as exc:
print(f"[relay] adb listing failed: {exc}", file=sys.stderr, flush=True)
await asyncio.sleep(poll_seconds)
continue
for port in target_ports - set(active):
try:
srv = await asyncio.start_server(_handler(target_host, port), "127.0.0.1", port)
active[port] = srv
print(f"[relay] + 127.0.0.1:{port} -> {target_host}:{port}", flush=True)
except OSError as exc:
print(f"[relay] bind 127.0.0.1:{port} failed: {exc}", file=sys.stderr, flush=True)
for port in set(active) - target_ports:
srv = active.pop(port)
srv.close()
try:
await srv.wait_closed()
except Exception:
pass
print(f"[relay] - 127.0.0.1:{port}", flush=True)
await asyncio.sleep(poll_seconds)
def _usage() -> None:
print(__doc__, file=sys.stderr)
sys.exit(2)
async def _main() -> None:
argv = sys.argv[1:]
if not argv:
_usage()
if argv[0] == "--watch-adb":
if len(argv) != 2:
_usage()
await run_dynamic(argv[1])
else:
if len(argv) != 3:
_usage()
await run_static(int(argv[0]), argv[1], int(argv[2]))
if __name__ == "__main__":
asyncio.run(_main())