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:
Executable
+9
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
+38
-21
@@ -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}`);
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user