diff --git a/.gitignore b/.gitignore index 5ab97cf..0f70daf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,51 @@ +# Dependencies node_modules/ + +# Build output dist/ +*.tsbuildinfo + +# Expo / React Native .expo/ .expo-shared/ +expo-env.d.ts +web-build/ + +# EAS +.eas/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +credentials.json +google-services.json +GoogleService-Info.plist + +# Native prebuild (generated by expo prebuild) +ios/ +android/ + +# Logs *.log -.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment .env -.env.local +.env.* +!.env.example + +# OS +.DS_Store +Thumbs.db + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# Test / coverage +coverage/ diff --git a/README.md b/README.md index d4cc4a1..605d827 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,120 @@ # Pi Mobile -Prototype mobile remote control for [Pi](https://pi.dev/docs/latest) coding agents. +Remote control for [Pi](https://pi.dev/docs/latest) coding agents from your phone. Phone connects to a hub running on your computer over the local network. The hub embeds the Pi SDK in-process and exposes session management plus streaming chat over Socket.IO. -## Prerequisites +## Quick start (developers) -- Node.js 20+ -- Pi installed and authenticated on the host machine (`pi` CLI with `/login` or provider API keys) -- Expo Go on your phone (or iOS Simulator / Android emulator) -- Phone and computer on the same LAN +### 1. Start the hub on your computer -## Project structure +No clone required: -``` -apps/api/ Express + Socket.IO hub with Pi SDK -apps/mobile/ Expo Router mobile app +```bash +npx pi-mobile-hub@latest ``` -## Setup +Or from this repo during development: ```bash npm install -``` - -## Run the API hub - -From the repo root: - -```bash npm run dev:api ``` -Or directly: +The hub listens on `0.0.0.0:8787` by default and prints: -```bash -cd apps/api -npm run dev -``` - -The server listens on `0.0.0.0:8787` by default and prints LAN URLs on startup. +- LAN URLs to connect from your phone +- A **pairing code** +- A **QR code** encoding a `pimobile://connect` deep link Custom port: ```bash -npm run start -w @pi-mobile/api -- --port 9000 +npx pi-mobile-hub --port 9000 ``` -## Run the mobile app +Disable pairing auth on trusted networks only: -In a second terminal: +```bash +npx pi-mobile-hub --no-auth +``` + +### 2. Install the mobile app + +**Option A — Prebuilt app (recommended)** + +Install the Pi Mobile app from TestFlight (iOS) or the APK link (Android). See [Releasing](#releasing) if you are building it yourself. + +**Option B — Expo Go (development)** ```bash npm run dev:mobile ``` -Scan the QR code with Expo Go, then: +Scan the QR code with Expo Go. -1. Enter your computer's LAN IP and port (`8787`) -2. Tap **Connect** -3. Create a session by choosing a folder under `~/` -4. Send prompts and watch streamed assistant text + tool activity -5. Resume persisted Pi sessions from the list +### 3. Pair and connect + +1. Open Pi Mobile on your phone +2. Scan the QR code printed by `pi-mobile-hub` in your terminal + - Or enter your computer's LAN IP, port (`8787`), and pairing code manually +3. Tap **Connect** +4. Create a session by choosing a folder under `~/` +5. Send prompts and watch streamed assistant text + tool activity +6. Resume persisted Pi sessions from the list + +Saved computers remember the pairing code for one-tap reconnect. + +## Prerequisites + +- Node.js 20+ +- Pi installed and authenticated on the host machine (`pi` CLI with `/login` or provider API keys) +- Phone and computer on the same LAN +- For prebuilt mobile installs: iOS or Android device + +## Project structure + +``` +apps/api/ Express + Socket.IO hub (`pi-mobile-hub` npm package) +apps/mobile/ Expo Router mobile app +``` + +## Development + +```bash +npm install +npm run dev:api # hub on :8787 +npm run dev:mobile # Expo dev server +npm run build:api # compile hub for publishing +``` ## API surface ### HTTP -- `GET /health` — liveness check -- `GET /fs/list?path=~` — folder-only explorer rooted at home +- `GET /health` — liveness check (`authRequired` indicates whether pairing is enabled) +- `GET /fs/list?path=~` — folder-only explorer rooted at home (requires pairing token) +- `POST /fs/mkdir` — create folder under home (requires pairing token) + +Authenticated HTTP requests must include header `x-pi-mobile-token: `. ### Socket.IO (client → server) | Event | Payload | |-------|---------| | `sessions:list` | — | -| `runtime:create` | `{ cwd, name? }` | +| `runtime:create` | `{ cwd, name?, model?, thinkingLevel? }` | | `runtime:open` | `{ sessionFile }` | | `runtime:attach` | `{ runtimeId }` | +| `runtime:model:set` | `{ runtimeId, model }` | +| `runtime:thinking:set` | `{ runtimeId, level }` | +| `runtime:thinking:cycle` | `{ runtimeId }` | +| `models:list` | `{ runtimeId?, cwd? }` | | `chat:prompt` | `{ runtimeId, message }` | | `chat:abort` | `{ runtimeId }` | +Socket connections must pass `auth: { token: "" }` when pairing is enabled. + ### Socket.IO (server → client) | Event | Payload | @@ -88,17 +122,83 @@ Scan the QR code with Expo Go, then: | `sessions:list:result` | `{ persisted, live }` | | `runtime:created` / `runtime:opened` | `{ runtimeId, sessionId, cwd, sessionName?, sessionFile? }` | | `chat:event` | `{ runtimeId, event }` | +| `chat:history` | `{ runtimeId, messages }` | | `error` | `{ message }` | +## Pairing & security + +Each hub start generates a fresh pairing token unless `--no-auth` is passed. The token is required for: + +- Socket.IO connections (`handshake.auth.token`) +- Folder HTTP endpoints (`x-pi-mobile-token` header) + +The mobile app stores the token per saved computer. Scanning the terminal QR code auto-fills host, port, and token via `pimobile://connect?host=...&port=...&token=...`. + +This is LAN pairing, not full remote access over the internet. Do not expose the hub to untrusted networks without additional hardening. + +## Releasing + +### Hub (`pi-mobile-hub` on npm) + +From `apps/api`: + +```bash +npm run build +npm version patch +npm publish +``` + +Users can then run `npx pi-mobile-hub@latest`. + +### Mobile app (EAS) + +One-time setup: + +```bash +cd apps/mobile +npm install -g eas-cli +eas login +eas init +``` + +Update `app.json` → `extra.eas.projectId` with the ID from `eas init`. + +Build for internal testing: + +```bash +# iOS (TestFlight via internal distribution) +eas build --platform ios --profile preview + +# Android (shareable APK) +eas build --platform android --profile preview +``` + +Submit to stores: + +```bash +eas build --platform ios --profile production +eas submit --platform ios + +eas build --platform android --profile production +eas submit --platform android +``` + +Optional OTA updates for JS-only changes: + +```bash +eas update --branch production --message "Describe the change" +``` + ## Notes -- Security/auth are intentionally out of scope for this prototype. - The folder browser only exposes directories under your home folder. - Pi credentials are read from the host environment / Pi auth storage — the mobile app does not handle login. - Multiple live runtimes can run concurrently; switch between them from the Sessions screen. ## Success checklist +- [ ] Start hub via `npx pi-mobile-hub` +- [ ] Pair via QR code or manual pairing code - [ ] Connect from phone via LAN IP - [ ] Browse folders starting at `~/` - [ ] Create a new Pi session diff --git a/apps/api/bin/pi-mobile-hub.js b/apps/api/bin/pi-mobile-hub.js new file mode 100755 index 0000000..2b075f0 --- /dev/null +++ b/apps/api/bin/pi-mobile-hub.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import("../dist/index.js").catch((error) => { + console.error( + "Failed to start pi-mobile-hub:", + error instanceof Error ? error.message : error, + ); + process.exit(1); +}); diff --git a/apps/api/package.json b/apps/api/package.json index 207766d..23bb7d8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,24 +1,40 @@ { - "name": "@pi-mobile/api", + "name": "pi-mobile-hub", "version": "0.1.0", - "private": true, + "description": "Pi Mobile API hub — remote control Pi agents from your phone", "type": "module", + "bin": { + "pi-mobile-hub": "./bin/pi-mobile-hub.js" + }, + "files": [ + "bin", + "dist" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", - "build": "tsc" + "build": "tsc", + "prepublishOnly": "npm run build" }, "dependencies": { "@earendil-works/pi-coding-agent": "latest", "cors": "^2.8.5", "express": "^4.21.2", + "qrcode-terminal": "^0.12.0", "socket.io": "^4.8.1" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.10.2", + "@types/qrcode-terminal": "^0.12.2", "tsx": "^4.19.2", "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" } } diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..2da3bc3 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,92 @@ +import { randomBytes } from "node:crypto"; +import type { NextFunction, Request, Response } from "express"; +import type { Socket } from "socket.io"; +import qrcode from "qrcode-terminal"; + +export type AuthConfig = { + enabled: boolean; + token?: string; +}; + +export function createAuthConfig(noAuth: boolean): AuthConfig { + if (noAuth) { + return { enabled: false }; + } + + return { + enabled: true, + token: randomBytes(24).toString("hex"), + }; +} + +export function isAuthorized(auth: AuthConfig, token: string | undefined): boolean { + if (!auth.enabled) { + return true; + } + + return typeof token === "string" && token.length > 0 && token === auth.token; +} + +export function createHttpAuthMiddleware(auth: AuthConfig) { + return (req: Request, res: Response, next: NextFunction) => { + if (!auth.enabled) { + next(); + return; + } + + const headerToken = req.header("x-pi-mobile-token"); + const queryToken = typeof req.query.token === "string" ? req.query.token : undefined; + const token = headerToken ?? queryToken; + + if (!isAuthorized(auth, token)) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + next(); + }; +} + +export function createSocketAuthMiddleware(auth: AuthConfig) { + return (socket: Socket, next: (error?: Error) => void) => { + if (!auth.enabled) { + next(); + return; + } + + const token = + typeof socket.handshake.auth?.token === "string" ? socket.handshake.auth.token : undefined; + + if (!isAuthorized(auth, token)) { + next(new Error("Unauthorized")); + return; + } + + next(); + }; +} + +export function buildConnectDeepLink(host: string, port: number, token: string): string { + const params = new URLSearchParams({ + host, + port: String(port), + token, + }); + return `pimobile://connect?${params.toString()}`; +} + +export function printPairingInfo(addresses: string[], port: number, token: string): void { + console.log("\nPairing code:"); + console.log(` ${token}`); + console.log("\nConnect from Pi Mobile using one of these addresses:"); + + for (const address of addresses) { + const deepLink = buildConnectDeepLink(address, port, token); + console.log(`\n http://${address}:${port}`); + console.log(` ${deepLink}`); + console.log(""); + qrcode.generate(deepLink, { small: true }); + } + + console.log("\nScan the QR code with Pi Mobile, or enter the pairing code manually."); +} diff --git a/apps/api/src/cli.ts b/apps/api/src/cli.ts new file mode 100644 index 0000000..81b5885 --- /dev/null +++ b/apps/api/src/cli.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type CliOptions = { + port: number; + noAuth: boolean; + help: boolean; + version: boolean; +}; + +const DEFAULT_PORT = 8787; + +function getPackageVersion(): string { + try { + const dir = dirname(fileURLToPath(import.meta.url)); + const packageJsonPath = join(dir, "..", "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: string }; + return packageJson.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + +export function parseCliArgs(argv: string[]): CliOptions { + let port = Number(process.env.PORT); + if (Number.isNaN(port)) { + port = DEFAULT_PORT; + } + + let noAuth = false; + let help = false; + let version = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } + + if (arg === "--version" || arg === "-v") { + version = true; + continue; + } + + if (arg === "--no-auth") { + noAuth = true; + continue; + } + + if (arg === "--port" || arg === "-p") { + const value = Number(argv[index + 1]); + if (!Number.isNaN(value)) { + port = value; + index += 1; + } + continue; + } + + if (arg.startsWith("--port=")) { + const value = Number(arg.slice("--port=".length)); + if (!Number.isNaN(value)) { + port = value; + } + } + } + + return { port, noAuth, help, version }; +} + +export function printHelp(): void { + console.log(`pi-mobile-hub — Pi Mobile API hub + +Usage: + pi-mobile-hub [options] + +Options: + -p, --port Port to listen on (default: 8787) + --no-auth Disable pairing token requirement + -h, --help Show this help message + -v, --version Show version number + +Examples: + npx pi-mobile-hub + npx pi-mobile-hub --port 9000 + npx pi-mobile-hub --no-auth +`); +} + +export function printVersion(): void { + console.log(getPackageVersion()); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9aa3c42..d9638e8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,25 +3,16 @@ import express from "express"; import { createServer } from "node:http"; import { networkInterfaces } from "node:os"; import { Server } from "socket.io"; +import { + createAuthConfig, + createHttpAuthMiddleware, + printPairingInfo, +} from "./auth.js"; +import { parseCliArgs, printHelp, printVersion } from "./cli.js"; import { createFolder, listFolders } from "./fs.js"; import { RuntimeRegistry } from "./runtime-registry.js"; import { registerSocketHandlers } from "./socket-handlers.js"; -const DEFAULT_PORT = 8787; - -function getPort(): number { - const argIndex = process.argv.indexOf("--port"); - if (argIndex !== -1) { - const value = Number(process.argv[argIndex + 1]); - if (!Number.isNaN(value)) { - return value; - } - } - - const envPort = Number(process.env.PORT); - return Number.isNaN(envPort) ? DEFAULT_PORT : envPort; -} - function getLanAddresses(): string[] { const addresses: string[] = []; const interfaces = networkInterfaces(); @@ -41,6 +32,19 @@ function getLanAddresses(): string[] { return addresses; } +const cli = parseCliArgs(process.argv.slice(2)); + +if (cli.help) { + printHelp(); + process.exit(0); +} + +if (cli.version) { + printVersion(); + process.exit(0); +} + +const auth = createAuthConfig(cli.noAuth); const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { @@ -51,15 +55,20 @@ const io = new Server(httpServer, { }); const registry = new RuntimeRegistry(); +const requireAuth = createHttpAuthMiddleware(auth); app.use(cors()); app.use(express.json()); app.get("/health", (_req, res) => { - res.json({ ok: true, service: "pi-mobile-api" }); + res.json({ + ok: true, + service: "pi-mobile-api", + authRequired: auth.enabled, + }); }); -app.get("/fs/list", async (req, res) => { +app.get("/fs/list", requireAuth, async (req, res) => { try { const path = typeof req.query.path === "string" ? req.query.path : "~"; const result = await listFolders(path); @@ -70,7 +79,7 @@ app.get("/fs/list", async (req, res) => { } }); -app.post("/fs/mkdir", async (req, res) => { +app.post("/fs/mkdir", requireAuth, async (req, res) => { try { const path = typeof req.body?.path === "string" ? req.body.path : "~"; const name = typeof req.body?.name === "string" ? req.body.name : ""; @@ -82,13 +91,21 @@ app.post("/fs/mkdir", async (req, res) => { } }); -registerSocketHandlers(io, registry); +registerSocketHandlers(io, registry, auth); -const port = getPort(); +const port = cli.port; httpServer.listen(port, "0.0.0.0", () => { const lan = getLanAddresses(); console.log(`Pi Mobile API listening on 0.0.0.0:${port}`); - if (lan.length > 0) { + + if (auth.enabled && auth.token) { + if (lan.length > 0) { + printPairingInfo(lan, port, auth.token); + } else { + printPairingInfo(["127.0.0.1"], port, auth.token); + console.log("Could not detect a LAN address. Use your computer's LAN IP on port", port); + } + } else if (lan.length > 0) { console.log("Connect from your phone using:"); for (const address of lan) { console.log(` http://${address}:${port}`); diff --git a/apps/api/src/socket-handlers.ts b/apps/api/src/socket-handlers.ts index 308567b..6bb99cf 100644 --- a/apps/api/src/socket-handlers.ts +++ b/apps/api/src/socket-handlers.ts @@ -1,4 +1,5 @@ import type { Server, Socket } from "socket.io"; +import { createSocketAuthMiddleware, type AuthConfig } from "./auth.js"; import { serializeMessages } from "./message-history.js"; import type { RuntimeRegistry } from "./runtime-registry.js"; import type { @@ -15,7 +16,13 @@ type AttachState = { unsubscribe?: () => void; }; -export function registerSocketHandlers(io: Server, registry: RuntimeRegistry) { +export function registerSocketHandlers( + io: Server, + registry: RuntimeRegistry, + auth: AuthConfig, +) { + io.use(createSocketAuthMiddleware(auth)); + io.on("connection", (socket: Socket) => { const state: AttachState = {}; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index dd54185..47df251 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -8,9 +8,11 @@ "userInterfaceStyle": "dark", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "dev.pi.mobile" }, "android": { + "package": "dev.pi.mobile", "adaptiveIcon": { "backgroundColor": "#0a0a0a" } @@ -20,6 +22,11 @@ ], "experiments": { "typedRoutes": true + }, + "extra": { + "eas": { + "projectId": "REPLACE_WITH_EAS_PROJECT_ID" + } } } } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index cd9b4ed..b444296 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -55,6 +55,7 @@ export default function ConnectScreen() { host, port, computerName, + token, connected, connecting, error, @@ -62,6 +63,7 @@ export default function ConnectScreen() { setHost, setPort, setComputerName, + setToken, connect, connectToSavedComputer, removeSavedComputerEntry, @@ -80,7 +82,10 @@ export default function ConnectScreen() { keyboardShouldPersistTaps="handled" > Pi Mobile - Remote control for Pi agents on your computer. + + Remote control for Pi agents on your computer. Scan the QR code from pi-mobile-hub, or enter + your computer details manually. + Computer name @@ -115,6 +120,17 @@ export default function ConnectScreen() { style={styles.input} /> + Pairing code + + {error ? {error} : null} (null); const [loading, setLoading] = useState(true); @@ -161,7 +161,7 @@ export default function FolderPickerScreen() { setLocalError(null); setSearch(""); try { - const result = (await listFolders(baseUrl, path)) as FolderListResult; + const result = (await listFolders(baseUrl, path, token)) as FolderListResult; setCurrent(result); } catch (loadError) { const message = loadError instanceof Error ? loadError.message : "Failed to load folders"; @@ -170,7 +170,7 @@ export default function FolderPickerScreen() { setLoading(false); } }, - [baseUrl], + [baseUrl, token], ); useEffect(() => { @@ -217,7 +217,7 @@ export default function FolderPickerScreen() { setCreatingFolder(true); setNewFolderError(null); try { - await createFolder(baseUrl, current.path, trimmed); + await createFolder(baseUrl, current.path, trimmed, token); setNewFolderVisible(false); await loadPath(current.path); } catch (createError) { diff --git a/apps/mobile/contexts/SocketContext.tsx b/apps/mobile/contexts/SocketContext.tsx index cbe94ae..d4a2fee 100644 --- a/apps/mobile/contexts/SocketContext.tsx +++ b/apps/mobile/contexts/SocketContext.tsx @@ -20,6 +20,7 @@ import { saveLastModel, upsertSavedComputer, } from "@/lib/api"; +import { parseConnectDeepLink } from "@/lib/deeplink"; import { createSocket, listModels as requestModels, @@ -38,11 +39,13 @@ import type { ThinkingLevel, } from "@/lib/types"; import type { Socket } from "socket.io-client"; +import * as Linking from "expo-linking"; type SocketContextValue = { host: string; port: string; computerName: string; + token: string; baseUrl: string | null; connected: boolean; connecting: boolean; @@ -56,8 +59,10 @@ type SocketContextValue = { setHost: (host: string) => void; setPort: (port: string) => void; setComputerName: (name: string) => void; + setToken: (token: string) => void; connect: () => Promise; connectToSavedComputer: (computer: SavedComputer) => Promise; + connectFromDeepLink: (url: string) => Promise; removeSavedComputerEntry: (id: string) => Promise; disconnect: () => void; refreshSessions: () => void; @@ -89,6 +94,7 @@ export function SocketProvider({ children }: { children: ReactNode }) { const [host, setHost] = useState(""); const [port, setPort] = useState("8787"); const [computerName, setComputerName] = useState(""); + const [token, setToken] = useState(""); const [baseUrl, setBaseUrl] = useState(null); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); @@ -118,6 +124,7 @@ export function SocketProvider({ children }: { children: ReactNode }) { setHost(recent.host); setPort(recent.port); setComputerName(recent.name); + setToken(recent.token ?? ""); void loadSavedProjects(recent.host, recent.port).then(setSavedProjects); }); }, []); @@ -168,20 +175,28 @@ export function SocketProvider({ children }: { children: ReactNode }) { }, []); const connectWithHost = useCallback( - async (nextHost: string, nextPort: string, nextName?: string) => { + async ( + nextHost: string, + nextPort: string, + nextName?: string, + nextToken?: string, + ) => { setConnecting(true); setError(null); try { const nextBaseUrl = buildBaseUrl(nextHost, nextPort); - const healthy = await checkHealth(nextBaseUrl); + const trimmedToken = nextToken?.trim(); + const healthy = await checkHealth(nextBaseUrl, trimmedToken); if (!healthy) { throw new Error("Could not reach Pi Mobile API"); } disconnect(); - const socket = createSocket(nextBaseUrl, { + const socket = createSocket( + nextBaseUrl, + { onConnect: () => { setConnected(true); setConnecting(false); @@ -215,7 +230,9 @@ export function SocketProvider({ children }: { children: ReactNode }) { listener(payload); } }, - }); + }, + trimmedToken, + ); socketRef.current = socket; setBaseUrl(nextBaseUrl); @@ -224,12 +241,16 @@ export function SocketProvider({ children }: { children: ReactNode }) { if (nextName !== undefined) { setComputerName(nextName); } - await saveHostConfig({ host: nextHost, port: nextPort }); + if (nextToken !== undefined) { + setToken(nextToken); + } + await saveHostConfig({ host: nextHost, port: nextPort, token: trimmedToken }); const nextComputers = await upsertSavedComputer( savedComputers, nextHost, nextPort, nextName ?? computerName, + trimmedToken, ); setSavedComputers(nextComputers); const projects = await loadSavedProjects(nextHost, nextPort); @@ -247,16 +268,58 @@ export function SocketProvider({ children }: { children: ReactNode }) { ); const connect = useCallback(async () => { - return connectWithHost(host, port, computerName); - }, [connectWithHost, host, port, computerName]); + return connectWithHost(host, port, computerName, token); + }, [connectWithHost, host, port, computerName, token]); const connectToSavedComputer = useCallback( async (computer: SavedComputer) => { - return connectWithHost(computer.host, computer.port, computer.name); + return connectWithHost( + computer.host, + computer.port, + computer.name, + computer.token, + ); }, [connectWithHost], ); + const connectFromDeepLink = useCallback( + async (url: string) => { + const params = parseConnectDeepLink(url); + if (!params) { + return false; + } + + return connectWithHost( + params.host, + params.port, + params.name ?? params.host, + params.token, + ); + }, + [connectWithHost], + ); + + useEffect(() => { + const handleDeepLink = (url: string) => { + void connectFromDeepLink(url); + }; + + void Linking.getInitialURL().then((url) => { + if (url) { + handleDeepLink(url); + } + }); + + const subscription = Linking.addEventListener("url", (event) => { + handleDeepLink(event.url); + }); + + return () => { + subscription.remove(); + }; + }, [connectFromDeepLink]); + const refreshSessions = useCallback(() => { socketRef.current?.emit("sessions:list"); }, []); @@ -384,6 +447,7 @@ export function SocketProvider({ children }: { children: ReactNode }) { host, port, computerName, + token, baseUrl, connected, connecting, @@ -398,8 +462,10 @@ export function SocketProvider({ children }: { children: ReactNode }) { setHost, setPort, setComputerName, + setToken, connect, connectToSavedComputer, + connectFromDeepLink, removeSavedComputerEntry, disconnect, refreshSessions, @@ -419,6 +485,7 @@ export function SocketProvider({ children }: { children: ReactNode }) { host, port, computerName, + token, baseUrl, connected, connecting, @@ -432,6 +499,7 @@ export function SocketProvider({ children }: { children: ReactNode }) { removeProject, connect, connectToSavedComputer, + connectFromDeepLink, removeSavedComputerEntry, disconnect, refreshSessions, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..38307b5 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,27 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "simulator": true + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 0503930..7e9fd06 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -116,6 +116,7 @@ export async function upsertSavedComputer( host: string, port: string, name?: string, + token?: string, ): Promise { const trimmedHost = host.trim(); const trimmedPort = port.trim() || "8787"; @@ -126,6 +127,7 @@ export async function upsertSavedComputer( const key = computerKey(trimmedHost, trimmedPort); const existing = computers.find((entry) => computerKey(entry.host, entry.port) === key); const trimmedName = name?.trim(); + const trimmedToken = token?.trim(); if (existing) { const next = computers.map((entry) => @@ -135,6 +137,7 @@ export async function upsertSavedComputer( name: trimmedName || entry.name, host: trimmedHost, port: trimmedPort, + token: trimmedToken || entry.token, lastUsedAt: Date.now(), } : entry, @@ -149,6 +152,7 @@ export async function upsertSavedComputer( name: trimmedName || trimmedHost, host: trimmedHost, port: trimmedPort, + token: trimmedToken, lastUsedAt: Date.now(), }, ...computers, @@ -191,8 +195,20 @@ export async function saveSavedProjects( await AsyncStorage.setItem(projectsStorageKey(host, port), JSON.stringify(projects)); } -export async function checkHealth(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/health`); +function authHeaders(token?: string): HeadersInit | undefined { + if (!token) { + return undefined; + } + + return { + "x-pi-mobile-token": token, + }; +} + +export async function checkHealth(baseUrl: string, token?: string): Promise { + const response = await fetch(`${baseUrl}/health`, { + headers: authHeaders(token), + }); if (!response.ok) { return false; } @@ -201,8 +217,10 @@ export async function checkHealth(baseUrl: string): Promise { return data.ok === true; } -export async function listFolders(baseUrl: string, path = "~") { - const response = await fetch(`${baseUrl}/fs/list?path=${encodeURIComponent(path)}`); +export async function listFolders(baseUrl: string, path = "~", token?: string) { + const response = await fetch(`${baseUrl}/fs/list?path=${encodeURIComponent(path)}`, { + headers: authHeaders(token), + }); if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? "Failed to list folders"); @@ -215,10 +233,14 @@ export async function createFolder( baseUrl: string, path: string, name: string, + token?: string, ): Promise<{ name: string; path: string }> { const response = await fetch(`${baseUrl}/fs/mkdir`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, body: JSON.stringify({ path, name }), }); if (!response.ok) { diff --git a/apps/mobile/lib/deeplink.ts b/apps/mobile/lib/deeplink.ts new file mode 100644 index 0000000..5671b1a --- /dev/null +++ b/apps/mobile/lib/deeplink.ts @@ -0,0 +1,33 @@ +export type ConnectDeepLinkParams = { + host: string; + port: string; + token: string; + name?: string; +}; + +export function parseConnectDeepLink(url: string): ConnectDeepLinkParams | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== "pimobile:") { + return null; + } + + const path = parsed.pathname.replace(/^\/+/, ""); + if (path !== "connect" && parsed.host !== "connect") { + return null; + } + + const host = parsed.searchParams.get("host")?.trim(); + const port = parsed.searchParams.get("port")?.trim() || "8787"; + const token = parsed.searchParams.get("token")?.trim(); + const name = parsed.searchParams.get("name")?.trim(); + + if (!host || !token) { + return null; + } + + return { host, port, token, name: name || undefined }; + } catch { + return null; + } +} diff --git a/apps/mobile/lib/socket.ts b/apps/mobile/lib/socket.ts index 60338ba..2f958e4 100644 --- a/apps/mobile/lib/socket.ts +++ b/apps/mobile/lib/socket.ts @@ -24,14 +24,22 @@ export type SocketCallbacks = { onChatHistory?: (payload: ChatHistoryPayload) => void; }; -export function createSocket(baseUrl: string, callbacks: SocketCallbacks): Socket { +export function createSocket( + baseUrl: string, + callbacks: SocketCallbacks, + token?: string, +): Socket { const socket = io(baseUrl, { transports: ["websocket"], autoConnect: true, + auth: token ? { token } : undefined, }); socket.on("connect", () => callbacks.onConnect?.()); socket.on("disconnect", () => callbacks.onDisconnect?.()); + socket.on("connect_error", (error) => { + callbacks.onError?.(error.message || "Connection failed"); + }); socket.on("error", (payload: { message?: string }) => { callbacks.onError?.(payload.message ?? "Socket error"); }); diff --git a/apps/mobile/lib/types.ts b/apps/mobile/lib/types.ts index a5cef46..830a4ed 100644 --- a/apps/mobile/lib/types.ts +++ b/apps/mobile/lib/types.ts @@ -107,6 +107,7 @@ export type ChatItem = export type HostConfig = { host: string; port: string; + token?: string; }; export type SavedComputer = { @@ -114,5 +115,6 @@ export type SavedComputer = { name: string; host: string; port: string; + token?: string; lastUsedAt: number; }; diff --git a/package-lock.json b/package-lock.json index 405e799..b969ad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,28 @@ ] }, "apps/api": { - "name": "@pi-mobile/api", + "name": "pi-mobile-hub", "version": "0.1.0", "dependencies": { "@earendil-works/pi-coding-agent": "latest", "cors": "^2.8.5", "express": "^4.21.2", + "qrcode-terminal": "^0.12.0", "socket.io": "^4.8.1" }, + "bin": { + "pi-mobile-hub": "bin/pi-mobile-hub.js" + }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.10.2", + "@types/qrcode-terminal": "^0.12.2", "tsx": "^4.19.2", "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" } }, "apps/api/node_modules/@types/node": { @@ -36,6 +44,14 @@ "undici-types": "~6.21.0" } }, + "apps/api/node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "apps/mobile": { "name": "@pi-mobile/mobile", "version": "0.1.0", @@ -4760,10 +4776,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pi-mobile/api": { - "resolved": "apps/api", - "link": true - }, "node_modules/@pi-mobile/mobile": { "resolved": "apps/mobile", "link": true @@ -5614,6 +5626,13 @@ "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", "license": "MIT" }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -10124,6 +10143,10 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pi-mobile-hub": { + "resolved": "apps/api", + "link": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 562ec07..f462042 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "apps/*" ], "scripts": { - "dev:api": "npm run dev -w @pi-mobile/api", + "dev:api": "npm run dev -w pi-mobile-hub", "dev:mobile": "npm run start -w @pi-mobile/mobile", - "build:api": "npm run build -w @pi-mobile/api" + "build:api": "npm run build -w pi-mobile-hub" }, "overrides": { "react": "19.1.0",