xcpack
v0.1.0
Published
Native project wiring for Catalyst and AI-powered Expo apps
Readme
xcpack
Plugin
Add xcpack to your Expo config:
export default {
expo: {
plugins: [
[
"xcpack",
{
catalyst: true,
ai: {
image: true,
},
},
],
],
},
};When you run xcpack prebuild or xcpack run:catalyst, xcpack also injects this script into the app's package.json:
{
"scripts": {
"catalyst": "xcpack run:catalyst"
}
}xcpack run:catalyst reuses the existing Catalyst app build when native/prebuild-relevant inputs have not changed. It rebuilds automatically when files under ios/ or Expo config files like app.json, app.config.*, or package.json change. Pass --clean or -c to always force a fresh rebuild.
Options
Current plugin shape:
xcpack({
catalyst: true | {
toolbarStyle?: "automatic" | "unified" | "unifiedCompact" | "preference" | "expanded"
titleVisibility?: "visible" | "hidden"
fs?: true | {}
},
ai?: {
image?: true | {
appGroupIdentifier?: string
}
}
})Examples:
xcpack({
catalyst: true,
})xcpack({
catalyst: {
toolbarStyle: "unified",
titleVisibility: "hidden",
},
})xcpack({
catalyst: {
fs: true,
},
})xcpack({
ai: {
image: true,
},
})API
Current public AI entrypoints:
import * as ai from "xcpack/ai";
import * as image from "xcpack/ai/image";
import * as runtime from "xcpack/ai/runtime";Mac Catalyst toolbar entrypoints:
import { Toolbar, Window, navigation, useNavigation, useToolbar } from "xcpack";Toolbar items can be configured through the direct API, hooks, or the declarative component API.
Toolbar.Menu uses an icon-only toolbar control. Provide an explicit icon; the label is still required for semantics and menu presentation.
Direct API example:
await navigation.setToolbarItems([
{
id: "filters",
kind: "menu",
icon: "line.3.horizontal.decrease.circle",
label: "All Types",
items: [
{ id: "all", label: "All Types" },
{ id: "feature", label: "Feature" },
{ id: "bug", label: "Bug" },
],
},
{
id: "status",
kind: "group",
selectionMode: "selectOne",
selectedIndex: 0,
items: [
{ id: "open", label: "Open" },
{ id: "closed", label: "Closed" },
],
},
]);Component API example:
<Window title="Issues" toolbarStyle="unified" titleVisibility="visible" />
<Toolbar>
<Toolbar.Menu
id="type-filter"
icon="line.3.horizontal.decrease.circle"
label="All Types"
items={[
{ id: "all", label: "All Types", onPress: () => setType("all") },
{ id: "feature", label: "Feature", onPress: () => setType("feature") },
{ id: "bug", label: "Bug", onPress: () => setType("bug") },
]}
/>
<Toolbar.Group
id="status-filter"
selectionMode="selectOne"
selectedIndex={0}
items={[
{ id: "open", label: "Open" },
{ id: "closed", label: "Closed" },
]}
onChange={(event) => setStatus(event.itemId ?? "open")}
/>
</Toolbar>Supported Catalyst toolbar item kinds:
buttonspaceflexibleSpacemenugroup
Image generation uses the ai.image surface:
import { load, adapter, assets, registry, flux, zImage } from "xcpack/ai/image";Load image runtimes through model-specific helpers:
const zImageRuntime = await load(zImage.load.turbo());
const fluxRuntime = await load(
flux.load.klein9B(
flux.models.klein9B.default,
flux.textEncoders.klein9B.default,
),
);
await zImageRuntime.generate(
zImage.generate.turbo(zImage.models.turbo.default, {
prompt: "An editorial beach portrait at sunset",
enhancePrompt: true,
}),
);
await fluxRuntime.generate(
flux.generate.klein9B(flux.models.klein9B.default, {
prompt: "Restyle this portrait as a cinematic studio photo",
sourceImage: "file:///tmp/source.png",
strength: 0.65,
}),
);
await fluxRuntime.generate(
flux.generate.klein9B(flux.models.klein9B.default, {
prompt: "Design language inspired by these references",
referenceImages: ["/tmp/ref-a.png", "/tmp/ref-b.png"],
}),
);
for await (const event of zImageRuntime.stream(
zImage.stream.turbo(zImage.models.turbo.default, {
prompt: "An editorial beach portrait at sunset",
guidanceScale: 0,
enhancePrompt: true,
}),
)) {
// handle progress / previews / result
}On devices with limited GPU memory (~4 GB), pass memoryProfile: "lowVRAM4GB" to reduce peak VRAM usage:
await zImageRuntime.generate(
zImage.generate.turbo(zImage.models.turbo.default, {
prompt: "An editorial beach portrait at sunset",
memoryProfile: "lowVRAM4GB",
}),
);When memoryProfile: "lowVRAM4GB" is set:
- The model is automatically switched to
zimage-turbo-8bitunless an explicit model was provided. - Resolution defaults to
768×768instead of1024×1024. - One-shot residency is forced — the transformer and text encoder are evicted from memory immediately after use.
- Prompt enhancement token budget is capped at 128 (down from 512) if
enhancePromptis enabled. - Generation preset uses 9 steps, guidance 0.0, and a max sequence length of 256.
Notes:
- Flux models require both a model id and a text encoder id.
- Z-Image models do not use external text encoders.
flux.models,flux.textEncoders, andzImage.modelsexpose slug-safe constants.- Model and text-encoder families expose
.defaultplus.allliteral tuples. - Flux img2img accepts
sourceImage?: string,referenceImages?: [string] | [string, string] | [string, string, string], andstrength?: number. - Flux keeps
sourceImageseparate fromreferenceImages; the source image is not counted against the 3-reference limit. - Flux
strengthmust be between0and1. - Flux image inputs can be plain local file paths or
file://URIs. - Z-Image-Turbo behaves best close to its intended no-CFG regime. In practice, prefer
guidanceScale: 0andenhancePrompt: truebefore increasing CFG. - Do not assume Flux and Z-Image should share the same guidance defaults. Flux often benefits from positive guidance values; Z-Image-Turbo can become noticeably softer when forced into Flux-style CFG settings.
The ai.image adapter still accepts options.model, and Flux adapter calls must also provide options.textEncoderId:
await adapter.generate({
prompt: "A silver robot in fog",
options: {
model: flux.models.klein9B.default,
textEncoderId: flux.textEncoders.klein9B.default,
},
});Asset downloads are handle-based and cancellable:
const download = await assets.startDownload(zImage.load.turbo());
const fluxDownload = await assets.startDownload(
flux.load.klein4B(
flux.models.klein4B.default,
flux.textEncoders.klein4B.default,
),
);
await assets.cancelDownload(download.handle, {
modelId: zImage.models.turbo.default,
deleteSharedAssets: false,
});Notes:
- Only one active download is allowed per
modelId. - Calling
assets.startDownload(modelId)again while that model is already downloading returns the existing handle. - Flux callers can opt into a specific encoder by passing the existing helper output, for example
assets.startDownload(flux.load.klein4B(...)); if omitted, xcpack keeps its own default encoder choice. assets.status()includesactiveDownloads, so the current process can recover active handles if UI state is lost.- Download handles are process-local and should not be treated as persistent across app restarts.
deleteSharedAssetsonly affects shared assets created by the cancelled download. Pre-existing assets are never deleted.cancelDownload(...)is model-aware: if the provided handle is stale but the model still has one active download, xcpack cancels that active download.- Cancelling a stale or already-finished download is a no-op and returns the current asset status.
Multimodal generation uses the ai.multimodal surface:
import { multimodal } from "xcpack/ai/multimodal";
const runtime = await multimodal.load.qwen35();
const result = await runtime.generate(
multimodal.generate.qwen35("qwen35-0.8b-abliterated-mxfp8", {
prompt: "Describe this image in detail",
images: ["/tmp/photo.png"],
maxOutputTokens: 256,
}),
);
for await (const event of runtime.stream(
multimodal.stream.qwen35("qwen35-0.8b-abliterated-mxfp8", {
prompt: "Compare these images",
images: ["/tmp/a.png", "/tmp/b.png"],
}),
)) {
// handle token chunks / completion info
}Notes:
multimodal.load.qwen35()currently defaults to theqwen35-0.8b-abliterated-mxfp8variant slug.multimodal.load.qwen3VL()currently defaults to theqwen3vl-2b-instruct-abliterated-4bitvariant slug.multimodal.models.qwen35andmultimodal.models.qwen3VLmap variant slugs to repo ids.multimodal.assetsandmultimodal.registryexpose the same download/inspection surface shape asai.image, backed by a simpler shared core-model asset layer.- Plain text generation is just the no-image case of the same API.
- Adapter usage still takes
options.model:
import { generate } from "xcpack/ai";
import { adapter, multimodal } from "xcpack/ai/multimodal";
await generate({
adapter,
input: {
...multimodal.generate.qwen35("qwen35-0.8b-abliterated-mxfp8", {
prompt: "Summarize this screenshot",
images: ["/tmp/screenshot.png"],
}),
options: { model: multimodal.models.qwen35.default },
},
});Text generation uses the ai.text surface:
import { text } from "xcpack/ai/text";
const runtime = await text.load.qwen3();
const gemma = await text.load.gemma3();
const result = await runtime.generate(
text.generate.qwen3("qwen3-0.6b-abliterated-8bit", {
system: "Be concise.",
prompt: "Write a haiku about fog.",
maxOutputTokens: 128,
}),
);
for await (const event of runtime.stream(
text.stream.qwen3("qwen3-0.6b-abliterated-8bit", {
prompt: "Write a tagline for a camera app.",
maxOutputTokens: 32,
}),
)) {
// handle token chunks / completion info
}
const gemmaResult = await gemma.generate(
text.generate.gemma3("gemma3-270m-it-abliterated-8bit", {
prompt: "Write a three-word product tagline.",
maxOutputTokens: 24,
}),
);Notes:
text.load.qwen3()currently defaults to theqwen3-0.6b-abliterated-8bitvariant slug.text.load.gemma3()currently defaults to thegemma3-270m-it-abliterated-8bitvariant slug.text.models.qwen3andtext.models.gemma3map variant slugs to the underlying repo ids.text.assetsandtext.registryexpose the same download/inspection surface shape asai.image.- Adapter usage takes
options.modelin the same way asai.multimodal.
The generic model runtime uses the neutral runtime surface:
import { runtime, Model, ModelContainer, PreparedInput, Tokenizer } from "xcpack";