Package Pi Mobile for developer distribution.

Publish the hub as pi-mobile-hub with CLI flags and LAN pairing auth, add mobile deep-link pairing and EAS build config, and expand gitignore for Expo/EAS artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-09 14:54:09 -05:00
co-authored by Cursor
parent 62cbc5357b
commit bb9999de58
19 changed files with 676 additions and 92 deletions
+45 -2
View File
@@ -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/
+138 -38
View File
@@ -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: <pairing-code>`.
### 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: "<pairing-code>" }` 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
+9
View File
@@ -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);
});
+19 -3
View File
@@ -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"
}
}
+92
View File
@@ -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.");
}
+94
View File
@@ -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 <number> 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());
}
+37 -20
View File
@@ -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 (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}`);
+8 -1
View File
@@ -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 = {};
+8 -1
View File
@@ -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"
}
}
}
}
+17 -1
View File
@@ -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"
>
<Text style={styles.brand}>Pi Mobile</Text>
<Text style={styles.subtitle}>Remote control for Pi agents on your computer.</Text>
<Text style={styles.subtitle}>
Remote control for Pi agents on your computer. Scan the QR code from pi-mobile-hub, or enter
your computer details manually.
</Text>
<View style={styles.card}>
<Text style={styles.label}>Computer name</Text>
@@ -115,6 +120,17 @@ export default function ConnectScreen() {
style={styles.input}
/>
<Text style={styles.label}>Pairing code</Text>
<TextInput
value={token}
onChangeText={setToken}
placeholder="From pi-mobile-hub terminal"
placeholderTextColor="#666"
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
+4 -4
View File
@@ -139,7 +139,7 @@ function NoSearchResults({ query }: { query: string }) {
}
export default function FolderPickerScreen() {
const { baseUrl, addProject, savedProjects, error } = useSocket();
const { baseUrl, token, addProject, savedProjects, error } = useSocket();
const insets = useSafeAreaInsets();
const [current, setCurrent] = useState<FolderListResult | 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) {
+76 -8
View File
@@ -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<boolean>;
connectToSavedComputer: (computer: SavedComputer) => Promise<boolean>;
connectFromDeepLink: (url: string) => Promise<boolean>;
removeSavedComputerEntry: (id: string) => Promise<void>;
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<string | null>(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,
+27
View File
@@ -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": {}
}
}
+27 -5
View File
@@ -116,6 +116,7 @@ export async function upsertSavedComputer(
host: string,
port: string,
name?: string,
token?: string,
): Promise<SavedComputer[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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) {
+33
View File
@@ -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;
}
}
+9 -1
View File
@@ -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");
});
+2
View File
@@ -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;
};
+28 -5
View File
@@ -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",
+2 -2
View File
@@ -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",