Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"fmt:check": "oxfmt --check .",
"test": "vitest run --configLoader runner",
"test:perf:cli-hook": "vitest run --configLoader runner src/supervisor/runtime/cliHookEventChain.perf.test.ts",
"test:perf:remote": "vitest run --configLoader runner src/mobile/remoteProtocol.perf.test.ts",
"test:integration:providers": "vitest run --configLoader runner --config vitest.integration.config.ts",
"smoke:integration": "node .agents/skills/interactive-testing/scripts/run-poracode-smoke.mjs",
"update-server": "node scripts/update-server.mjs",
Expand Down
6 changes: 3 additions & 3 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"id": "./",
"id": "/pwa/",
"name": "Poracode",
"short_name": "Poracode",
"description": "Follow and steer your Poracode AI agents from your phone — threads, git review, and live browser, paired to your desktop.",
"start_url": "./app",
"scope": "./",
"start_url": "/app/threads",
"scope": "/app/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#070709",
Expand Down
117 changes: 107 additions & 10 deletions public/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,49 @@
// build ships an equivalent worker generated at runtime (see
// src/main/remote/pairingPage.ts); keep the two in sync.
//
// Strategy: network-first for same-origin GETs, caching successful responses
// and falling back to the cache (and to the app shell for navigations) when
// offline. Cross-origin requests — notably the paired desktop's /api, /oauth
// and /ws endpoints, which live on a different host — are never intercepted.
const CACHE_NAME = "poracode-pwa-v2";
// Strategy: cache-first for immutable hashed build assets, network-first for
// other same-origin GETs, and an app-shell fallback for offline navigations.
// Cross-origin requests — notably the paired desktop's /api, /oauth and /ws
// endpoints, which live on a different host — are never intercepted.
const BUILD_VERSION = "__PORACODE_BUILD_VERSION__";
const CACHE_NAME = `poracode-pwa-${BUILD_VERSION}`;
const NAVIGATION_FALLBACK_DELAY_MS = 500;
const APP_BASE_URL = new URL("./", self.location.href);
const shellUrl = (path) => new URL(path, APP_BASE_URL).pathname;
const SHELL_URLS = ["./", "app", "manifest.webmanifest", "app-icon.svg"].map(shellUrl);

function shellAssetUrls(html) {
const urls = new Set();
for (const match of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) {
const url = new URL(match[1], APP_BASE_URL);
if (url.origin === self.location.origin) urls.add(`${url.pathname}${url.search}`);
}
return [...urls];
}

async function cacheShell() {
const cache = await caches.open(CACHE_NAME);
await Promise.allSettled(SHELL_URLS.map((url) => cache.add(url)));
const shell = await cache.match(shellUrl("app"));
if (!shell) return;
const assets = shellAssetUrls(await shell.text());
await Promise.allSettled(assets.map((url) => cache.add(url)));
}

function validBuildAssetUrls(value) {
if (!Array.isArray(value)) return [];
const assetPrefix = `${APP_BASE_URL.pathname}assets/`;
return value.slice(0, 256).flatMap((candidate) => {
if (typeof candidate !== "string") return [];
const url = new URL(candidate, APP_BASE_URL);
return url.origin === self.location.origin && url.pathname.startsWith(assetPrefix)
? [`${url.pathname}${url.search}`]
: [];
});
}

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS).catch(() => undefined)),
);
event.waitUntil(cacheShell());
self.skipWaiting();
});

Expand All @@ -23,26 +53,93 @@ self.addEventListener("activate", (event) => {
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
Promise.all(
keys
.filter((key) => key.startsWith("poracode-pwa-") && key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});

self.addEventListener("message", (event) => {
if (event.data?.type !== "cache-build-assets") return;
const urls = validBuildAssetUrls(event.data.urls);
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => Promise.allSettled(urls.map((url) => cache.add(url)))),
);
});

self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;

const url = new URL(request.url);
// Only handle same-origin requests; the desktop API lives elsewhere.
if (url.origin !== self.location.origin) return;
const isAppRequest = url.pathname === "/app" || url.pathname.startsWith("/app/");
const buildRoute = APP_BASE_URL.pathname.replace(/\/$/, "");
const isBuildRequest =
buildRoute === "" || url.pathname === buildRoute || url.pathname.startsWith(`${buildRoute}/`);
if (!isAppRequest && !isBuildRequest) return;

if (url.pathname.startsWith(`${APP_BASE_URL.pathname}assets/`)) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
if (response.ok) {
const clone = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
}),
),
);
return;
}

if (request.mode === "navigate") {
const networkResponse = fetch(request).then(async (response) => {
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
await cache.put(shellUrl("app"), response.clone());
}
return response;
});
const cachedResponse = new Promise((resolve) => {
setTimeout(() => {
void caches.match(shellUrl("app")).then(resolve);
}, NAVIGATION_FALLBACK_DELAY_MS);
});
event.waitUntil(
networkResponse.then(
() => undefined,
() => undefined,
),
);
event.respondWith(
Promise.race([networkResponse, cachedResponse])
.then((response) => response || networkResponse)
.catch(
async () =>
(await caches.match(shellUrl("app"))) ||
(await caches.match(shellUrl("./"))) ||
Response.error(),
),
);
return;
}

event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
const cacheKey = request.mode === "navigate" ? shellUrl("app") : request;
void caches.open(CACHE_NAME).then((cache) => cache.put(cacheKey, clone));
}
return response;
})
Expand Down
13 changes: 12 additions & 1 deletion scripts/finalize-mobile-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// Capacitor native shells both default to serving `index.html` from the web
// root, so mirror the entry to `index.html`. Asset URLs use a relative base
// ("./"), so the copy resolves identically at the new filename.
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";

const mobileBasePath = readEnv("PORACODE_MOBILE_BASE_PATH");
Expand All @@ -14,6 +15,7 @@ const outDir = resolve(
);
const source = join(outDir, "mobile.html");
const target = join(outDir, "index.html");
const serviceWorkerPath = join(outDir, "service-worker.js");
const wellKnownDir = join(outDir, ".well-known");
const sshRuntimeSourceDir = resolve(process.cwd(), "resources/mobile-ssh-runtime");
const sshRuntimeTargetDir = join(outDir, "poracode-ssh-runtime");
Expand Down Expand Up @@ -44,6 +46,14 @@ if (requireIosLinks && !appleTeamId) {
}

copyFileSync(source, target);
const serviceWorker = readFileSync(serviceWorkerPath, "utf8");
const buildVersion = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 12);
const versionToken = "__PORACODE_BUILD_VERSION__";
if (!serviceWorker.includes(versionToken)) {
console.error(`[finalize-mobile-build] missing build-version token in ${serviceWorkerPath}`);
process.exit(1);
}
writeFileSync(serviceWorkerPath, serviceWorker.replaceAll(versionToken, buildVersion), "utf8");
mkdirSync(sshRuntimeTargetDir, { recursive: true });
copyFileSync(
join(sshRuntimeSourceDir, "manifest.json"),
Expand All @@ -54,6 +64,7 @@ mkdirSync(wellKnownDir, { recursive: true });
writeJson(join(wellKnownDir, "assetlinks.json"), buildAssetLinks());
writeJson(join(wellKnownDir, "apple-app-site-association"), buildAppleAppSiteAssociation());
console.log(`[finalize-mobile-build] wrote ${target}`);
console.log(`[finalize-mobile-build] versioned the service worker as ${buildVersion}`);
console.log("[finalize-mobile-build] embedded the SSH runtime");
console.log("[finalize-mobile-build] wrote .well-known association files");

Expand Down
79 changes: 78 additions & 1 deletion src/main/remote/RemoteAccessServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
dbGetThreadContextUsage,
dbGetLatestThreadRuntimeAnchorItemId,
dbGetThreadRuntimeItems,
dbGetThreadRuntimeItemsPage,
dbGetThreadRuntimeSummaries,
dbGetThreads,
dbReplaceThreadRuntimeSnapshot,
Expand Down Expand Up @@ -70,6 +71,9 @@ vi.mock("../db", () => {
dbGetThreadContextUsage: vi.fn<() => null>(() => null),
dbGetLatestThreadRuntimeAnchorItemId: vi.fn<() => null>(() => null),
dbGetThreadRuntimeItems: vi.fn<() => unknown[]>(() => []),
dbGetThreadRuntimeItemsPage: vi.fn<() => { items: unknown[]; nextCursor: number | null }>(
() => ({ items: [], nextCursor: null }),
),
dbGetThreadRuntimeSummaries: vi.fn<() => Record<string, unknown>>(() => ({})),
dbGetThread: vi.fn<(threadId: string) => unknown>(() => null),
dbGetThreads: vi.fn<() => unknown[]>(() => []),
Expand Down Expand Up @@ -119,6 +123,9 @@ afterEach(async () => {
vi.mocked(dbGetThreadContextUsage).mockReset().mockReturnValue(null);
vi.mocked(dbGetLatestThreadRuntimeAnchorItemId).mockReset().mockReturnValue(null);
vi.mocked(dbGetThreadRuntimeItems).mockReset().mockReturnValue([]);
vi.mocked(dbGetThreadRuntimeItemsPage)
.mockReset()
.mockReturnValue({ items: [], nextCursor: null });
vi.mocked(dbGetThreadRuntimeSummaries).mockReset().mockReturnValue({});
vi.mocked(dbGetThread).mockReset().mockReturnValue(null);
vi.mocked(dbGetThreads).mockReset().mockReturnValue([]);
Expand Down Expand Up @@ -720,8 +727,14 @@ describe("RemoteAccessServer", () => {
const serviceWorkerResponse = await fetch(new URL("/service-worker.js", info.httpBaseUrl));
expect(serviceWorkerResponse.status).toBe(200);
const serviceWorker = await serviceWorkerResponse.text();
expect(serviceWorker).toContain("poracode-remote-local");
expect(serviceWorker).toContain("poracode-remote-local-1.0.0");
expect(serviceWorker).toContain("caches.delete(LEGACY_CACHE_NAME)");
expect(serviceWorker).toContain("if (response.ok)");
expect(serviceWorker).toContain('url.pathname.startsWith("/assets/")');
expect(serviceWorker).toContain("NAVIGATION_FALLBACK_DELAY_MS = 500");
expect(serviceWorker).toContain('if (request.mode === "navigate")');
expect(serviceWorker).toContain("if (!isAppRequest && !isPwaStaticRequest) return");
expect(serviceWorker).toContain('request.mode === "navigate" ? "/app" : request');

const { ws, ready } = await openPairedSocket(info);
expect(ready).toMatchObject({ type: "ready", seq: 0 });
Expand All @@ -743,6 +756,70 @@ describe("RemoteAccessServer", () => {
ws.close();
});

it("pages remote thread runtime history while preserving the legacy full response", async () => {
const thread = createTestThread({ id: "thread-paged", presentationMode: "gui" });
const fullItems = [
{
id: "old",
type: "assistant_message",
state: "completed" as const,
payload: {},
streams: {},
},
{
id: "tail",
type: "assistant_message",
state: "completed" as const,
payload: {},
streams: {},
},
];
const tailPage = { items: [fullItems[1]!], nextCursor: 41 };
vi.mocked(dbGetThread).mockReturnValue(thread);
vi.mocked(dbGetThreadRuntimeItems).mockReturnValue(fullItems);
vi.mocked(dbGetThreadRuntimeItemsPage).mockReturnValue(tailPage);

const server = new RemoteAccessServer({
appVersion: "1.0.0",
identity: { desktopId: "desktop-test", label: "Test Desktop" },
host: "127.0.0.1",
port: 0,
callSupervisor: async () => null as never,
});
servers.push(server);
const info = await server.start();
const token = await issueAccessToken(info, ["session:read"]);
const headers = { authorization: `Bearer ${token}` };

const legacyResponse = await fetch(
new URL("/api/threads/thread-paged/history", info.httpBaseUrl),
{ headers },
);
expect(legacyResponse.status).toBe(200);
await expect(legacyResponse.json()).resolves.toMatchObject({ runtimeItems: fullItems });

const tailResponse = await fetch(
new URL("/api/threads/thread-paged/history?runtimePage=1", info.httpBaseUrl),
{ headers },
);
expect(tailResponse.status).toBe(200);
await expect(tailResponse.json()).resolves.toMatchObject({
runtimeItems: tailPage.items,
runtimeNextCursor: 41,
});

const olderResponse = await fetch(
new URL(
"/api/threads/thread-paged/history/items?beforePosition=41&limit=500&targetTimelineEntryCount=40",
info.httpBaseUrl,
),
{ headers },
);
expect(olderResponse.status).toBe(200);
await expect(olderResponse.json()).resolves.toEqual(tailPage);
expect(dbGetThreadRuntimeItemsPage).toHaveBeenLastCalledWith("thread-paged", 41, 500, 40);
});

it("builds shell snapshots from aggregated runtime summaries", async () => {
vi.mocked(dbGetProjects).mockReturnValue([
createTestProject({
Expand Down
Loading