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>
253 lines
6.8 KiB
TypeScript
253 lines
6.8 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import type { HostConfig, ModelRef, SavedComputer } from "./types";
|
|
|
|
const HOST_KEY = "pi-mobile:last-host";
|
|
const COMPUTERS_KEY = "pi-mobile:computers";
|
|
const PROJECTS_PREFIX = "pi-mobile:projects:";
|
|
const LAST_MODEL_KEY = "pi-mobile:last-model";
|
|
|
|
function generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
function computerKey(host: string, port: string): string {
|
|
return `${host.trim()}:${port.trim() || "8787"}`;
|
|
}
|
|
|
|
function sortByRecent(computers: SavedComputer[]): SavedComputer[] {
|
|
return [...computers].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
}
|
|
|
|
function projectsStorageKey(host: string, port: string): string {
|
|
return `${PROJECTS_PREFIX}${host.trim()}:${port.trim() || "8787"}`;
|
|
}
|
|
|
|
export function buildBaseUrl(host: string, port: string): string {
|
|
const trimmedHost = host.trim();
|
|
const trimmedPort = port.trim() || "8787";
|
|
const withProtocol = trimmedHost.startsWith("http") ? trimmedHost : `http://${trimmedHost}`;
|
|
const url = new URL(withProtocol);
|
|
url.port = trimmedPort;
|
|
return url.origin;
|
|
}
|
|
|
|
export async function loadHostConfig(): Promise<HostConfig | null> {
|
|
const raw = await AsyncStorage.getItem(HOST_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(raw) as HostConfig;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function saveHostConfig(config: HostConfig): Promise<void> {
|
|
await AsyncStorage.setItem(HOST_KEY, JSON.stringify(config));
|
|
}
|
|
|
|
export async function loadLastModel(): Promise<ModelRef | null> {
|
|
const raw = await AsyncStorage.getItem(LAST_MODEL_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<ModelRef>;
|
|
return typeof parsed.provider === "string" && typeof parsed.id === "string"
|
|
? { provider: parsed.provider, id: parsed.id }
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function saveLastModel(model: ModelRef): Promise<void> {
|
|
await AsyncStorage.setItem(LAST_MODEL_KEY, JSON.stringify(model));
|
|
}
|
|
|
|
export async function loadSavedComputers(): Promise<SavedComputer[]> {
|
|
const raw = await AsyncStorage.getItem(COMPUTERS_KEY);
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
return sortByRecent(
|
|
parsed.filter(
|
|
(entry): entry is SavedComputer =>
|
|
typeof entry === "object" &&
|
|
entry !== null &&
|
|
typeof (entry as SavedComputer).id === "string" &&
|
|
typeof (entry as SavedComputer).name === "string" &&
|
|
typeof (entry as SavedComputer).host === "string" &&
|
|
typeof (entry as SavedComputer).port === "string" &&
|
|
typeof (entry as SavedComputer).lastUsedAt === "number",
|
|
),
|
|
);
|
|
}
|
|
} catch {
|
|
// fall through to migration
|
|
}
|
|
}
|
|
|
|
const legacy = await loadHostConfig();
|
|
if (!legacy?.host.trim()) {
|
|
return [];
|
|
}
|
|
|
|
const migrated: SavedComputer = {
|
|
id: generateId(),
|
|
name: legacy.host.trim(),
|
|
host: legacy.host.trim(),
|
|
port: legacy.port.trim() || "8787",
|
|
lastUsedAt: Date.now(),
|
|
};
|
|
await saveSavedComputers([migrated]);
|
|
return [migrated];
|
|
}
|
|
|
|
export async function saveSavedComputers(computers: SavedComputer[]): Promise<void> {
|
|
await AsyncStorage.setItem(COMPUTERS_KEY, JSON.stringify(sortByRecent(computers)));
|
|
}
|
|
|
|
export async function upsertSavedComputer(
|
|
computers: SavedComputer[],
|
|
host: string,
|
|
port: string,
|
|
name?: string,
|
|
token?: string,
|
|
): Promise<SavedComputer[]> {
|
|
const trimmedHost = host.trim();
|
|
const trimmedPort = port.trim() || "8787";
|
|
if (!trimmedHost) {
|
|
return computers;
|
|
}
|
|
|
|
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) =>
|
|
entry.id === existing.id
|
|
? {
|
|
...entry,
|
|
name: trimmedName || entry.name,
|
|
host: trimmedHost,
|
|
port: trimmedPort,
|
|
token: trimmedToken || entry.token,
|
|
lastUsedAt: Date.now(),
|
|
}
|
|
: entry,
|
|
);
|
|
await saveSavedComputers(next);
|
|
return sortByRecent(next);
|
|
}
|
|
|
|
const next = [
|
|
{
|
|
id: generateId(),
|
|
name: trimmedName || trimmedHost,
|
|
host: trimmedHost,
|
|
port: trimmedPort,
|
|
token: trimmedToken,
|
|
lastUsedAt: Date.now(),
|
|
},
|
|
...computers,
|
|
];
|
|
await saveSavedComputers(next);
|
|
return sortByRecent(next);
|
|
}
|
|
|
|
export async function removeSavedComputer(
|
|
computers: SavedComputer[],
|
|
id: string,
|
|
): Promise<SavedComputer[]> {
|
|
const next = computers.filter((entry) => entry.id !== id);
|
|
await saveSavedComputers(next);
|
|
return next;
|
|
}
|
|
|
|
export async function loadSavedProjects(host: string, port: string): Promise<string[]> {
|
|
const raw = await AsyncStorage.getItem(projectsStorageKey(host, port));
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function saveSavedProjects(
|
|
host: string,
|
|
port: string,
|
|
projects: string[],
|
|
): Promise<void> {
|
|
await AsyncStorage.setItem(projectsStorageKey(host, port), JSON.stringify(projects));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const data = (await response.json()) as { ok?: boolean };
|
|
return data.ok === true;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
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",
|
|
...authHeaders(token),
|
|
},
|
|
body: JSON.stringify({ path, name }),
|
|
});
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
throw new Error(body.error ?? "Failed to create folder");
|
|
}
|
|
|
|
return response.json();
|
|
}
|