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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ Native, SwiftUI, and other native mobile projects.
**[Add Mobile Dev to Codex](https://chatgpt.com/plugins/plugins_6ac4c117f76881918d3676e851fa4005)**
from the plugin directory, then open a new chat.

To install the prebuilt plugin from this repository instead, see
[Install from the GitHub marketplace](CONTRIBUTING.md#install-from-the-github-marketplace).
Mobile Dev checks the version published in the plugin store. When a newer version
is available, click **Open plugin store** to update there, then fully quit and
reopen Codex.

Existing GitHub installations offer **Install from plugin store** once version
0.1.167 or newer is published there. Install and enable the store version, return
to Mobile Dev, and click **Finish migration**. This verifies the store package,
removes the GitHub plugin and its marketplace, and preserves local recordings and
saved data. Fully quit and reopen Codex afterward. The migration is offered only
when the store version is at least as new as the running GitHub version.

### Claude Desktop: manual installation

Expand Down
2 changes: 1 addition & 1 deletion apps/codex-plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"demo_recording_url": "https://github.com/raw/callstackincubator/mobile-dev/main/docs/review/media/mobile-dev-demo.mp4"
},
"publication": {
"release_notes": "Adds product-specific privacy, terms and support links, three starter prompts, and reproducible review cases. Corrects TraceSift write/service annotations. Filters storage secrets and binary values before host delivery, scopes reads before decoding, and adds exact key/column selection."
"release_notes": "Checks updates against the published plugin-store version and opens the store to update. Adds a version-gated migration that verifies the enabled store installation before removing the GitHub plugin and marketplace, preserving local recordings and saved data. Reduces Sentry reporting volume and excludes expected failures."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion apps/codex-plugin/server/codex-updates.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ServerFeature } from "@mobile-dev/core/server-feature.ts";
import { PluginUpdates, registerPluginUpdateTools } from "./plugin-updates.ts";

/** Checks for and installs releases through Codex's plugin marketplace; the panel's banner calls these tools. */
/** Checks store releases and finishes migration from GitHub installations through the panel banner. */
export const codexUpdates: ServerFeature = server => {
const updates = new PluginUpdates();
registerPluginUpdateTools(server, updates);
Expand Down
333 changes: 233 additions & 100 deletions apps/codex-plugin/server/plugin-updates.ts

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions apps/codex-plugin/shared/plugin-updates.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { z } from "zod";

export const PLUGIN_STORE_ID = "plugins_6ac4c117f76881918d3676e851fa4005";
export const PLUGIN_STORE_URL = `https://chatgpt.com/plugins/${PLUGIN_STORE_ID}`;
export const STORE_MIGRATION_VERSION = "0.1.167";

const versionTextSchema = z.string();
export const releaseVersionSchema = versionTextSchema.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
const statusSchema = z.enum(["disabled", "unavailable", "current", "available", "updated"]);
const statusSchema = z.enum(["disabled", "unavailable", "current", "available", "migration", "migrated"]);
const optionalVersionSchema = releaseVersionSchema.optional();
const booleanSchema = z.boolean();
const optionalBooleanSchema = booleanSchema.optional();
export const pluginUpdateSchema = z.object({
status: statusSchema,
currentVersion: releaseVersionSchema,
latestVersion: optionalVersionSchema,
storeInstalled: optionalBooleanSchema,
});
export type PluginUpdate = z.infer<typeof pluginUpdateSchema>;

Expand All @@ -21,7 +28,3 @@ export function compareReleaseVersions(left: string, right: string): number {
}
return 0;
}

export function pluginReleaseUrl(version: string): string {
return `https://github.com/callstackincubator/mobile-dev/releases/tag/v${version}`;
}
86 changes: 65 additions & 21 deletions apps/codex-plugin/test/plugin-update-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { build } from "esbuild";
import { JSDOM } from "jsdom";

const update = { status: "available", currentVersion: "0.1.131", latestVersion: "0.1.132" };
const update = { status: "available", currentVersion: "0.1.166", latestVersion: "0.1.167" };

async function banner(t: test.TestContext, app: unknown) {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test" });
Expand Down Expand Up @@ -38,43 +38,66 @@ async function banner(t: test.TestContext, app: unknown) {
return { dom, ...mounted, settle, button };
}

test("the banner handles progress, failures, retry, release notes, and restart instructions", async t => {
let installs = 0;
test("store updates open the exact listing without installing through GitHub", async t => {
const links: string[] = [];
const tools: string[] = [];
const app = {
async callServerTool({ name }: { name: string }) {
tools.push(name);
return { content: [], structuredContent: { update } };
},
async openLink({ url }: { url: string }) { links.push(url); return {}; },
};
const view = await banner(t, app);
view.controller.setAvailable(true);
await view.settle();
assert.match(view.dom.window.document.body.textContent!, /0.1.167 is available in the plugin store/);
const open = view.button("Open plugin store");
open.click();
await view.settle();
assert.deepEqual(links, ["https://chatgpt.com/plugins/plugins_6ac4c117f76881918d3676e851fa4005"]);
assert.deepEqual(tools, ["mobile_check_plugin_update"]);
});

test("migration installs through the store before cleanup and handles progress, retry and restart", async t => {
let migrations = 0;
let finish: (value: unknown) => void = () => {};
const links: string[] = [];
const migration = { ...update, status: "migration", storeInstalled: false };
const app = {
async callServerTool({ name }: { name: string }) {
if (name === "mobile_check_plugin_update") return { content: [], structuredContent: { update } };
installs++;
if (name === "mobile_check_plugin_update") return { content: [], structuredContent: { update: migration } };
assert.equal(name, "mobile_migrate_plugin_to_store");
migrations++;
return new Promise(resolve => { finish = resolve; });
},
async openLink({ url }: { url: string }) { links.push(url); return {}; },
};
const view = await banner(t, app);
view.controller.setAvailable(true);
await view.settle();
assert.match(view.dom.window.document.body.textContent!, /Mobile Dev 0.1.132 is available/);
const releaseNotes = view.button("Release notes");
releaseNotes.click();
const open = view.button("Install from plugin store");
open.click();
await view.settle();
assert.deepEqual(links, ["https://github.com/callstackincubator/mobile-dev/releases/tag/v0.1.132"]);
const install = view.button("Update");
install.click();
install.click();
assert.equal(links.length, 1);
assert.equal(migrations, 0);
const migrate = view.button("Finish migration");
migrate.click();
migrate.click();
await view.settle();
assert.equal(installs, 1);
const updating = view.button("Updating…");
assert.equal(updating.disabled, true);
finish({ isError: true, content: [{ type: "text", text: "Network unavailable" }] });
assert.equal(migrations, 1);
const finishing = view.button("Finishing…");
assert.equal(finishing.disabled, true);
finish({ isError: true, content: [{ type: "text", text: "Install and enable the store version first" }] });
await view.settle();
assert.match(view.dom.window.document.body.textContent!, /Network unavailable/);
const retry = view.button("Retry update");
assert.match(view.dom.window.document.body.textContent!, /Install and enable the store version first/);
const retry = view.button("Finish migration");
retry.click();
await view.settle();
finish({ content: [], structuredContent: { update: { ...update, status: "updated" } } });
finish({ content: [], structuredContent: { update: { ...migration, status: "migrated", storeInstalled: true } } });
await view.settle();
assert.equal(installs, 2);
assert.match(view.dom.window.document.body.textContent!, /Quit and reopen Codex to use the new version/);
assert.equal(migrations, 2);
assert.match(view.dom.window.document.body.textContent!, /Quit and reopen Codex to use the store version/);
const busy = view.dom.window.document.querySelector('[aria-busy="true"]');
assert.equal(busy, null);
await view.controller.check();
Expand All @@ -85,6 +108,27 @@ test("the banner handles progress, failures, retry, release notes, and restart i
assert.equal(dismissed, null);
});

test("failed store links stay retryable and an installed store copy can go straight to cleanup", async t => {
let available = false;
const app = {
async callServerTool() { return { content: [], structuredContent: { update: { ...update, status: "migration", storeInstalled: available } } }; },
async openLink() { return { isError: true }; },
};
const view = await banner(t, app);
view.controller.setAvailable(true);
await view.settle();
const open = view.button("Install from plugin store");
open.click();
await view.settle();
assert.match(view.dom.window.document.body.textContent!, /Could not open the plugin store/);
assert.equal(view.controller.getSnapshot().storeOpened, undefined);
available = true;
await view.controller.check();
await view.settle();
const finish = view.button("Finish migration");
assert.equal(finish.disabled, false);
});

test("hidden workspaces do not check, dismissed versions stay hidden, and teardown cancels requests", async t => {
let checks = 0;
let signal: AbortSignal | undefined;
Expand Down
Loading
Loading