DEV Community

Cover image for I built a self-hosted remote terminal that survives app kills, network switches, and phone sleep — runs on Web, iOS, Android, and HarmonyOS
West Ray
West Ray

Posted on

I built a self-hosted remote terminal that survives app kills, network switches, and phone sleep — runs on Web, iOS, Android, and HarmonyOS

Two months ago I was SSH'd into a server from my phone on a train. Switched to another app for 10 seconds, came back — tab killed, session gone.

Every mobile terminal app has the same problem: none can actually keep a connection alive. The OS kills background processes, switches networks, or puts the radio to sleep.

I flipped the model: what if the shell keeps running on a remote agent, and your phone is just the display?

Architecture

  • Worker — lightweight agent on any machine. Manages PTY sessions that keep running with zero connected clients.
  • Gateway — .NET 10 middleware. Auth, routing, session coordination.
  • Client — Web (React + xterm.js), iOS, Android, HarmonyOS. Pure rendering with scrollback replay on reconnect.
Client (Browser / iOS / Android / HarmonyOS)
         ↕  SignalR
     Gateway (.NET 10)
         ↕  SignalR
     Worker (.NET 10 + PTY)
Enter fullscreen mode Exit fullscreen mode

The Hard Part: Hand-Rolling SignalR for HarmonyOS

On iOS/Android/Web, there's a Microsoft SDK. On HarmonyOS? Nothing.

1091 lines of ArkTS later, I had a full SignalR client:

  • Negotiate handshake — HTTP POST, extract connection token
  • WebSocket transport — exponential backoff reconnection
  • Hub protocol — 5 message types (Invocation, StreamItem, Completion, Ping, Close), 0x1E record-separated
  • Keepalive — 15s ping, cyclic reconnect (official SDK gives up after 5; mine does 15)
switch (message.type) {
  case 1: this.invokeHandler(invocation.target, invocation.arguments); break;
  case 2: pending.resolve(streamItem.item); break;
  case 3: completion.error ? pending.reject(err) : pending.resolve(result); break;
  case 6: break; // Ping
  case 7: this.handleCloseMessage(close); break;
}
Enter fullscreen mode Exit fullscreen mode

Virtual Keyboard on Mobile

No physical keyboard means no Ctrl+C. My solution: sticky modifier keys. Tap Ctrl once to latch it, tap C to send SIGINT:

const ch = 'c'.toLowerCase().charCodeAt(0);  // 99
this.sendInput(String.fromCharCode(ch - 96)); // x03 = SIGINT
Enter fullscreen mode Exit fullscreen mode

ArkTS Constraints

Every one caused a compile error: no as const, no untyped object literals, no destructuring, throw only accepts Error.

Numbers

425 commits, 53 days, 1 person, 5 platforms. HarmonyOS: 8645 lines ArkTS, 1091 lines SignalR.

Try It

docker run -d -p 5045:5045 ghcr.io/monster-echo/corterm-gateway:latest
Enter fullscreen mode Exit fullscreen mode

GitHub: github.com/monster-echo/CortexTerminal2
App Store · Google Play · Huawei AppGallery

Questions about SignalR, PTY lifecycle, or cross-platform CI? Ask below.

Top comments (0)