Introduction
Perry is a native TypeScript compiler that compiles TypeScript source code directly to native executables. No JavaScript runtime, no JIT warmup, no V8 — your TypeScript compiles to a real binary.
// hello.ts
console.log("Hello from Perry!");
$ perry hello.ts -o hello
$ ./hello
Hello from Perry!
Why Perry?
- Native performance — Compiles to machine code via LLVM. Integer-heavy code like Fibonacci runs 2x faster than Node.js.
- Real multi-threading —
parallelMapandspawngive you actual OS threads with compile-time safety. No isolates, no message passing overhead. Something no JS runtime can do. - Small binaries — A hello world is ~300KB. Perry detects what runtime features you use and only links what’s needed.
- Native UI — Build desktop and mobile apps with declarative TypeScript that compiles to real AppKit, UIKit, GTK4, Win32, or DOM widgets.
- 7 targets — macOS, iOS, Android, Windows, Linux, Web, and WebAssembly from the same source code.
- Familiar ecosystem — Use npm packages like
fastify,mysql2,redis,bcrypt,lodash, and more — compiled natively. - Zero config — Point Perry at a
.tsfile and get a binary. Notsconfig.jsonrequired.
What Perry Compiles
Perry supports a practical subset of TypeScript:
- Variables, functions, classes, enums, interfaces
- Async/await, closures, generators
- Destructuring, spread, template literals
- Arrays, Maps, Sets, typed arrays
- Regular expressions, JSON, Promises
- Module imports/exports
- Generic type erasure
See Supported Features for the complete list.
Quick Example: Native App
import { App, Text, Button, VStack, State } from "perry/ui";
const count = State(0);
App({
title: "Counter",
width: 400,
height: 300,
body: VStack(16, [
Text(`Count: ${count.value}`),
Button("Increment", () => count.set(count.value + 1)),
]),
});
$ perry counter.ts -o counter
$ ./counter # Opens a native macOS/Windows/Linux window
This produces a ~3MB native app with real platform widgets — no Electron, no WebView.
How It Works
TypeScript (.ts)
↓ Parse (SWC)
↓ Lower to HIR
↓ Transform (inline, closure conversion, async)
↓ Codegen (LLVM)
↓ Link (system linker)
↓
Native Executable
Perry uses SWC for TypeScript parsing and LLVM for native code generation. Types are erased at compile time (like tsc), and values are represented at runtime using NaN-boxing for efficient 64-bit tagged values.
Next Steps
Installation
Prerequisites
Perry compiles TypeScript to native binaries by linking with your system’s C toolchain, so every install path needs a linker:
- macOS: Xcode Command Line Tools (
xcode-select --install) - Linux:
gccorclang(apt install build-essentialon Debian/Ubuntu,apk add build-baseon Alpine) - Windows: LLVM (
winget install LLVM.LLVM) +perry setup windows(lightweight, ~1.5 GB, no Visual Studio needed), or MSVC Build Tools with the “Desktop development with C++” workload — see the Windows platform guide for both options
The source install additionally needs the Rust toolchain via rustup.
Install Perry
npm / npx (recommended — any platform)
Perry ships as a prebuilt-binary npm package. This is the fastest way to get started and the only path that covers all seven supported platforms (macOS arm64/x64, Linux x64/arm64 glibc + musl, Windows x64) with a single command:
# Project-local (pins Perry's version alongside your deps)
npm install @perryts/perry
npx perry compile src/main.ts -o myapp && ./myapp
# Global
npm install -g @perryts/perry
perry compile src/main.ts -o myapp
# Zero-install, one-shot
npx -y @perryts/perry compile src/main.ts -o myapp
@perryts/perry is a thin launcher; npm automatically picks the matching prebuilt via optionalDependencies (@perryts/perry-darwin-arm64, @perryts/perry-linux-x64-musl, etc.) based on your os / cpu / libc. Requires Node.js ≥ 16.
| Platform | Prebuilt package |
|---|---|
| macOS arm64 (Apple Silicon) | @perryts/perry-darwin-arm64 |
| macOS x64 (Intel) | @perryts/perry-darwin-x64 |
| Linux x64 (glibc) | @perryts/perry-linux-x64 |
| Linux arm64 (glibc) | @perryts/perry-linux-arm64 |
| Linux x64 (musl / Alpine) | @perryts/perry-linux-x64-musl |
| Linux arm64 (musl / Alpine) | @perryts/perry-linux-arm64-musl |
| Windows x64 | @perryts/perry-win32-x64 |
Homebrew (macOS)
brew install perryts/perry/perry
winget (Windows)
winget install PerryTS.Perry
APT (Debian / Ubuntu)
curl -fsSL https://perryts.github.io/perry-apt/perry.gpg.pub | sudo gpg --dearmor -o /usr/share/keyrings/perry.gpg
echo "deb [signed-by=/usr/share/keyrings/perry.gpg] https://perryts.github.io/perry-apt stable main" | sudo tee /etc/apt/sources.list.d/perry.list
sudo apt update && sudo apt install perry
From Source
git clone https://github.com/PerryTS/perry.git
cd perry
cargo build --release
The binary is at target/release/perry. Add it to your PATH:
# Add to ~/.zshrc or ~/.bashrc
export PATH="/path/to/perry/target/release:$PATH"
Self-Update
Once installed, Perry can update itself:
perry update
This downloads the latest release and atomically replaces the binary.
Verify Installation
perry doctor
This checks your installation, shows the current version, and reports if an update is available.
perry --version
Platform-Specific Setup
macOS
No additional setup needed. Perry uses the system cc linker and AppKit for UI apps.
For iOS development, install Xcode (not just Command Line Tools) for the iOS SDK and simulator.
Linux
Install GTK4 development libraries for UI apps:
# Ubuntu/Debian
sudo apt install libgtk-4-dev
# Fedora
sudo dnf install gtk4-devel
Windows
Two toolchain options — pick one. Both produce identical binaries.
Lightweight (recommended, ~1.5 GB, no Visual Studio):
winget install LLVM.LLVM
perry setup windows
perry setup windows downloads the Microsoft CRT + Windows SDK libraries via xwin after prompting for license acceptance. Pass --accept-license to skip the prompt in CI.
MSVC Build Tools (~8 GB):
Install Visual Studio Build Tools with the “Desktop development with C++” workload — via the Visual Studio Installer, or:
winget install Microsoft.VisualStudio.2022.BuildTools --override `
"--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
Run perry doctor to verify the toolchain. See the Windows platform guide for details.
What’s Next
Hello World
Your First Program
Create a file called hello.ts:
// demonstrates: the minimal Perry program in the docs
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows
// targets: wasm, web, android
console.log("Hello, Perry!")
Compile and run it:
perry hello.ts -o hello
./hello
Output:
Hello, Perry!
That’s it. Perry compiled your TypeScript to a native executable — no Node.js, no bundler, no runtime.
A Slightly Bigger Example
// demonstrates: recursive fib as a perf-vs-node talking point
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows
// targets: wasm, web, android
function fibonacci(n: number): number {
if (n <= 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
const start = Date.now()
const result = fibonacci(35)
const elapsed = Date.now() - start
console.log(`fibonacci(35) = ${result}`)
console.log(`Completed in ${elapsed}ms`)
perry fib.ts -o fib
./fib
This runs about 2x faster than Node.js because Perry compiles to native machine code with integer specialization.
Using Variables and Functions
const name: string = "World";
const items: number[] = [1, 2, 3, 4, 5];
const doubled = items.map((x) => x * 2);
const sum = doubled.reduce((acc, x) => acc + x, 0);
console.log(`Hello, ${name}!`);
console.log(`Sum of doubled: ${sum}`);
Async Code
async function fetchData(): Promise<string> {
const response = await fetch("https://httpbin.org/get");
const data = await response.json();
return data.origin;
}
const ip = await fetchData();
console.log(`Your IP: ${ip}`);
perry fetch.ts -o fetch
./fetch
Perry compiles async/await to a native async runtime backed by Tokio.
Multi-Threading
Perry can do something no JavaScript runtime can — run your code on multiple CPU cores:
import { parallelMap, parallelFilter, spawn } from "perry/thread";
const data = [1, 2, 3, 4, 5, 6, 7, 8];
// Process all elements across all CPU cores
const doubled = parallelMap(data, (x) => x * 2);
console.log(doubled); // [2, 4, 6, 8, 10, 12, 14, 16]
// Run heavy work in the background
const result = await spawn(() => {
let sum = 0;
for (let i = 0; i < 100_000_000; i++) sum += i;
return sum;
});
console.log(result);
This is real OS-level parallelism, not web workers or separate isolates. See Multi-Threading for details.
What the Compiler Produces
When you run perry file.ts -o output, Perry:
- Parses your TypeScript with SWC
- Lowers the AST to an intermediate representation (HIR)
- Applies optimizations (inlining, closure conversion, etc.)
- Generates native machine code with LLVM
- Links with your system’s C compiler
The result is a standalone executable with no external dependencies.
Binary Size
| Program | Binary Size |
|---|---|
| Hello world | ~300KB |
| CLI with fs/path | ~3MB |
| UI app | ~3MB |
| Full app with stdlib | ~48MB |
Perry automatically detects which runtime features you use and only links what’s needed.
Next Steps
First Native App
Perry compiles declarative TypeScript UI code to native platform widgets. No Electron, no WebView — real AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows.
A Simple Counter
Create counter.ts:
import { App, Text, Button, VStack, State } from "perry/ui";
const count = State(0);
App({
title: "My Counter",
width: 400,
height: 300,
body: VStack(16, [
Text(`Count: ${count.value}`),
Button("Increment", () => count.set(count.value + 1)),
Button("Reset", () => count.set(0)),
]),
});
Compile and run:
perry counter.ts -o counter
./counter
A native window opens with a label and two buttons. Clicking “Increment” updates the count in real-time.
How It Works
App({ title, width, height, body })— Creates a native application window.bodyis the root widget.State(initialValue)— Creates reactive state..valuereads,.set(v)writes and triggers UI updates.VStack(spacing, [...])— Vertical stack layout (like SwiftUI’s VStack or CSS flexbox column). Spacing arg is optional.Text(string)— A text label. Template literals referencing${state.value}bind reactively.Button(label, onClick)— A native button with a click handler.
A Todo App
import {
App, Text, Button, TextField, VStack, HStack, State, ForEach, Spacer,
} from "perry/ui";
const todos = State<string[]>([]);
const count = State(0); // ForEach iterates by index, so we keep a count in sync
const input = State("");
App({
title: "Todo App",
width: 480,
height: 600,
body: VStack(16, [
HStack(8, [
TextField("Add a todo...", (value: string) => input.set(value)),
Button("Add", () => {
const text = input.value;
if (text.length > 0) {
todos.set([...todos.value, text]);
count.set(count.value + 1);
input.set("");
}
}),
]),
ForEach(count, (i: number) =>
HStack(8, [
Text(todos.value[i]),
Spacer(),
Button("Remove", () => {
todos.set(todos.value.filter((_, idx) => idx !== i));
count.set(count.value - 1);
}),
])
),
]),
});
Cross-Platform
The same code runs on all 6 platforms:
# macOS (default)
perry app.ts -o app
./app
# iOS Simulator
perry app.ts -o app --target ios-simulator
# Web (compiles to WebAssembly + DOM bridge in a self-contained HTML file)
perry app.ts -o app --target web # alias: --target wasm
open app.html
# Other platforms
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android
Each target compiles to the platform’s native widget toolkit. See Platforms for details.
Adding Styling
import { App, Text, Button, VStack, State } from "perry/ui";
const count = State(0);
App("Styled Counter", () => {
const label = Text(`Count: ${count.get()}`);
label.setFontSize(24);
label.setColor("#333333");
const btn = Button("Increment", () => count.set(count.get() + 1));
btn.setCornerRadius(8);
btn.setBackgroundColor("#007AFF");
const stack = VStack([label, btn]);
stack.setPadding(20);
return stack;
});
See Styling for all available style properties.
Next Steps
- Project Configuration — Set up
package.jsonfor Perry projects - UI Overview — Complete guide to Perry’s UI system
- Widgets Reference — All available widgets
- State Management — Reactive state and bindings
Project Configuration
Perry projects use perry.toml and package.json for configuration. No special config file is required for basic usage, but larger projects benefit from Perry-specific settings.
Looking for the full perry.toml reference? See perry.toml Reference for every field, section, platform option, and environment variable.
Basic Setup
perry init my-project
cd my-project
This creates a package.json and a starter src/index.ts.
package.json
{
"name": "my-project",
"version": "1.0.0",
"main": "src/index.ts",
"perry": {
"compilePackages": []
}
}
Perry Configuration
The perry field in package.json controls compiler behavior:
compilePackages
List npm packages to compile natively instead of routing through the JavaScript runtime:
{
"perry": {
"compilePackages": ["@noble/curves", "@noble/hashes"]
}
}
When a package is listed here, Perry:
- Resolves the package in
node_modules/ - Prefers TypeScript source (
src/index.ts) over compiled JavaScript (lib/index.js) - Compiles all functions natively through LLVM
- Deduplicates across nested
node_modules/to prevent duplicate linker symbols
This is useful for pure TypeScript/JavaScript packages that don’t rely on Node.js APIs. Packages that use native bindings, eval(), or dynamic require() won’t work.
splash
Configure a native splash screen for iOS and Android. The splash screen appears instantly during cold start, before your app code runs.
Minimal (both platforms share the same splash):
{
"perry": {
"splash": {
"image": "logo/icon-256.png",
"background": "#FFF5EE"
}
}
}
Per-platform overrides:
{
"perry": {
"splash": {
"image": "logo/icon-256.png",
"background": "#FFF5EE",
"ios": {
"image": "logo/splash-ios.png",
"background": "#FFFFFF"
},
"android": {
"image": "logo/splash-android.png",
"background": "#FFFFFF"
}
}
}
}
Full custom override (complete control):
{
"perry": {
"splash": {
"ios": {
"storyboard": "splash/LaunchScreen.storyboard"
},
"android": {
"layout": "splash/splash_background.xml",
"theme": "splash/themes.xml"
}
}
}
}
| Field | Description |
|---|---|
splash.image | Path to a PNG image, centered on the splash screen (both platforms) |
splash.background | Hex color for the background (default: #FFFFFF) |
splash.ios.image | iOS-specific image override |
splash.ios.background | iOS-specific background color |
splash.ios.storyboard | Custom LaunchScreen.storyboard (compiled with ibtool) |
splash.android.image | Android-specific image override |
splash.android.background | Android-specific background color |
splash.android.layout | Custom drawable XML for windowBackground |
splash.android.theme | Custom themes.xml |
Resolution order per platform:
- Custom file override (storyboard / layout+theme)
- Platform-specific image/color (
splash.{platform}.image) - Universal image/color (
splash.image) - No
splashkey → blank white screen (backward compatible)
Using npm Packages
Perry natively supports many popular npm packages without any configuration:
import fastify from "fastify";
import mysql from "mysql2/promise";
import Redis from "ioredis";
import bcrypt from "bcrypt";
These are compiled to native code using Perry’s built-in implementations. See Standard Library for the full list.
For packages not natively supported, use compilePackages for pure TS/JS packages, or the JavaScript runtime fallback for complex packages.
Project Structure
Perry is flexible about project structure. Common patterns:
my-project/
├── package.json
├── src/
│ └── index.ts
└── node_modules/ # Only needed for compilePackages
For UI apps:
my-app/
├── package.json
├── src/
│ ├── index.ts # Main app entry
│ └── components/ # UI components
└── assets/ # Images, etc.
Compilation
# Compile a file
perry src/index.ts -o build/app
# Compile with a specific target
perry src/index.ts -o build/app --target ios-simulator
# Debug: print intermediate representation
perry src/index.ts --print-hir
See CLI Commands for all options.
Next Steps
- CLI Commands — All compiler commands and flags
- Supported Features — What TypeScript features work
- Standard Library — Supported npm packages
Supported TypeScript Features
Perry compiles a practical subset of TypeScript to native code. This page lists what’s supported.
Primitive Types
const n: number = 42;
const s: string = "hello";
const b: boolean = true;
const u: undefined = undefined;
const nl: null = null;
All primitives are represented as 64-bit NaN-boxed values at runtime.
Variables and Constants
let x = 10;
const y = "immutable";
var z = true; // var is supported but let/const preferred
Perry infers types from initializers — let x = 5 is inferred as number without an explicit annotation.
Functions
function add(a: number, b: number): number {
return a + b;
}
// Optional parameters
function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}
// Rest parameters
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
// Arrow functions
const double = (x: number) => x * 2;
Classes
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
speak(): string {
return `${this.name} makes a noise`;
}
}
class Dog extends Animal {
speak(): string {
return `${this.name} barks`;
}
}
// Static methods
class Counter {
private static instance: Counter;
private count: number = 0;
static getInstance(): Counter {
if (!Counter.instance) {
Counter.instance = new Counter();
}
return Counter.instance;
}
}
Supported class features:
- Constructors
- Instance and static methods
- Instance and static properties
- Inheritance (
extends) - Method overriding
instanceofchecks (via class ID chain)- Singleton patterns (static method return type inference)
Enums
// Numeric enums
enum Direction {
Up,
Down,
Left,
Right,
}
// String enums
enum Color {
Red = "RED",
Green = "GREEN",
Blue = "BLUE",
}
const dir = Direction.Up;
const color = Color.Red;
Enums are compiled to constants and work across modules.
Interfaces and Type Aliases
interface User {
name: string;
age: number;
email?: string;
}
type Point = { x: number; y: number };
type StringOrNumber = string | number;
type Callback = (value: number) => void;
Interfaces and type aliases are erased at compile time (like tsc). They exist only for documentation and editor tooling.
Arrays
const nums: number[] = [1, 2, 3];
// Array methods
nums.push(4);
nums.pop();
const len = nums.length;
const doubled = nums.map((x) => x * 2);
const filtered = nums.filter((x) => x > 2);
const sum = nums.reduce((acc, x) => acc + x, 0);
const found = nums.find((x) => x === 3);
const idx = nums.indexOf(3);
const joined = nums.join(", ");
const sliced = nums.slice(1, 3);
nums.splice(1, 1);
nums.unshift(0);
const sorted = nums.sort((a, b) => a - b);
const reversed = nums.reverse();
const includes = nums.includes(3);
const every = nums.every((x) => x > 0);
const some = nums.some((x) => x > 2);
nums.forEach((x) => console.log(x));
const flat = [[1, 2], [3]].flat();
const concatted = nums.concat([5, 6]);
// Array.from
const arr = Array.from(someIterable);
// Array.isArray
if (Array.isArray(value)) { /* ... */ }
// for...of iteration
for (const item of nums) {
console.log(item);
}
Objects
const obj = { name: "Perry", version: 1 };
obj.name = "Perry 2";
// Dynamic property access
const key = "name";
const val = obj[key];
// Object.keys, Object.values, Object.entries
const keys = Object.keys(obj);
const values = Object.values(obj);
const entries = Object.entries(obj);
// Spread
const copy = { ...obj, extra: true };
// delete
delete obj[key];
Destructuring
// Array destructuring
const [a, b, ...rest] = [1, 2, 3, 4, 5];
// Object destructuring
const { name, age, email = "none" } = user;
// Rename
const { name: userName } = user;
// Rest pattern
const { id, ...remaining } = obj;
// Function parameter destructuring
function process({ name, age }: User) {
console.log(name, age);
}
Template Literals
const name = "world";
const greeting = `Hello, ${name}!`;
const multiline = `
Line 1
Line 2
`;
const expr = `Result: ${1 + 2}`;
Spread and Rest
// Array spread
const combined = [...arr1, ...arr2];
// Object spread
const merged = { ...defaults, ...overrides };
// Rest parameters
function log(...args: any[]) { /* ... */ }
Closures
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
get: () => count,
};
}
const counter = makeCounter();
counter.increment();
console.log(counter.get()); // 1
Perry performs closure conversion — captured variables are stored in heap-allocated closure objects.
Async/Await
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return await response.json();
}
// Top-level await
const data = await fetchUser(1);
Perry compiles async functions to a state machine backed by Tokio’s async runtime.
Promises
const p = new Promise<number>((resolve, reject) => {
resolve(42);
});
p.then((value) => console.log(value));
// Promise.all
const results = await Promise.all([fetch(url1), fetch(url2)]);
Generators
function* range(start: number, end: number) {
for (let i = start; i < end; i++) {
yield i;
}
}
for (const n of range(0, 10)) {
console.log(n);
}
Map and Set
const map = new Map<string, number>();
map.set("a", 1);
map.get("a");
map.has("a");
map.delete("a");
map.size;
const set = new Set<number>();
set.add(1);
set.has(1);
set.delete(1);
set.size;
Regular Expressions
const re = /hello\s+(\w+)/;
const match = "hello world".match(re);
if (re.test("hello perry")) {
console.log("Matched!");
}
const replaced = "hello world".replace(/world/, "perry");
Error Handling
try {
throw new Error("something went wrong");
} catch (e) {
console.log(e.message);
} finally {
console.log("cleanup");
}
JSON
const obj = JSON.parse('{"key": "value"}');
const str = JSON.stringify(obj);
const pretty = JSON.stringify(obj, null, 2);
typeof and instanceof
if (typeof x === "string") {
console.log(x.length);
}
if (obj instanceof Dog) {
obj.speak();
}
typeof checks NaN-boxing tags at runtime. instanceof walks the class ID chain.
Modules
// Named exports
export function helper() { /* ... */ }
export const VALUE = 42;
// Default export
export default class MyClass { /* ... */ }
// Import
import MyClass, { helper, VALUE } from "./module";
import * as utils from "./utils";
// Re-exports
export { helper } from "./module";
BigInt
const big = BigInt(9007199254740991);
const result = big + BigInt(1);
// Bitwise operations
const and = big & BigInt(0xFF);
const or = big | BigInt(0xFF);
const xor = big ^ BigInt(0xFF);
const shl = big << BigInt(2);
const shr = big >> BigInt(2);
const not = ~big;
String Methods
const s = "Hello, World!";
s.length;
s.toUpperCase();
s.toLowerCase();
s.trim();
s.split(", ");
s.includes("World");
s.startsWith("Hello");
s.endsWith("!");
s.indexOf("World");
s.slice(0, 5);
s.substring(0, 5);
s.replace("World", "Perry");
s.repeat(3);
s.charAt(0);
s.padStart(20);
s.padEnd(20);
Math
Math.floor(3.7);
Math.ceil(3.2);
Math.round(3.5);
Math.abs(-5);
Math.max(1, 2, 3);
Math.min(1, 2, 3);
Math.sqrt(16);
Math.pow(2, 10);
Math.random();
Math.PI;
Math.E;
Math.log(10);
Math.sin(0);
Math.cos(0);
Date
const now = Date.now();
const d = new Date();
d.getTime();
d.toISOString();
Console
console.log("message");
console.error("error");
console.warn("warning");
console.time("label");
console.timeEnd("label");
Garbage Collection
Perry includes a mark-sweep garbage collector. It runs automatically when memory pressure is detected (~8MB arena blocks), but you can also trigger it manually:
gc(); // Explicit garbage collection
The GC uses conservative stack scanning to find roots and supports arena-allocated objects (arrays, objects) and malloc-allocated objects (strings, closures, promises, BigInts, errors).
JSX/TSX
Perry supports JSX syntax for UI component composition:
// Component functions
function Greeting({ name }: { name: string }) {
return <Text>{`Hello, ${name}!`}</Text>;
}
// JSX elements
<Button onClick={() => console.log("clicked")}>Click me</Button>
// Fragments
<>
<Text>Line 1</Text>
<Text>Line 2</Text>
</>
// Spread props
<Component {...props} extra="value" />
// Conditional rendering
{condition ? <Text>Yes</Text> : <Text>No</Text>}
JSX elements are transformed to function calls via the jsx()/jsxs() runtime.
Next Steps
- Type System — Type inference and checking
- Limitations — What’s not supported yet
Type System
Perry erases types at compile time, similar to how tsc removes type annotations when emitting JavaScript. However, Perry also performs type inference to generate efficient native code.
Type Inference
Perry infers types from expressions without requiring annotations:
let x = 5; // inferred as number
let s = "hello"; // inferred as string
let b = true; // inferred as boolean
let arr = [1, 2, 3]; // inferred as number[]
Inference works through:
- Literal values:
5→number,"hi"→string - Binary operations:
a + bwhere both are numbers →number - Variable propagation: if
xisnumber, thenlet y = xisnumber - Method returns:
"hello".trim()→string,[1,2].length→number - Function returns: user-defined function return types are propagated to callers
function double(n: number): number {
return n * 2;
}
let result = double(5); // inferred as number
Type Annotations
Standard TypeScript annotations work:
let name: string = "Perry";
let count: number = 0;
let items: string[] = [];
function greet(name: string): string {
return `Hello, ${name}`;
}
interface Config {
port: number;
host: string;
}
Utility Types
Common TypeScript utility types are erased at compile time (they don’t affect code generation):
type Partial<T> = { [P in keyof T]?: T[P] };
type Pick<T, K> = { [P in K]: T[P] };
type Record<K, V> = { [P in K]: V };
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
type ReturnType<T> = /* ... */;
type Readonly<T> = { readonly [P in keyof T]: T[P] };
These are all recognized and erased — they won’t cause compilation errors.
Generics
Generic type parameters are erased:
function identity<T>(value: T): T {
return value;
}
class Box<T> {
value: T;
constructor(value: T) {
this.value = value;
}
}
const box = new Box<number>(42);
At runtime, all values are NaN-boxed — the generic parameter doesn’t affect code generation.
Type Checking with --type-check
For stricter type checking, Perry can integrate with Microsoft’s TypeScript checker:
perry file.ts --type-check
This resolves cross-file types, interfaces, and generics via an IPC protocol. It falls back gracefully if the type checker is not installed.
Without --type-check, Perry relies on its own inference engine, which handles common patterns but doesn’t perform full TypeScript type checking.
Union and Intersection Types
Union types are recognized syntactically but don’t affect code generation:
type StringOrNumber = string | number;
function process(value: StringOrNumber) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value + 1);
}
}
Use typeof checks for runtime type narrowing.
Type Guards
function isString(value: any): value is string {
return typeof value === "string";
}
if (isString(x)) {
console.log(x.toUpperCase());
}
The value is string annotation is erased, but the typeof check works at runtime.
Next Steps
- Supported Features — Complete feature list
- Limitations — What’s not supported
Limitations
Perry compiles a practical subset of TypeScript. This page documents what’s not supported or works differently from Node.js/tsc.
No Runtime Type Checking
Types are erased at compile time. There is no runtime type system — Perry doesn’t generate type guards or runtime type metadata.
// These annotations are erased — no runtime effect
const x: number = someFunction(); // No runtime check that result is actually a number
Use explicit typeof checks where runtime type discrimination is needed.
No eval() or Dynamic Code
Perry compiles to native code ahead of time. Dynamic code execution is not possible:
// Not supported
eval("console.log('hi')");
new Function("return 42");
No Decorators
TypeScript decorators are not currently supported:
// Not supported
@Component
class MyClass {}
No Reflection
There is no Reflect API or runtime type metadata:
// Not supported
Reflect.getMetadata("design:type", target, key);
No Dynamic require()
Only static imports are supported:
// Supported
import { foo } from "./module";
// Not supported
const mod = require("./module");
const mod = await import("./module");
No Prototype Manipulation
Perry compiles classes to fixed structures. Dynamic prototype modification is not supported:
// Not supported
MyClass.prototype.newMethod = function() {};
Object.setPrototypeOf(obj, proto);
No Symbol Type
The Symbol primitive type is not currently supported:
// Not supported
const sym = Symbol("description");
No WeakMap/WeakRef
Weak references are not implemented:
// Not supported
const wm = new WeakMap();
const wr = new WeakRef(obj);
No Proxy
The Proxy object is not supported:
// Not supported
const proxy = new Proxy(target, handler);
Limited Error Types
Error and basic throw/catch work, but custom error subclasses have limited support:
// Works
throw new Error("message");
// Limited
class CustomError extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
}
}
Threading Model
Perry supports real multi-threading via parallelMap and spawn from perry/thread. See Multi-Threading.
Threads do not share mutable state — closures passed to thread primitives cannot capture mutable variables (enforced at compile time). Values are deep-copied across thread boundaries. There is no SharedArrayBuffer or Atomics.
No Computed Property Names
Dynamic property keys in object literals are limited:
// Supported
const key = "name";
obj[key] = "value";
// Not supported
const obj = { [key]: "value" };
npm Package Compatibility
Not all npm packages work with Perry:
- Natively supported: ~50 popular packages (fastify, mysql2, redis, etc.) — these are compiled natively. See Standard Library.
compilePackages: Pure TS/JS packages can be compiled natively via configuration.- Not supported: Packages requiring native addons (
.nodefiles),eval(), dynamicrequire(), or Node.js internals.
Workarounds
Dynamic Behavior
For cases where you need dynamic behavior, use the JavaScript runtime fallback:
import { jsEval } from "perry/jsruntime";
// Routes specific code through QuickJS for dynamic evaluation
Type Narrowing
Since there’s no runtime type checking, use explicit checks:
// Instead of relying on type narrowing from generics
if (typeof value === "string") {
// String path
} else if (typeof value === "number") {
// Number path
}
Next Steps
- Supported Features — What does work
- Type System — How types are handled
Porting npm Packages
Status: experimental. This guide — and the
port-npm-to-perryskill that ships alongside it — is a first pass at systematizing what Perry contributors have been doing ad-hoc. Results will vary by package. Feedback at issue #115.
Perry compiles a practical subset of TypeScript. Most pure TS/JS packages can be pulled into a native compile via perry.compilePackages, but some will need small patches to avoid the constructs Perry doesn’t support. This page is a field guide for doing that port — by hand, or by driving a coding agent with the prompt template below.
When porting makes sense
| Situation | Try this first |
|---|---|
Package uses native addons (.node files, binding.gyp, node-gyp) | Don’t port — no path forward. Find an alternative package or use the QuickJS fallback. |
| Package is pure TS/JS with only light use of dynamic features | Good candidate. Add to compilePackages, patch whatever trips the compiler. |
Package’s core API is built on Proxy (ORMs, validation DSLs, reactive stores) | Probably not portable. The surface Perry-users touch is the Proxy. |
Package is pure TS/JS but uses lookbehind regex, Symbol, WeakMap, etc. | Patchable. See Common gaps below. |
The workflow
1. Add it to compilePackages
In your project’s package.json:
{
"perry": {
"compilePackages": ["@noble/curves", "@noble/hashes"]
}
}
This is what tells Perry to pull the package into the native compile instead of routing it through a JavaScript runtime. See Project Configuration for the full semantics — including how first-resolved directories get cached so transitive copies dedup.
2. Try compiling
perry compile src/main.ts -o /tmp/port-test && /tmp/port-test
Most of the time this is where you find out what’s actually broken. Compile-time errors cite a file:line in the package — that’s your patch list.
3. Patch the gaps
See Common gaps for the typical fixes. Keep patches minimal and localized — the goal is a clean compile, not a refactor.
Record each patch in a file at your project root (convention: perry-patches/<package>.md) so you can reapply them after npm install blows them away. Until compilePackages grows a native patch-file convention, this is the one bit of maintenance overhead.
4. Re-check after each compile
Iterate: compile, patch the next error, compile again. Don’t try to catch everything in a single pass — some errors only surface after earlier ones are fixed.
Common gaps
Perry’s full limitations list is the canonical reference. In practice, these are the ones you hit when porting:
Lookbehind regex
Perry uses Rust’s regex crate, which doesn’t support lookbehind ((?<=…) / (?<!…)).
// Not supported
str.match(/(?<=prefix)\w+/);
// Rewrite — capture the prefix and slice
const m = str.match(/prefix(\w+)/);
const rest = m ? m[1] : null;
Symbol
Not supported as a primitive. When a package uses Symbol as a sentinel (the common case — e.g., for unique keys in a registry), swap for a string:
// Before
const REGISTRY_KEY = Symbol("registry");
// After
const REGISTRY_KEY = "__pkg_registry__";
When Symbol is used to implement Symbol.iterator/Symbol.asyncIterator, check whether the iteration is actually reached in your use case — often the class has a for-loop method alongside the iterator and you can ignore the iterator path.
Proxy, Reflect
Not supported. These are usually load-bearing for the package’s public API, so porting is often not feasible. If the Proxy is only in an optional path (e.g., dev-mode warnings), delete that branch.
WeakMap / WeakRef / FinalizationRegistry
Not implemented. Swap WeakMap for a regular Map if the GC semantics aren’t critical for correctness (most caches can tolerate this — they’ll just hold references slightly longer).
Decorators
// Not supported
@Component
class Foo {}
// Remove the decorator and inline the behavior, or use a factory function
const Foo = Component(class Foo {});
Dynamic require() / await import(…)
Perry only supports static imports. If a package branches on typeof require !== "undefined" for a Node/browser split, pick the branch that works natively and delete the other.
Prototype manipulation
// Not supported
Object.setPrototypeOf(obj, proto);
MyClass.prototype.newMethod = function() {};
Usually appears in fallback shims for older runtimes. Often dead code in the Perry path — just delete it.
Computed property keys in object literals
// Not supported
const obj = { [key]: value };
// Rewrite
const obj: Record<string, V> = {};
obj[key] = value;
Using a coding agent
A general coding agent (Claude Code, Cursor, Codex, Aider) can drive most of this workflow. If you’re using a skill-aware agent, invoke the port-npm-to-perry skill directly. Otherwise, paste this prompt:
I want to port the npm package <NAME> to run under Perry
(https://github.com/PerryTS/perry). Perry compiles a subset of TypeScript
natively; the subset's gaps are documented at
https://github.com/PerryTS/perry/blob/main/docs/src/language/limitations.md.
Please:
1. Read the package at node_modules/<NAME>/. Check package.json for
native addons (binding.gyp, gypfile, prebuilds/ — stop if present).
2. Scan for unsupported constructs: eval, new Function, dynamic require,
Symbol, Proxy, WeakMap, WeakRef, Reflect, decorators, lookbehind
regex (?<= / ?<!), Object.setPrototypeOf, computed property keys.
3. Report a triage: what rules the package out vs. what's patchable.
4. If patchable: add the package to perry.compilePackages in
package.json, apply minimal localized patches, and record each
patch in perry-patches/<NAME>.md.
5. Verify by running `perry compile` against a small file that imports
the package.
Don't patch blindly — a grep hit inside a string or comment isn't real.
Show me the triage before applying substantial patches.
This is intentionally an agent-agnostic prompt — it’ll work with any competent coding agent. The skill version bundles the same instructions with richer context and is auto-discovered by Claude Code.
Giving feedback
This whole workflow is experimental. If a port fails in a way that feels like Perry should handle it — or if the guide misses a common gap — please comment on issue #115 so we can iterate.
Multi-Threading
Perry gives you real OS threads with a one-line API. No worker setup, no message ports, no structured clone overhead. Just parallelMap, parallelFilter, and spawn.
import { parallelMap, parallelFilter, spawn } from "perry/thread";
// Process a million items across all CPU cores
const results = parallelMap(data, (item) => heavyComputation(item));
// Filter a large dataset in parallel
const valid = parallelFilter(records, (r) => r.score > threshold);
// Run expensive work in the background
const answer = await spawn(() => computeHash(largeFile));
This is something no JavaScript runtime can do. V8, Bun, and Deno are all locked to one thread per isolate. Perry compiles to native code — there are no isolates, no GIL, no structural limitations. Your code runs on real OS threads with the full power of every CPU core.
Why This Matters
JavaScript’s single-threaded model is its biggest performance bottleneck. Here’s how runtimes try to work around it:
| Runtime | “Multi-threading” | Reality |
|---|---|---|
| Node.js | worker_threads | Separate V8 isolates. Data copied via structured clone. ~2MB RAM per worker. Complex API. |
| Deno | Worker | Same as Node — isolated heaps, message passing only. |
| Bun | Worker | Same architecture. Faster structured clone, still isolated. |
| Perry | parallelMap / spawn | Real OS threads. Lightweight (8MB stack). One-line API. Compile-time safety. |
The fundamental problem: V8 uses a garbage-collected heap that cannot be shared between threads. Every “worker” is an entirely separate JavaScript engine instance with its own heap, its own GC, and its own copy of your data.
Perry doesn’t have this limitation. It compiles TypeScript to native machine code. Values are transferred between threads using zero-cost copies for numbers and efficient serialization for objects — no separate engine instances, no multi-megabyte overhead per thread.
Three Primitives
parallelMap — Data-Parallel Processing
Split an array across all CPU cores. Each element is processed independently. Results are collected in order.
import { parallelMap } from "perry/thread";
const prices = [100, 200, 300, 400, 500, 600, 700, 800];
const adjusted = parallelMap(prices, (price) => {
// Heavy computation runs on a worker thread
let result = price;
for (let i = 0; i < 1000000; i++) {
result = Math.sqrt(result * result + i);
}
return result;
});
Perry automatically:
- Detects the number of CPU cores
- Splits the array into chunks (one per core)
- Spawns OS threads to process each chunk
- Collects results in the original order
- Returns a new array
For small arrays, Perry skips threading entirely and processes inline — no overhead for trivial cases.
parallelFilter — Data-Parallel Filtering
Filter a large array across all CPU cores. Like .filter() but parallel:
import { parallelFilter } from "perry/thread";
const users = getMillionUsers();
// Filter across all cores — order is preserved
const active = parallelFilter(users, (user) => {
return user.lastLogin > cutoffDate && user.score > 100;
});
Same rules as parallelMap: closures cannot capture mutable variables (compile-time enforced), and values are deep-copied between threads.
spawn — Background Threads
Run any computation in the background and get a Promise back. The main thread continues immediately.
import { spawn } from "perry/thread";
// Start heavy work in the background
const handle = spawn(() => {
let sum = 0;
for (let i = 0; i < 100_000_000; i++) {
sum += Math.sin(i);
}
return sum;
});
// Main thread keeps running — UI stays responsive
console.log("Computing...");
// Get the result when you need it
const result = await handle;
console.log("Done:", result);
spawn returns a standard Promise. You can await it, pass it to Promise.all, or chain .then() — it works exactly like any other async operation.
Practical Examples
Parallel Image Processing
import { parallelMap } from "perry/thread";
// Each pixel processed on a separate core
const processed = parallelMap(pixels, (pixel) => {
const r = Math.min(255, pixel.r * 1.2);
const g = Math.min(255, pixel.g * 0.8);
const b = Math.min(255, pixel.b * 1.1);
return { r, g, b };
});
Parallel Cryptographic Hashing
import { parallelMap } from "perry/thread";
// Hash thousands of items across all cores
const passwords = ["pass1", "pass2", "pass3", /* ... thousands more */];
const hashed = parallelMap(passwords, (password) => {
return computeHash(password);
});
Multiple Independent Computations
import { spawn } from "perry/thread";
// Three independent tasks run simultaneously on three OS threads
const task1 = spawn(() => analyzeDataset(dataA));
const task2 = spawn(() => analyzeDataset(dataB));
const task3 = spawn(() => analyzeDataset(dataC));
// All three run concurrently
const [result1, result2, result3] = await Promise.all([task1, task2, task3]);
Keeping UI Responsive
import { spawn } from "perry/thread";
import { Text, Button } from "perry/ui";
let statusText = "Ready";
Button("Start Analysis", async () => {
statusText = "Analyzing...";
// Heavy computation runs on a background thread
// UI stays responsive — user can still interact
const result = await spawn(() => {
return runExpensiveAnalysis(data);
});
statusText = `Done: ${result}`;
});
Text(statusText);
Captured Variables
Closures can capture outer variables. Captured values are automatically deep-copied to each worker thread:
import { parallelMap } from "perry/thread";
const taxRate = 0.08;
const discount = 0.15;
// taxRate and discount are captured and copied to each thread
const finalPrices = parallelMap(prices, (price) => {
const discounted = price * (1 - discount);
return discounted * (1 + taxRate);
});
Numbers and booleans are zero-cost copies (just 64-bit values). Strings, arrays, and objects are deep-copied automatically.
Safety
Perry enforces thread safety at compile time. You don’t need to think about race conditions, mutexes, or data corruption.
No Shared Mutable State
Closures passed to parallelMap and spawn cannot capture mutable variables. The compiler rejects this:
let counter = 0;
// COMPILE ERROR: Closures passed to parallelMap cannot
// capture mutable variable 'counter'
parallelMap(data, (item) => {
counter++; // Not allowed
return item;
});
This eliminates data races by design. If you need to aggregate results, use the return values:
// Instead of mutating a shared counter, return values and reduce
const results = parallelMap(data, (item) => processItem(item));
const total = results.reduce((sum, r) => sum + r, 0);
Independent Thread Arenas
Each worker thread has its own memory arena. Objects created on one thread can never be accessed from another thread. Values cross thread boundaries only through deep-copy serialization, which Perry handles automatically and invisibly.
How It Works
Perry’s threading model is built on three pillars:
1. Native Code, Not Interpreted
Perry compiles TypeScript to native machine code via LLVM. There’s no interpreter, no VM, no isolate. A function pointer is just a function pointer — it’s valid on any thread.
2. Thread-Local Memory
Each thread gets its own memory arena (bump allocator) and garbage collector. No synchronization overhead during computation. When a thread finishes, its arena is freed automatically.
3. Serialized Transfer
Values crossing thread boundaries are serialized to a thread-safe intermediate format and deserialized on the target thread. The cost depends on the value type:
| Value Type | Transfer Cost |
|---|---|
| Numbers, booleans, null, undefined | Zero-cost (64-bit copy) |
| Strings | O(n) byte copy |
| Arrays | O(n) deep copy of elements |
| Objects | O(n) deep copy of fields |
| Closures | Pointer + captured values |
For numeric workloads — the most common parallelizable tasks — the threading overhead is negligible.
Next Steps
- parallelMap Reference — detailed API and performance tips
- parallelFilter Reference — parallel array filtering
- spawn Reference — background threads and Promise integration
parallelMap
import { parallelMap } from "perry/thread";
function parallelMap<T, U>(data: T[], fn: (item: T) => U): U[];
Processes every element of an array in parallel across all available CPU cores. Returns a new array with the results in the same order as the input.
Basic Usage
import { parallelMap } from "perry/thread";
const numbers = [1, 2, 3, 4, 5, 6, 7, 8];
const doubled = parallelMap(numbers, (x) => x * 2);
// [2, 4, 6, 8, 10, 12, 14, 16]
How It Works
Input: [a, b, c, d, e, f, g, h] (8 elements, 4 CPU cores)
Core 1: [a, b] → map → [a', b']
Core 2: [c, d] → map → [c', d']
Core 3: [e, f] → map → [e', f']
Core 4: [g, h] → map → [g', h']
Output: [a', b', c', d', e', f', g', h'] (same order as input)
Perry automatically detects the number of CPU cores and splits the array into equal chunks. Elements within each chunk are processed sequentially; chunks run concurrently across cores.
Capturing Variables
The mapping function can reference variables from the outer scope. Captured values are deep-copied to each worker thread automatically:
const exchangeRate = 1.12;
const fees = [0.01, 0.02, 0.015];
const converted = parallelMap(prices, (price) => {
// exchangeRate is captured and copied to each thread
return price * exchangeRate;
});
What Can Be Captured
| Type | Supported | Transfer |
|---|---|---|
| Numbers | Yes | Zero-cost (64-bit copy) |
| Booleans | Yes | Zero-cost |
| Strings | Yes | Byte copy |
| Arrays | Yes | Deep copy |
| Objects | Yes | Deep copy |
const variables | Yes | Copied |
let/var variables | Only if not reassigned | Copied |
What Cannot Be Captured
Mutable variables — variables that are reassigned anywhere in the enclosing scope — are rejected at compile time:
let total = 0;
// COMPILE ERROR: Cannot capture mutable variable 'total'
parallelMap(data, (item) => {
total += item; // Would be a data race
return item;
});
Instead, return values and reduce:
const results = parallelMap(data, (item) => item * 2);
const total = results.reduce((sum, x) => sum + x, 0);
Performance
When to Use parallelMap
Use parallelMap when the computation per element is significantly heavier than the cost of copying the element across threads.
Good candidates (CPU-bound work per element):
// Heavy math
parallelMap(data, (x) => expensiveComputation(x));
// String processing on large strings
parallelMap(documents, (doc) => parseAndAnalyze(doc));
// Cryptographic operations
parallelMap(inputs, (input) => computeHash(input));
Poor candidates (trivial work per element):
// Too simple — threading overhead outweighs the gain
parallelMap(numbers, (x) => x + 1);
// For trivial operations, use regular map
const result = numbers.map((x) => x + 1);
Small Array Optimization
For arrays with fewer elements than CPU cores, Perry skips threading entirely and processes elements inline on the main thread. There’s zero overhead for small inputs.
Numeric Fast Path
When elements are pure numbers (no strings, objects, or arrays), Perry transfers them between threads at virtually zero cost — just 64-bit value copies with no serialization.
Examples
Matrix Row Processing
import { parallelMap } from "perry/thread";
// Process each row of a matrix independently
const rows = [[1,2,3], [4,5,6], [7,8,9]];
const rowSums = parallelMap(rows, (row) => {
let sum = 0;
for (const val of row) sum += val;
return sum;
});
// [6, 15, 24]
Batch Validation
import { parallelMap } from "perry/thread";
const users = [
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "invalid" },
{ name: "Charlie", email: "charlie@example.com" },
];
const validationResults = parallelMap(users, (user) => {
const emailValid = user.email.includes("@") && user.email.includes(".");
const nameValid = user.name.length > 0 && user.name.length < 100;
return { name: user.name, valid: emailValid && nameValid };
});
Financial Calculations
import { parallelMap } from "perry/thread";
const portfolios = getPortfolioData(); // thousands of portfolios
// Monte Carlo simulation across all cores
const riskScores = parallelMap(portfolios, (portfolio) => {
let totalRisk = 0;
for (let sim = 0; sim < 10000; sim++) {
totalRisk += simulateReturns(portfolio);
}
return totalRisk / 10000;
});
parallelFilter
import { parallelFilter } from "perry/thread";
function parallelFilter<T>(data: T[], predicate: (item: T) => boolean): T[];
Filters an array in parallel across all available CPU cores. Returns a new array containing only the elements where the predicate returned a truthy value. Order is preserved.
Basic Usage
import { parallelFilter } from "perry/thread";
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const evens = parallelFilter(numbers, (x) => x % 2 === 0);
// [2, 4, 6, 8, 10]
How It Works
Input: [a, b, c, d, e, f, g, h] (8 elements, 4 CPU cores)
Core 1: [a, b] → test → [a] (b filtered out)
Core 2: [c, d] → test → [c, d] (both kept)
Core 3: [e, f] → test → [] (both filtered out)
Core 4: [g, h] → test → [h] (g filtered out)
Output: [a, c, d, h] (concatenated in original order)
Each core independently tests its chunk of elements. Results are merged in the original element order after all threads complete.
Why Not Just Use .filter()?
Regular .filter() runs on a single thread. For large arrays with expensive predicates, parallelFilter distributes the work:
// Single-threaded — one core does all the work
const results = data.filter((item) => expensivePredicate(item));
// Parallel — all cores share the work
import { parallelFilter } from "perry/thread";
const results = parallelFilter(data, (item) => expensivePredicate(item));
The tradeoff: parallelFilter has overhead from copying values between threads. Use it when the predicate is expensive enough to justify that cost.
Capturing Variables
Like parallelMap, the predicate can capture outer variables. Captures are deep-copied to each thread:
import { parallelFilter } from "perry/thread";
const minScore = 85;
const maxAge = 30;
// minScore and maxAge are captured and copied to each thread
const qualified = parallelFilter(candidates, (c) => {
return c.score >= minScore && c.age <= maxAge;
});
Mutable variables cannot be captured — the compiler rejects this at compile time.
Examples
Filtering Large Datasets
import { parallelFilter } from "perry/thread";
const transactions = getTransactionLog(); // millions of records
const suspicious = parallelFilter(transactions, (tx) => {
return tx.amount > 10000
&& tx.country !== tx.user.homeCountry
&& tx.timestamp.hour < 6;
});
Combined with parallelMap
import { parallelMap, parallelFilter } from "perry/thread";
// Step 1: Filter to relevant items (parallel)
const active = parallelFilter(users, (u) => u.isActive && u.age >= 18);
// Step 2: Transform the filtered results (parallel)
const profiles = parallelMap(active, (u) => ({
name: u.name,
score: computeScore(u),
}));
Predicate with Heavy Computation
import { parallelFilter } from "perry/thread";
// Each predicate call does significant work — perfect for parallelization
const valid = parallelFilter(certificates, (cert) => {
return verifyCertificateChain(cert) && !isRevoked(cert);
});
Performance
Use parallelFilter when:
- The array has many elements (hundreds or more)
- The predicate function does meaningful work per element
- You need to keep the UI responsive during filtering
For trivial predicates on small arrays, regular .filter() is faster (no threading overhead).
spawn
import { spawn } from "perry/thread";
function spawn<T>(fn: () => T): Promise<T>;
Runs a closure on a new OS thread and returns a Promise that resolves when the thread completes. The main thread continues immediately — UI and other work are not blocked.
Basic Usage
import { spawn } from "perry/thread";
const result = await spawn(() => {
// This runs on a separate OS thread
let sum = 0;
for (let i = 0; i < 100_000_000; i++) {
sum += i;
}
return sum;
});
console.log(result); // 4999999950000000
Non-Blocking
spawn returns immediately. The main thread doesn’t wait:
import { spawn } from "perry/thread";
console.log("1. Starting background work");
const handle = spawn(() => {
// Runs on a background thread
return expensiveComputation();
});
console.log("2. Main thread continues immediately");
const result = await handle;
console.log("3. Got result:", result);
Output:
1. Starting background work
2. Main thread continues immediately
3. Got result: <computed value>
Multiple Concurrent Tasks
Spawn multiple tasks and they run truly concurrently — one OS thread per spawn call:
import { spawn } from "perry/thread";
const t1 = spawn(() => analyzeCustomers(regionA));
const t2 = spawn(() => analyzeCustomers(regionB));
const t3 = spawn(() => analyzeCustomers(regionC));
// All three run simultaneously on separate OS threads
const [r1, r2, r3] = await Promise.all([t1, t2, t3]);
console.log("Region A:", r1);
console.log("Region B:", r2);
console.log("Region C:", r3);
Unlike Node.js worker_threads, each spawn is a lightweight OS thread (~8MB stack), not a full V8 isolate (~2MB heap + startup cost).
Capturing Variables
Like parallelMap, spawn closures can capture outer variables. They are deep-copied to the background thread:
import { spawn } from "perry/thread";
const config = { iterations: 1000, seed: 42 };
const dataset = loadData();
const result = await spawn(() => {
// config and dataset are copied to this thread
return runSimulation(config, dataset);
});
Mutable variables cannot be captured — this is enforced at compile time.
Returning Complex Values
spawn can return any value type. Complex values (objects, arrays, strings) are serialized back to the main thread automatically:
import { spawn } from "perry/thread";
const stats = await spawn(() => {
const values = computeExpensiveValues();
return {
mean: average(values),
median: median(values),
stddev: standardDeviation(values),
count: values.length,
};
});
console.log(stats.mean, stats.median);
UI Integration
spawn is ideal for keeping native UIs responsive during heavy computation:
import { spawn } from "perry/thread";
import { Text, Button, VStack } from "perry/ui";
let status = "Ready";
let result = "";
VStack(10, [
Text(status),
Text(result),
Button("Analyze", async () => {
status = "Processing...";
// Background thread — UI stays responsive
const data = await spawn(() => {
return runAnalysis(largeDataset);
});
result = `Found ${data.count} patterns`;
status = "Done";
}),
]);
Without spawn, the analysis would freeze the UI. With spawn, the user can still scroll, tap other buttons, or navigate while the computation runs.
Compared to Node.js worker_threads
// ── Node.js: ~15 lines, separate file needed ──────────
// worker.js
const { parentPort, workerData } = require("worker_threads");
const result = heavyComputation(workerData);
parentPort.postMessage(result);
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js", {
workerData: inputData,
});
worker.on("message", (result) => {
console.log(result);
});
worker.on("error", (err) => { /* handle */ });
// ── Perry: 1 line ─────────────────────────────────────
const result = await spawn(() => heavyComputation(inputData));
No separate files. No message ports. No event handlers. No structured clone. One line.
Examples
Background File Processing
import { spawn } from "perry/thread";
import { readFileSync } from "fs";
// Read and process a large file without blocking
const analysis = await spawn(() => {
const content = readFileSync("large-dataset.csv");
return parseAndAnalyze(content);
});
Parallel API Calls with Processing
import { spawn } from "perry/thread";
// Fetch data, then process it on a background thread
const rawData = await fetch("https://api.example.com/data").then(r => r.json());
// CPU-intensive processing happens off the main thread
const processed = await spawn(() => {
return transformAndEnrich(rawData);
});
Deferred Computation
import { spawn } from "perry/thread";
// Start computation early, use result later
const precomputed = spawn(() => buildLookupTable(params));
// ... do other setup work ...
// Result is ready (or we wait for it)
const table = await precomputed;
UI Overview
Perry’s perry/ui module lets you build native desktop and mobile apps with declarative TypeScript. Your UI code compiles directly to platform-native widgets — AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows, and DOM elements on the web.
Quick Start
// demonstrates: the smallest complete Perry UI app
// docs: docs/src/ui/overview.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, Text, VStack } from "perry/ui"
App({
title: "My App",
width: 400,
height: 300,
body: VStack(16, [
Text("Hello from Perry!"),
]),
})
perry app.ts -o app && ./app
Mental Model
Perry’s UI follows the same model as SwiftUI and Flutter: you compose native widgets using stack-based layout containers (VStack, HStack, ZStack), control alignment and distribution, and style widgets directly via method calls. If you’re coming from web development, the key shift is:
- Layout is controlled by stack alignment, distribution, and spacers — not CSS properties. See Layout.
- Styling is applied directly to widgets — not through stylesheets. See Styling.
- Absolute positioning uses overlays (
widgetAddOverlay+widgetSetOverlayFrame) — notposition: absolute/relative. - Design tokens come from the
perry-stylingpackage. See Theming.
App Lifecycle
Every Perry UI app starts with App():
import { App, VStack, Text } from "perry/ui";
App({
title: "Window Title",
width: 800,
height: 600,
body: VStack(16, [
Text("Content here"),
]),
});
App({}) accepts a config object with the following properties:
| Property | Type | Description |
|---|---|---|
title | string | Window title |
width | number | Initial window width |
height | number | Initial window height |
body | widget | Root widget |
icon | string | App icon file path (optional) |
frameless | boolean | Remove title bar (optional) |
level | string | Window z-order: "floating", "statusBar", "modal" (optional) |
transparent | boolean | Transparent background (optional) |
vibrancy | string | Native blur material, e.g. "sidebar" (optional) |
activationPolicy | string | "regular", "accessory" (no dock icon), "background" (optional) |
See Multi-Window for full documentation on window properties.
Lifecycle Hooks
import { App, onActivate, onTerminate } from "perry/ui";
onActivate(() => {
console.log("App became active");
});
onTerminate(() => {
console.log("App is closing");
});
App({ title: "My App", width: 800, height: 600, body: /* ... */ });
Widget Tree
Perry UIs are built as a tree of widgets:
import { App, Text, Button, VStack, HStack } from "perry/ui";
App({
title: "Layout Demo",
width: 400,
height: 300,
body: VStack(16, [
Text("Header"),
HStack(8, [
Button("Left", () => console.log("left")),
Button("Right", () => console.log("right")),
]),
Text("Footer"),
]),
});
Widgets are created by calling their constructor functions. Layout containers (VStack, HStack, ZStack) accept a spacing value (in points) followed by an array of child widgets.
Handle-Based Architecture
Under the hood, each widget is a handle — a small integer that references a native platform object. When you call Text("hello"), Perry creates a native NSTextField (macOS), UILabel (iOS), GtkLabel (Linux), or <span> (web) and returns a handle you can use to modify it.
const label = Text("Hello");
label.setFontSize(18); // Modifies the native widget
label.setColor("#FF0000"); // Through the handle
Imports
All UI functions are imported from perry/ui:
import {
// App lifecycle
App, onActivate, onTerminate,
// Widgets
Text, Button, TextField, SecureField, Toggle, Slider,
Image, ProgressView, Picker,
// Layout
VStack, HStack, ZStack, ScrollView, Spacer, Divider,
NavigationStack, LazyVStack, Form, Section,
VStackWithInsets, HStackWithInsets, SplitView, splitViewAddChild,
// Layout control
stackSetAlignment, stackSetDistribution, stackSetDetachesHidden,
widgetMatchParentWidth, widgetMatchParentHeight, widgetSetHugging,
widgetAddOverlay, widgetSetOverlayFrame,
// State
State, ForEach,
// Dialogs
openFileDialog, saveFileDialog, alert, Sheet,
// Menus
menuBarCreate, menuBarAddMenu, contextMenu,
// Canvas
Canvas,
// Table
Table,
// Window
Window,
// Camera (iOS)
CameraView, cameraStart, cameraStop, cameraFreeze, cameraUnfreeze,
cameraSampleColor, cameraSetOnTap,
} from "perry/ui";
Platform Differences
The same code runs on all platforms, but the look and feel matches each platform’s native style:
| Feature | macOS | iOS | Linux | Windows | Web |
|---|---|---|---|---|---|
| Buttons | NSButton | UIButton | GtkButton | HWND Button | <button> |
| Text | NSTextField | UILabel | GtkLabel | Static HWND | <span> |
| Layout | NSStackView | UIStackView | GtkBox | Manual layout | Flexbox |
| Menus | NSMenu | — | GMenu | HMENU | DOM |
Platform-specific behavior is noted on each widget’s documentation page.
Next Steps
- Widgets — All available widgets
- Layout — Arranging widgets
- State Management — Reactive state and bindings
- Styling — Colors, fonts, sizing
- Events — Click, hover, keyboard
Widgets
Perry provides native widgets that map to each platform’s native controls.
Every example on this page is a real runnable program verified by CI
(scripts/run_doc_tests.sh) — the snippet you read is the same source that’s
compiled and launched.
The widget API is free functions, not methods. A widget is a 64-bit
opaque handle; you pass it into helpers like textSetFontSize(widget, 18)
rather than calling widget.setFontSize(18). That’s the only shape perry/ui
supports — no fluent chain, no prototype methods.
Text
Displays read-only text.
// demonstrates: Text widget styling with the real free-function API
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, textSetFontSize, textSetFontWeight, textSetColor, textSetFontFamily } from "perry/ui"
const label = Text("Hello, World!")
textSetFontSize(label, 18)
textSetColor(label, 0.2, 0.2, 0.2, 1.0) // RGBA in [0, 1]
textSetFontFamily(label, "Menlo")
const bold = Text("Bold")
textSetFontWeight(bold, 20, 1.0)
App({
title: "Text",
width: 400,
height: 200,
body: VStack(8, [label, bold]),
})
Color is RGBA with each channel in [0.0, 1.0] — divide a hex byte by 255
(0x33 / 255 ≈ 0.2).
Helpers: textSetString, textSetFontSize, textSetFontWeight,
textSetFontFamily, textSetColor, textSetWraps, textSetSelectable.
Text widgets inside template literals with state.value update automatically
— perry detects the state read and rewires the widget to re-render on change.
See State Management.
Button
A clickable button.
// demonstrates: Button styling with buttonSet*/widgetSet* helpers
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import {
App,
VStack,
Button,
buttonSetBordered,
widgetSetEnabled,
setCornerRadius,
} from "perry/ui"
// Note: buttonSetContentTintColor is macOS/iOS-only (maps to NSButton /
// UIButton tint). GTK4/Win32 don't have an equivalent — set
// widgetSetBackgroundColor(btn, r, g, b, a) there instead.
const primary = Button("Click Me", () => console.log("Clicked!"))
buttonSetBordered(primary, 1)
setCornerRadius(primary, 8)
const disabled = Button("Can't click me", () => {})
widgetSetEnabled(disabled, 0)
App({
title: "Button",
width: 400,
height: 200,
body: VStack(12, [primary, disabled]),
})
Helpers: buttonSetTitle, buttonSetBordered, buttonSetImage
(SF Symbol name on macOS/iOS), buttonSetImagePosition,
buttonSetContentTintColor, buttonSetTextColor, widgetSetEnabled.
TextField
An editable single-line text input.
// demonstrates: TextField + two-way binding via stateBindTextfield
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, TextField, State, stateBindTextfield } from "perry/ui"
const text = State("")
const field = TextField("Placeholder...", (value: string) => text.set(value))
stateBindTextfield(text, field) // programmatic text.set() also updates the field
App({
title: "TextField",
width: 400,
height: 200,
body: VStack(12, [
field,
Text(`You typed: ${text.value}`),
]),
})
TextField(placeholder, onChange) fires onChange as the user types. Pair
with stateBindTextfield(state, field) for two-way binding so programmatic
state.set(…) also updates the visible text.
Helpers: textfieldSetString, textfieldSetFontSize,
textfieldSetTextColor, textfieldSetBackgroundColor,
textfieldSetBorderless, textfieldSetOnSubmit, textfieldSetOnFocus,
textfieldSetNextKeyView.
SecureField
A password input — identical signature to TextField, but text is masked.
// demonstrates: SecureField for password input
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, SecureField, State } from "perry/ui"
const password = State("")
App({
title: "SecureField",
width: 400,
height: 200,
body: VStack(12, [
SecureField("Enter password...", (value: string) => password.set(value)),
]),
})
Toggle
A boolean on/off switch.
// demonstrates: Toggle widget bound to State
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, Toggle, State } from "perry/ui"
const enabled = State(false)
App({
title: "Toggle",
width: 400,
height: 200,
body: VStack(12, [
Toggle("Enable notifications", (on: boolean) => enabled.set(on)),
Text(`Enabled: ${enabled.value}`),
]),
})
Slider
A numeric slider.
// demonstrates: Slider with a numeric range
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, Slider, State } from "perry/ui"
const value = State(50)
App({
title: "Slider",
width: 400,
height: 200,
body: VStack(12, [
Slider(0, 100, (v: number) => value.set(v)),
Text(`Value: ${value.value}`),
]),
})
Slider(min, max, onChange) — onChange fires on every drag. Use
stateBindSlider(state, slider) for two-way binding.
Picker
A dropdown selection control. Items are added with pickerAddItem.
// demonstrates: Picker with items added via pickerAddItem
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, Picker, State, pickerAddItem } from "perry/ui"
const selected = State(0)
const picker = Picker((index: number) => selected.set(index))
pickerAddItem(picker, "Option A")
pickerAddItem(picker, "Option B")
pickerAddItem(picker, "Option C")
App({
title: "Picker",
width: 400,
height: 200,
body: VStack(12, [
picker,
Text(`Selected index: ${selected.value}`),
]),
})
ImageFile / ImageSymbol
Two distinct constructors:
ImageFile(path)— image from a file pathImageSymbol(name)— SF Symbol glyph name (macOS/iOS only)
// demonstrates: ImageSymbol for SF Symbol glyphs (macOS/iOS)
// docs: docs/src/ui/widgets.md
// platforms: macos
// targets: ios-simulator, visionos-simulator, tvos-simulator
import { App, HStack, ImageSymbol, widgetSetWidth, widgetSetHeight } from "perry/ui"
const star = ImageSymbol("star.fill")
widgetSetWidth(star, 32)
widgetSetHeight(star, 32)
const heart = ImageSymbol("heart.fill")
const bell = ImageSymbol("bell.fill")
App({
title: "ImageSymbol",
width: 400,
height: 200,
body: HStack(12, [star, heart, bell]),
})
Use widgetSetWidth(img, N) / widgetSetHeight(img, N) to size the image.
ProgressView
An indeterminate or determinate progress indicator.
// demonstrates: ProgressView as an indeterminate spinner
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, ProgressView } from "perry/ui"
App({
title: "ProgressView",
width: 400,
height: 200,
body: VStack(12, [
Text("Loading..."),
ProgressView(),
]),
})
TextArea
A multi-line text input. Same (placeholder, onChange) signature as
TextField but renders as a multi-line box.
// demonstrates: TextArea for multi-line input
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, Text, TextArea, State } from "perry/ui"
const content = State("")
App({
title: "TextArea",
width: 500,
height: 400,
body: VStack(12, [
TextArea("Enter multi-line text...", (value: string) => content.set(value)),
Text(`Length: ${content.value.length}`),
]),
})
Helpers: textareaSetString.
Sections
Group controls into labelled sections. Perry has no Form() widget — use a
VStack of Section(title)s and attach children via widgetAddChild.
// demonstrates: Section grouping with widgetAddChild (no Form widget in Perry)
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import {
App,
VStack,
Section,
TextField,
Toggle,
State,
widgetAddChild,
} from "perry/ui"
const name = State("")
const notifications = State(true)
const personal = Section("Personal Info")
widgetAddChild(personal, TextField("Name", (value: string) => name.set(value)))
const settings = Section("Settings")
widgetAddChild(
settings,
Toggle("Notifications", (on: boolean) => notifications.set(on)),
)
App({
title: "Sections",
width: 500,
height: 400,
body: VStack(16, [personal, settings]),
})
Platform-specific widgets
These exist only on specific platforms and aren’t verified by the cross-platform doc-tests:
Table(rows, cols, renderer)— macOS only. A data table with rows, columns, and a cell renderer.QRCode(data, size)— macOS only. Renders a QR code.Canvas(width, height, draw)— all desktop platforms. A drawing surface; see Canvas.CameraView()— iOS only (other platforms planned). See Camera.
These are linked from their own pages with platform-specific examples.
Common widget helpers
Every widget handle accepts these:
| Helper | Description |
|---|---|
widgetSetWidth(w, n) / widgetSetHeight(w, n) | Explicit size in points |
widgetSetBackgroundColor(w, r, g, b, a) | RGBA in [0, 1] |
setCornerRadius(w, r) | Rounded corners in points |
widgetSetOpacity(w, alpha) | Opacity in [0, 1] |
widgetSetEnabled(w, flag) | 0 disables, 1 enables |
widgetSetHidden(w, flag) | 0 visible, 1 hidden |
widgetSetTooltip(w, text) | Tooltip on hover (desktop only) |
widgetSetOnClick(w, cb) | Click handler |
widgetSetOnHover(w, cb) | Hover enter/leave (desktop only) |
widgetSetOnDoubleClick(w, cb) | Double-click handler |
widgetSetEdgeInsets(w, top, left, bottom, right) | Padding around contents |
widgetSetBorderColor(w, r, g, b, a) / widgetSetBorderWidth(w, n) | Border |
widgetAddChild(parent, child) | Attach a child to a container |
widgetSetContextMenu(w, menu) | Right-click menu |
See Styling and Events for deeper coverage.
Next Steps
- Layout — Arranging widgets with stacks and containers
- Styling — Colors, fonts, borders
- State Management — Reactive bindings
Layout
Perry provides layout containers that arrange child widgets using the platform’s native layout system.
VStack
Arranges children vertically (top to bottom).
import { VStack, Text, Button } from "perry/ui";
VStack(16, [
Text("First"),
Text("Second"),
Text("Third"),
]);
VStack(spacing, children) — the first argument is the gap in points between children.
Methods:
setPadding(padding: number)— Set padding around all edgessetSpacing(spacing: number)— Set spacing between children
HStack
Arranges children horizontally (left to right).
import { HStack, Text, Button, Spacer } from "perry/ui";
HStack(8, [
Button("Cancel", () => {}),
Spacer(),
Button("OK", () => {}),
]);
HStack(spacing, children) — the first argument is the gap in points between children.
ZStack
Layers children on top of each other (back to front).
import { ZStack, Text, Image } from "perry/ui";
ZStack(0, [
Image("background.png"),
Text("Overlay text"),
]);
ScrollView
A scrollable container.
import { ScrollView, VStack, Text } from "perry/ui";
ScrollView(
VStack(
8,
Array.from({ length: 100 }, (_, i) => Text(`Row ${i}`))
)
);
Methods:
setRefreshControl(callback: () => void)— Add pull-to-refresh (calls callback on pull)endRefreshing()— Stop the refresh indicator
LazyVStack
A vertically scrolling list that lazily renders items. More efficient than ScrollView + VStack for large lists.
import { LazyVStack, Text } from "perry/ui";
LazyVStack(1000, (index) => {
return Text(`Row ${index}`);
});
NavigationStack
A navigation container that supports push/pop navigation.
import { NavigationStack, VStack, Text, Button } from "perry/ui";
NavigationStack(
VStack(16, [
Text("Home Screen"),
Button("Go to Details", () => {
// Push a new view
}),
])
);
Spacer
A flexible space that expands to fill available room.
import { HStack, Text, Spacer } from "perry/ui";
HStack(8, [
Text("Left"),
Spacer(),
Text("Right"),
]);
Use Spacer() inside HStack or VStack to push widgets apart.
Divider
A visual separator line.
import { VStack, Text, Divider } from "perry/ui";
VStack(12, [
Text("Section 1"),
Divider(),
Text("Section 2"),
]);
Nesting Layouts
Layouts can be nested freely. This example is verified by CI:
// demonstrates: nested VStack/HStack + Spacer + Divider
// docs: docs/src/ui/layout.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, HStack, Text, Button, Spacer, Divider } from "perry/ui"
App({
title: "Layout Example",
width: 800,
height: 600,
body: VStack(16, [
// Header
HStack(8, [
Text("My App"),
Spacer(),
Button("Settings", () => {}),
]),
Divider(),
// Content
VStack(12, [
Text("Welcome!"),
HStack(8, [
Button("Action 1", () => {}),
Button("Action 2", () => {}),
]),
]),
Spacer(),
// Footer
Text("v1.0.0"),
]),
})
Child Management
Containers support dynamic child management:
const stack = VStack(16, []);
// Add children dynamically
stack.addChild(Text("New child"));
stack.addChildAt(0, Text("Prepended"));
stack.removeChild(someWidget);
stack.reorderChild(widget, 2);
stack.clearChildren();
Methods:
addChild(widget)— Append a child widgetaddChildAt(index, widget)— Insert a child at a specific positionremoveChild(widget)— Remove a child widgetreorderChild(widget, newIndex)— Move a child to a new positionclearChildren()— Remove all children
Stack Alignment
Control how children are aligned within a stack using stackSetAlignment:
import { VStack, Text, stackSetAlignment } from "perry/ui";
const centered = VStack(16, [
Text("Centered"),
Text("Content"),
]);
stackSetAlignment(centered, 9); // CenterX
VStack alignment (cross-axis = horizontal):
| Value | Name | Effect |
|---|---|---|
| 5 | Leading | Children align to the leading (left) edge |
| 9 | CenterX | Children centered horizontally |
| 7 | Width | Children stretch to fill the stack’s width |
HStack alignment (cross-axis = vertical):
| Value | Name | Effect |
|---|---|---|
| 3 | Top | Children align to the top |
| 12 | CenterY | Children centered vertically |
| 4 | Bottom | Children align to the bottom |
Stack Distribution
Control how children share space within a stack using stackSetDistribution:
import { HStack, Button, stackSetDistribution } from "perry/ui";
const buttons = HStack(8, [
Button("Cancel", () => {}),
Button("OK", () => {}),
]);
stackSetDistribution(buttons, 1); // FillEqually — both buttons get equal width
| Value | Name | Behavior |
|---|---|---|
| 0 | Fill | Default. First resizable child fills remaining space |
| 1 | FillEqually | All children get equal size |
| 2 | FillProportionally | Children sized proportionally to their intrinsic content |
| 3 | EqualSpacing | Equal gaps between children |
| 4 | EqualCentering | Equal distance between child centers |
Fill Parent
Pin a child’s edges to its parent container:
import { VStack, Text, widgetMatchParentWidth } from "perry/ui";
const banner = Text("Full width banner");
widgetMatchParentWidth(banner);
VStack(16, [banner, Text("Normal width")]);
widgetMatchParentWidth(widget)— stretch to fill parent’s widthwidgetMatchParentHeight(widget)— stretch to fill parent’s height
Content Hugging
Control whether a widget resists being stretched beyond its intrinsic size:
import { VStack, Text, widgetSetHugging } from "perry/ui";
const label = Text("I stay small");
widgetSetHugging(label, 750); // High priority — resist stretching
const filler = Text("I stretch");
widgetSetHugging(filler, 1); // Low priority — stretch to fill
- High priority (250-750+): widget resists stretching, stays at its natural size
- Low priority (1-249): widget stretches to fill available space
Overlay Positioning
For absolute positioning, add overlay children to any container:
import { VStack, Text, widgetAddOverlay, widgetSetOverlayFrame } from "perry/ui";
const container = VStack(16, [Text("Main content")]);
const badge = Text("3");
badge.setCornerRadius(10);
badge.setBackgroundColor("#FF3B30");
widgetAddOverlay(container, badge);
widgetSetOverlayFrame(badge, 280, 10, 20, 20); // x, y, width, height
Overlay children are positioned absolutely relative to their parent — similar to CSS position: absolute.
Split Views
Create resizable split panes for sidebar layouts:
import { SplitView, splitViewAddChild, VStack, Text } from "perry/ui";
const split = SplitView();
const sidebar = VStack(8, [Text("Navigation"), Text("Item 1"), Text("Item 2")]);
const content = VStack(16, [Text("Main Content")]);
splitViewAddChild(split, sidebar);
splitViewAddChild(split, content);
The user can drag the divider to resize panes. On macOS this maps to NSSplitView.
Stacks with Built-in Padding
Create a stack with padding in a single call:
import { VStackWithInsets, HStackWithInsets, Text, widgetAddChild } from "perry/ui";
// VStackWithInsets(spacing, top, right, bottom, left)
const card = VStackWithInsets(12, 16, 16, 16, 16);
widgetAddChild(card, Text("Padded content"));
widgetAddChild(card, Text("More content"));
Equivalent to creating a stack and then calling setEdgeInsets, but more concise. Children are added via widgetAddChild instead of the constructor array.
Detaching Hidden Views
By default, hidden children still occupy space in a stack. To collapse them:
import { VStack, Text, widgetSetHidden, stackSetDetachesHidden } from "perry/ui";
const stack = VStack(8, [Text("Always visible"), Text("Sometimes hidden")]);
stackSetDetachesHidden(stack, 1); // Hidden children leave no gap
Common Layout Patterns
Centered content
const page = VStack(16, [Text("Title"), Text("Subtitle")]);
stackSetAlignment(page, 9); // CenterX
Sidebar + content
const split = SplitView();
splitViewAddChild(split, sidebar);
splitViewAddChild(split, content);
Equal-width button row
const row = HStack(8, [Button("Cancel", onCancel), Button("OK", onOK)]);
stackSetDistribution(row, 1); // FillEqually
Full-width child in a stack
const input = TextField("Search...", onChange);
widgetMatchParentWidth(input);
VStack(12, [input, results]);
Floating badge / overlay
const icon = Image("bell.png");
const badge = Text("3");
widgetAddOverlay(icon, badge);
widgetSetOverlayFrame(badge, 20, -5, 16, 16);
Toolbar with spacer
HStack(8, [
Button("Back", goBack),
Spacer(),
Text("Page Title"),
Spacer(),
Button("Settings", openSettings),
]);
Next Steps
- Styling — Colors, padding, sizing
- Widgets — All available widgets
- State Management — Dynamic UI with state
Styling
Perry widgets support native styling properties that map to each platform’s styling system.
Coming from CSS
Perry’s layout model is closer to SwiftUI or Flutter than CSS. If you’re coming from web development, here’s how concepts translate:
| CSS | Perry |
|---|---|
display: flex; flex-direction: column | VStack(spacing, [...]) |
display: flex; flex-direction: row | HStack(spacing, [...]) |
justify-content | stackSetDistribution(stack, mode) + Spacer() |
align-items | stackSetAlignment(stack, value) |
position: absolute | widgetAddOverlay + widgetSetOverlayFrame |
width: 100% | widgetMatchParentWidth(widget) |
padding: 10px 20px | setEdgeInsets(10, 20, 10, 20) |
gap: 16px | VStack(16, [...]) — first argument is the gap |
| CSS variables / design tokens | perry-styling package (Theming) |
opacity | setOpacity(value) |
border-radius | setCornerRadius(value) |
See Layout for full details on alignment, distribution, overlays, and split views.
Colors
import { Text, Button } from "perry/ui";
const label = Text("Colored text");
label.setColor("#FF0000"); // Text color (hex)
label.setBackgroundColor("#F0F0F0"); // Background color
Colors are specified as hex strings (#RRGGBB).
Fonts
const label = Text("Styled text");
label.setFontSize(24); // Font size in points
label.setFontFamily("Menlo"); // Font family name
Use "monospaced" for the system monospaced font.
Corner Radius
const btn = Button("Rounded", () => {});
btn.setCornerRadius(12);
Borders
const widget = VStack(0, []);
widget.setBorderColor("#CCCCCC");
widget.setBorderWidth(1);
Padding and Insets
const stack = VStack(8, [Text("Padded content")]);
stack.setPadding(16);
stack.setEdgeInsets(10, 20, 10, 20); // top, right, bottom, left
Sizing
const widget = VStack(0, []);
widget.setWidth(300);
widget.setHeight(200);
widget.setFrame(0, 0, 300, 200); // x, y, width, height
Opacity
const widget = Text("Semi-transparent");
widget.setOpacity(0.5); // 0.0 to 1.0
Background Gradient
const widget = VStack(0, []);
widget.setBackgroundGradient("#FF0000", "#0000FF"); // Start color, end color
Control Size
const btn = Button("Small", () => {});
btn.setControlSize(0); // 0=mini, 1=small, 2=regular, 3=large
macOS: Maps to
NSControl.ControlSize. Other platforms may interpret differently.
Tooltips
const btn = Button("Hover me", () => {});
btn.setTooltip("Click to perform action");
macOS/Windows/Linux: Native tooltips. iOS/Android: No tooltip support. Web: HTML
titleattribute.
Enabled/Disabled
const btn = Button("Submit", () => {});
btn.setEnabled(false); // Greys out and disables interaction
Complete Styling Example
// demonstrates: a styled counter card using the real free-function API
// docs: docs/src/ui/styling.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import {
App,
Text,
Button,
VStack,
HStack,
State,
Spacer,
textSetFontSize,
textSetFontFamily,
textSetColor,
widgetSetBackgroundColor,
widgetSetEdgeInsets,
setCornerRadius,
} from "perry/ui"
// Note: widgetSetBorderColor / widgetSetBorderWidth are macOS/iOS/Windows
// only — perry-ui-gtk4 doesn't export them (GTK4 borders are CSS-driven).
// Omitted from this demo so it compiles everywhere.
const count = State(0)
const title = Text("Counter")
textSetFontSize(title, 28)
textSetColor(title, 0.1, 0.1, 0.1, 1.0)
const display = Text(`${count.value}`)
textSetFontSize(display, 48)
textSetFontFamily(display, "monospaced")
textSetColor(display, 0.0, 0.478, 1.0, 1.0)
const decBtn = Button("-", () => count.set(count.value - 1))
setCornerRadius(decBtn, 20)
widgetSetBackgroundColor(decBtn, 1.0, 0.231, 0.188, 1.0)
const incBtn = Button("+", () => count.set(count.value + 1))
setCornerRadius(incBtn, 20)
widgetSetBackgroundColor(incBtn, 0.204, 0.78, 0.349, 1.0)
const controls = HStack(8, [decBtn, Spacer(), incBtn])
widgetSetEdgeInsets(controls, 20, 20, 20, 20)
const container = VStack(16, [title, display, controls])
widgetSetEdgeInsets(container, 40, 40, 40, 40)
setCornerRadius(container, 16)
widgetSetBackgroundColor(container, 1.0, 1.0, 1.0, 1.0)
App({
title: "Styled App",
width: 400,
height: 300,
body: container,
})
Colors are RGBA floats in [0.0, 1.0]. Divide each hex byte by 255 to
convert — 0xFF3B30 becomes (1.0, 0.231, 0.188, 1.0). Padding is four
explicit sides (widgetSetEdgeInsets(w, top, left, bottom, right)), not a
single value.
Composing Styles
Reduce repetition by creating helper functions:
import { VStackWithInsets, Text, widgetAddChild } from "perry/ui";
function card(children: any[]) {
const c = VStackWithInsets(12, 16, 16, 16, 16);
c.setCornerRadius(12);
c.setBackgroundColor("#FFFFFF");
c.setBorderColor("#E5E5E5");
c.setBorderWidth(1);
for (const child of children) widgetAddChild(c, child);
return c;
}
// Usage
card([Text("Title"), Text("Body text")]);
For larger apps, use the perry-styling package to define design tokens in JSON and generate a typed theme file. See Theming for the full workflow.
Next Steps
State Management
Perry uses reactive state to automatically update the UI when data changes.
Creating State
import { State } from "perry/ui";
const count = State(0); // number state
const name = State("Perry"); // string state
const items = State<string[]>([]); // array state
State(initialValue) creates a reactive state container.
Reading and Writing
const value = count.value; // Read current value
count.set(42); // Set new value → triggers UI update
Every .set() call re-renders the widget tree with the new value.
Reactive Text
Template literals with state.value update automatically:
import { Text, State } from "perry/ui";
const count = State(0);
Text(`Count: ${count.value}`);
// The text updates whenever count changes
This works because Perry detects state.value reads inside template literals and creates reactive bindings.
Binding Inputs to State
Input widgets expose an onChange callback. Forward that into a state’s
.set(...) to keep the state in sync as the user types/toggles/drags:
import { TextField, State, stateBindTextfield } from "perry/ui";
const input = State("");
const field = TextField("Type here...", (value: string) => input.set(value));
// Optional: also let input.set("hello") update the field on screen.
stateBindTextfield(input, field);
Input control signatures:
TextField(placeholder, onChange)— text input,onChange: (value: string) => voidSecureField(placeholder, onChange)— password input,onChange: (value: string) => voidToggle(label, onChange)— boolean toggle,onChange: (value: boolean) => voidSlider(min, max, onChange)— numeric slider,onChange: (value: number) => voidPicker(onChange)— dropdown,onChange: (index: number) => void; items viapickerAddItem
For programmatic-to-UI sync (state-drives-widget) use the dedicated binders:
stateBindTextfield, stateBindSlider, stateBindToggle, stateBindTextNumeric,
stateBindVisibility.
onChange Callbacks
Listen for state changes with the free-function stateOnChange:
import { State, stateOnChange } from "perry/ui";
const count = State(0);
stateOnChange(count, (newValue: number) => {
console.log(`Count changed to ${newValue}`);
});
ForEach
Render a list from numeric state (the index count):
import { VStack, Text, ForEach, State } from "perry/ui";
const items = State(["Apple", "Banana", "Cherry"]);
const itemCount = State(3);
VStack(16, [
ForEach(itemCount, (i: number) =>
Text(`${i + 1}. ${items.value[i]}`)
),
]);
Note:
ForEachiterates by index over a numeric state. Keep a count state in sync with your array, then read the items viaarray.value[i]inside the closure.
ForEach re-renders the list when the count state changes:
// Add an item
items.set([...items.value, "Date"]);
itemCount.set(itemCount.value + 1);
// Remove an item
items.set(items.value.filter((_, i) => i !== 1));
itemCount.set(itemCount.value - 1);
Conditional Rendering
Use state to conditionally show widgets:
import { VStack, Text, Button, State } from "perry/ui";
const showDetails = State(false);
VStack(16, [
Button("Toggle", () => showDetails.set(!showDetails.value)),
showDetails.value ? Text("Details are visible!") : Spacer(),
]);
Multi-State Text
Text can depend on multiple state values:
const firstName = State("John");
const lastName = State("Doe");
Text(`Hello, ${firstName.value} ${lastName.value}!`);
// Updates when either firstName or lastName changes
State with Objects and Arrays
const user = State({ name: "Perry", age: 0 });
// Update by replacing the whole object
user.set({ ...user.value, age: 1 });
const todos = State<{ text: string; done: boolean }[]>([]);
// Add a todo
todos.set([...todos.value, { text: "New task", done: false }]);
// Toggle a todo
const items = todos.value;
items[0].done = !items[0].done;
todos.set([...items]);
Note: State uses identity comparison. You must create a new array/object reference for changes to be detected. Mutating in-place without calling
.set()with a new reference won’t trigger updates.
Complete Example
// demonstrates: complete reactive todo app combining State, ForEach, and widget tree mutation
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm
import {
App,
Text,
Button,
TextField,
VStack,
HStack,
State,
ForEach,
Spacer,
Divider,
} from "perry/ui"
const todos = State<string[]>([])
const count = State(0)
const input = State("")
App({
title: "Todo App",
width: 480,
height: 600,
body: VStack(16, [
Text("My Todos"),
HStack(8, [
TextField("What needs to be done?", (value: string) => input.set(value)),
Button("Add", () => {
const text = input.value
if (text.length > 0) {
todos.set([...todos.value, text])
count.set(count.value + 1)
input.set("")
}
}),
]),
Divider(),
ForEach(count, (i: number) =>
HStack(8, [
Text(todos.value[i]),
Spacer(),
Button("Delete", () => {
todos.set(todos.value.filter((_, idx) => idx !== i))
count.set(count.value - 1)
}),
]),
),
Spacer(),
Text(`${count.value} items`),
]),
})
This program is built and run by CI (scripts/run_doc_tests.sh), so the
snippet above always matches the compiled artifact under
docs/examples/ui/state/todo_app.ts.
Next Steps
Events
Perry widgets support native event handlers for user interaction.
onClick
import { Button, Text } from "perry/ui";
Button("Click me", () => {
console.log("Button clicked!");
});
// Or set it after creation
const label = Text("Clickable text");
label.setOnClick(() => {
console.log("Text clicked!");
});
onHover
Triggered when the mouse enters or leaves a widget.
import { Button } from "perry/ui";
const btn = Button("Hover me", () => {});
btn.setOnHover((isHovering) => {
if (isHovering) {
console.log("Mouse entered");
} else {
console.log("Mouse left");
}
});
Note: Hover events are available on macOS, Windows, Linux, and Web. iOS and Android use touch interactions instead.
onDoubleClick
import { Text } from "perry/ui";
const label = Text("Double-click me");
label.setOnDoubleClick(() => {
console.log("Double-clicked!");
});
Keyboard Shortcuts
Register in-app keyboard shortcuts (active when the app is focused):
import { addKeyboardShortcut } from "perry/ui";
// Cmd+N on macOS, Ctrl+N on other platforms
addKeyboardShortcut("n", 1, () => {
console.log("New document");
});
// Cmd+Shift+S (modifiers: 1=Cmd/Ctrl, 2=Shift, 4=Option/Alt, 8=Control)
addKeyboardShortcut("s", 3, () => {
console.log("Save as...");
});
Keyboard shortcuts are also supported in menu items:
menuAddItem(menu, "New", () => newDoc(), "n"); // Cmd+N
menuAddItem(menu, "Save As", () => saveAs(), "S"); // Cmd+Shift+S
Global Hotkeys
Register system-wide hotkeys that work even when the app is in the background — essential for launchers, clipboard managers, and quick-access tools:
import { registerGlobalHotkey } from "perry/ui";
// Cmd+Space (macOS) / Ctrl+Space (Windows)
registerGlobalHotkey("space", 1, () => {
// Show/hide your launcher
});
// Cmd+Shift+V (clipboard manager)
registerGlobalHotkey("v", 3, () => {
// Show clipboard history
});
Modifier bits: 1 = Cmd (macOS) / Ctrl (Windows), 2 = Shift, 4 = Option (macOS) / Alt (Windows), 8 = Control (macOS only). Combine by adding: 3 = Cmd+Shift, 5 = Cmd+Option, etc.
| Platform | Implementation |
|---|---|
| macOS | NSEvent.addGlobalMonitorForEvents + addLocalMonitorForEvents |
| Windows | RegisterHotKey + WM_HOTKEY dispatch in message loop |
| Linux | Not yet supported (requires X11 XGrabKey or Wayland portal) |
macOS note: Global event monitoring requires accessibility permissions. The user will see a system prompt on first use.
Linux note: Global hotkeys are a known limitation. On X11,
XGrabKeyis possible but not yet implemented. On Wayland, theGlobalShortcutsportal has limited compositor support.
Clipboard
import { clipboardGet, clipboardSet } from "perry/ui";
// Copy to clipboard
clipboardSet("Hello, clipboard!");
// Read from clipboard
const text = clipboardGet();
Complete Example
// demonstrates: click + hover + double-click + keyboard shortcut all wired to
// a single State-backed status label
// docs: docs/src/ui/events.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import {
App,
Text,
Button,
VStack,
State,
Spacer,
addKeyboardShortcut,
widgetSetOnHover,
widgetSetOnDoubleClick,
} from "perry/ui"
const lastEvent = State("No events yet")
// Cmd+R (modifiers: 1 = Cmd/Ctrl).
addKeyboardShortcut("r", 1, () => {
lastEvent.set("Keyboard: Cmd+R")
})
const hoverBtn = Button("Hover me", () => {})
widgetSetOnHover(hoverBtn, () => {
lastEvent.set("Hover fired")
})
const dblLabel = Text("Double-click me")
widgetSetOnDoubleClick(dblLabel, () => {
lastEvent.set("Double-clicked!")
})
App({
title: "Events Demo",
width: 400,
height: 300,
body: VStack(16, [
Text(`Last event: ${lastEvent.value}`),
Spacer(),
Button("Click me", () => {
lastEvent.set("Button clicked")
}),
hoverBtn,
dblLabel,
]),
})
Verified by CI. Note that event handlers are registered via free functions
(widgetSetOnHover(widget, cb)) rather than methods — the widget handle is
opaque and perry’s API is function-first throughout.
Next Steps
- Menus — Menu bar and context menus with keyboard shortcuts
- Widgets — All available widgets
- State Management — Reactive state
Canvas
The Canvas widget provides a 2D drawing surface for custom graphics.
Creating a Canvas
import { Canvas } from "perry/ui";
const canvas = Canvas(400, 300, (ctx) => {
// Drawing code here
ctx.fillRect(10, 10, 100, 80);
});
Canvas(width, height, drawCallback) creates a canvas and calls your drawing function.
Drawing Shapes
Rectangles
Canvas(400, 300, (ctx) => {
// Filled rectangle
ctx.setFillColor("#FF0000");
ctx.fillRect(10, 10, 100, 80);
// Stroked rectangle
ctx.setStrokeColor("#0000FF");
ctx.setLineWidth(2);
ctx.strokeRect(150, 10, 100, 80);
});
Lines
Canvas(400, 300, (ctx) => {
ctx.setStrokeColor("#000000");
ctx.setLineWidth(1);
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(200, 150);
ctx.stroke();
});
Circles and Arcs
Canvas(400, 300, (ctx) => {
ctx.setFillColor("#00FF00");
ctx.beginPath();
ctx.arc(200, 150, 50, 0, Math.PI * 2); // x, y, radius, startAngle, endAngle
ctx.fill();
});
Colors
Canvas(400, 300, (ctx) => {
ctx.setFillColor("#FF6600"); // Hex color
ctx.setStrokeColor("#333333");
ctx.setLineWidth(3);
});
Gradients
Canvas(400, 300, (ctx) => {
ctx.setGradient("#FF0000", "#0000FF"); // Start color, end color
ctx.fillRect(0, 0, 400, 300);
});
Text on Canvas
Canvas(400, 300, (ctx) => {
ctx.setFillColor("#000000");
ctx.fillText("Hello Canvas!", 50, 50);
});
Platform Notes
| Platform | Implementation |
|---|---|
| macOS | Core Graphics (CGContext) |
| iOS | Core Graphics (CGContext) |
| Linux | Cairo |
| Windows | GDI |
| Android | Canvas/Bitmap |
| Web | HTML5 Canvas |
Complete Example
import { App, Canvas, VStack } from "perry/ui";
App({
title: "Canvas Demo",
width: 400,
height: 320,
body: VStack(0, [
Canvas(400, 300, (ctx) => {
// Background
ctx.setFillColor("#1A1A2E");
ctx.fillRect(0, 0, 400, 300);
// Sun
ctx.setFillColor("#FFD700");
ctx.beginPath();
ctx.arc(300, 80, 40, 0, Math.PI * 2);
ctx.fill();
// Ground
ctx.setFillColor("#2D5016");
ctx.fillRect(0, 220, 400, 80);
// Tree trunk
ctx.setFillColor("#8B4513");
ctx.fillRect(80, 150, 20, 70);
// Tree top
ctx.setFillColor("#228B22");
ctx.beginPath();
ctx.arc(90, 130, 40, 0, Math.PI * 2);
ctx.fill();
}),
]),
});
Next Steps
Menus
Perry supports native menu bars, context menus, and toolbar items across all platforms.
Menu Bar
Create a native application menu bar:
import { App, VStack, Text, menuBarCreate, menuBarAddMenu, menuAddItem, menuAddSeparator, menuAddSubmenu, menuBarAttach } from "perry/ui";
// Build the menu bar before App(...)
const menuBar = menuBarCreate();
// File menu
const fileMenu = menuBarAddMenu(menuBar, "File");
menuAddItem(fileMenu, "New", () => newDoc(), "n"); // Cmd+N
menuAddItem(fileMenu, "Open", () => openDoc(), "o"); // Cmd+O
menuAddSeparator(fileMenu);
menuAddItem(fileMenu, "Save", () => saveDoc(), "s"); // Cmd+S
menuAddItem(fileMenu, "Save As...", () => saveAs(), "S"); // Cmd+Shift+S
// Edit menu
const editMenu = menuBarAddMenu(menuBar, "Edit");
menuAddItem(editMenu, "Undo", () => undo(), "z");
menuAddItem(editMenu, "Redo", () => redo(), "Z"); // Cmd+Shift+Z
menuAddSeparator(editMenu);
menuAddItem(editMenu, "Cut", () => cut(), "x");
menuAddItem(editMenu, "Copy", () => copy(), "c");
menuAddItem(editMenu, "Paste", () => paste(), "v");
// Submenu
const viewMenu = menuBarAddMenu(menuBar, "View");
const zoomSubmenu = menuAddSubmenu(viewMenu, "Zoom");
menuAddItem(zoomSubmenu, "Zoom In", () => zoomIn(), "+");
menuAddItem(zoomSubmenu, "Zoom Out", () => zoomOut(), "-");
menuAddItem(zoomSubmenu, "Actual Size", () => zoomReset(), "0");
menuBarAttach(menuBar);
App({
title: "Menu Demo",
width: 800,
height: 600,
body: VStack(16, [
Text("App content here"),
]),
});
Menu Bar Functions
menuBarCreate()— Create a new menu barmenuBarAddMenu(menuBar, title)— Add a top-level menu, returns menu handlemenuAddItem(menu, label, callback, shortcut?)— Add a menu item with optional keyboard shortcutmenuAddSeparator(menu)— Add a separator linemenuAddSubmenu(menu, title)— Add a submenu, returns submenu handlemenuBarAttach(menuBar)— Attach the menu bar to the application
Keyboard Shortcuts
The 4th argument to menuAddItem is an optional keyboard shortcut:
| Shortcut | macOS | Other |
|---|---|---|
"n" | Cmd+N | Ctrl+N |
"S" | Cmd+Shift+S | Ctrl+Shift+S |
"+" | Cmd++ | Ctrl++ |
Uppercase letters imply Shift.
Context Menus
Right-click menus on widgets:
import { Text, contextMenu } from "perry/ui";
const label = Text("Right-click me");
contextMenu(label, [
{ label: "Copy", action: () => copyText() },
{ label: "Paste", action: () => pasteText() },
{ separator: true },
{ label: "Delete", action: () => deleteItem() },
]);
Toolbar
Add a toolbar to the window:
import { App, VStack, Text, toolbarCreate, toolbarAddItem } from "perry/ui";
const toolbar = toolbarCreate();
toolbarAddItem(toolbar, "New", () => newDoc());
toolbarAddItem(toolbar, "Save", () => saveDoc());
toolbarAddItem(toolbar, "Run", () => runCode());
App({
title: "Toolbar Demo",
width: 800,
height: 600,
body: VStack(16, [
Text("App content here"),
]),
});
Platform Notes
| Platform | Menu Bar | Context Menu | Toolbar |
|---|---|---|---|
| macOS | NSMenu | NSMenu | NSToolbar |
| iOS | — (no menu bar) | UIMenu | UIToolbar |
| Windows | HMENU/SetMenu | — | Horizontal layout |
| Linux | GMenu/set_menubar | — | HeaderBar |
| Web | DOM | DOM | DOM |
iOS: Menu bars are not applicable. Use toolbar and navigation patterns instead.
Next Steps
- Events — Keyboard shortcuts and interactions
- Dialogs — File dialogs and alerts
- Toolbar and navigation
Dialogs
Perry provides native dialog functions for file selection, alerts, and sheets.
File Open Dialog
import { openFileDialog } from "perry/ui";
const filePath = openFileDialog();
if (filePath) {
console.log(`Selected: ${filePath}`);
}
Returns the selected file path, or null if cancelled.
Folder Selection Dialog
import { openFolderDialog } from "perry/ui";
const folderPath = openFolderDialog();
if (folderPath) {
console.log(`Selected folder: ${folderPath}`);
}
Save File Dialog
import { saveFileDialog } from "perry/ui";
const savePath = saveFileDialog();
if (savePath) {
// Write file to savePath
}
Alert
Display a native alert dialog:
import { alert } from "perry/ui";
alert("Operation Complete", "Your file has been saved successfully.");
alert(title, message) shows a modal alert with an OK button.
Sheets
Sheets are modal panels attached to a window:
import { Sheet, Text, Button, VStack } from "perry/ui";
const sheet = Sheet(
VStack(16, [
Text("Sheet Content"),
Button("Close", () => {
sheet.dismiss();
}),
])
);
// Show the sheet
sheet.present();
Platform Notes
| Dialog | macOS | iOS | Windows | Linux | Web |
|---|---|---|---|---|---|
| File Open | NSOpenPanel | UIDocumentPicker | IFileOpenDialog | GtkFileChooserDialog | <input type="file"> |
| File Save | NSSavePanel | — | IFileSaveDialog | GtkFileChooserDialog | Download link |
| Folder | NSOpenPanel | — | IFileOpenDialog | GtkFileChooserDialog | — |
| Alert | NSAlert | UIAlertController | MessageBoxW | MessageDialog | alert() |
| Sheet | NSSheet | Modal VC | Modal Dialog | Modal Window | Modal div |
Complete Example
import { App, Text, Button, TextField, VStack, HStack, State, openFileDialog, saveFileDialog, alert } from "perry/ui";
import { readFileSync, writeFileSync } from "perry/fs";
const content = State("");
const filePath = State("");
App({
title: "Text Editor",
width: 800,
height: 600,
body: VStack(12, [
HStack(8, [
Button("Open", () => {
const path = openFileDialog();
if (path) {
filePath.set(path);
content.set(readFileSync(path));
}
}),
Button("Save As", () => {
const path = saveFileDialog();
if (path) {
writeFileSync(path, content.value);
filePath.set(path);
alert("Saved", `File saved to ${path}`);
}
}),
]),
Text(`File: ${filePath.value || "No file open"}`),
TextField("Start typing...", (value: string) => content.set(value)),
]),
});
Next Steps
- Menus — Menu bar and context menus
- Multi-Window — Multiple windows
- Events — User interaction events
Table
The Table widget displays tabular data with columns, headers, and row selection.
Creating a Table
import { Table } from "perry/ui";
const table = Table(10, 3, (row, col) => {
return `Row ${row}, Col ${col}`;
});
Table(rowCount, colCount, renderCell) creates a table. The render function is called for each cell and should return the text content.
Column Headers
const table = Table(100, 3, (row, col) => {
const data = [
["Alice", "alice@example.com", "Admin"],
["Bob", "bob@example.com", "User"],
// ...
];
return data[row]?.[col] ?? "";
});
table.setColumnHeader(0, "Name");
table.setColumnHeader(1, "Email");
table.setColumnHeader(2, "Role");
Column Widths
table.setColumnWidth(0, 150); // Name column
table.setColumnWidth(1, 250); // Email column
table.setColumnWidth(2, 100); // Role column
Row Selection
table.setOnRowSelect((row) => {
console.log(`Selected row: ${row}`);
});
// Get the currently selected row
const selected = table.getSelectedRow();
Dynamic Row Count
Update the number of rows after creation:
table.updateRowCount(newCount);
Platform Notes
| Platform | Implementation |
|---|---|
| macOS | NSTableView + NSScrollView |
| Web | HTML <table> |
| iOS/Android/Linux/Windows | Stubs (pending native implementation) |
Complete Example
import { App, Table, Text, VStack, State } from "perry/ui";
const selectedName = State("None");
const users = [
{ name: "Alice", email: "alice@example.com", role: "Admin" },
{ name: "Bob", email: "bob@example.com", role: "Editor" },
{ name: "Charlie", email: "charlie@example.com", role: "Viewer" },
{ name: "Diana", email: "diana@example.com", role: "Admin" },
{ name: "Eve", email: "eve@example.com", role: "Editor" },
];
const table = Table(users.length, 3, (row, col) => {
const user = users[row];
if (col === 0) return user.name;
if (col === 1) return user.email;
return user.role;
});
table.setColumnHeader(0, "Name");
table.setColumnHeader(1, "Email");
table.setColumnHeader(2, "Role");
table.setColumnWidth(0, 150);
table.setColumnWidth(1, 250);
table.setColumnWidth(2, 100);
table.setOnRowSelect((row) => {
selectedName.set(users[row].name);
});
App({
title: "Table Demo",
width: 600,
height: 400,
body: VStack(12, [
table,
Text(`Selected: ${selectedName.value}`),
]),
});
Next Steps
Animation
Perry supports animating widget properties for smooth transitions.
Opacity Animation
import { Text } from "perry/ui";
const label = Text("Fading text");
// Animate from the widget's current opacity to `target` over `durationSecs`.
label.animateOpacity(1.0, 0.3); // target, durationSeconds
Position Animation
import { Button } from "perry/ui";
const btn = Button("Moving", () => {});
// Animate by a delta (dx, dy) relative to the widget's current position.
btn.animatePosition(100, 200, 0.5); // dx, dy, durationSeconds
Example: Fade-In Effect
When the first argument reads from a State.value, Perry auto-subscribes
the call to the state — toggling visible re-runs the animation.
// demonstrates: auto-reactive animateOpacity driven by a State toggle
// docs: docs/src/ui/animation.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm
import { App, Text, Button, VStack, State } from "perry/ui"
const visible = State(false)
const label = Text("Hello!")
label.animateOpacity(visible.value ? 1.0 : 0.0, 0.3)
App({
title: "Animation Demo",
width: 400,
height: 300,
body: VStack(16, [
Button("Toggle", () => {
visible.set(!visible.value)
}),
label,
]),
})
Platform Notes
| Platform | Implementation |
|---|---|
| macOS | NSAnimationContext / ViewPropertyAnimator |
| iOS | UIView.animate |
| Android | ViewPropertyAnimator |
| Windows | WM_TIMER-based animation |
| Linux | CSS transitions (GTK4) |
| Web | CSS transitions |
Next Steps
Multi-Window & Window Management
Perry supports creating multiple native windows and controlling their appearance and behavior.
Creating Windows
import { App, Window, Text, Button, VStack } from "perry/ui";
const win = Window("Settings", 500, 400);
win.setBody(VStack(16, [
Text("Settings panel"),
]));
win.show();
App({
title: "My App",
width: 800,
height: 600,
body: VStack(16, [
Text("Main Window"),
Button("Open Settings", () => win.show()),
]),
});
Window(title, width, height) creates a new native window. Call .setBody() to set its content and .show() to display it.
Window Instance Methods
const win = Window("My Window", 600, 400);
win.setBody(widget); // Set the root widget
win.show(); // Show the window
win.hide(); // Hide without destroying
win.closeWindow(); // Close and destroy
win.onFocusLost(() => { // Called when window loses focus
win.hide();
});
App Window Properties
The main App({}) config object supports several window properties for building launcher-style, overlay, or utility apps:
import { App, Text, VStack } from "perry/ui";
App({
title: "QuickLaunch",
width: 600,
height: 80,
frameless: true,
level: "floating",
transparent: true,
vibrancy: "sidebar",
activationPolicy: "accessory",
body: VStack(8, [
Text("Search..."),
]),
});
frameless: true
Removes the window title bar and frame, creating a borderless window.
| Platform | Implementation |
|---|---|
| macOS | NSWindowStyleMask::Borderless + movable by background |
| Windows | WS_POPUP window style |
| Linux | set_decorated(false) |
level: "floating" | "statusBar" | "modal" | "normal"
Controls the window’s z-order level relative to other windows.
| Level | Description |
|---|---|
"normal" | Default window level |
"floating" | Stays above normal windows |
"statusBar" | Stays above floating windows |
"modal" | Modal panel level |
| Platform | Implementation |
|---|---|
| macOS | NSWindow.level (NSFloatingWindowLevel, etc.) |
| Windows | SetWindowPos with HWND_TOPMOST |
| Linux | set_modal(true) (best-effort) |
transparent: true
Makes the window background transparent, allowing the desktop to show through non-opaque regions of your UI.
| Platform | Implementation |
|---|---|
| macOS | isOpaque = false, backgroundColor = .clear |
| Windows | WS_EX_LAYERED with SetLayeredWindowAttributes |
| Linux | CSS background-color: transparent |
vibrancy: string
Applies a native translucent material to the window background. On macOS this uses the system vibrancy effect; on Windows it uses Mica/Acrylic.
macOS materials: "sidebar", "titlebar", "selection", "menu", "popover", "headerView", "sheet", "windowBackground", "hudWindow", "fullScreenUI", "tooltip", "contentBackground", "underWindowBackground", "underPageBackground"
| Platform | Implementation |
|---|---|
| macOS | NSVisualEffectView with the specified material |
| Windows | DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE) — Mica, Acrylic, or Mica Alt depending on material (Windows 11 22H2+) |
| Linux | CSS alpha(@window_bg_color, 0.85) (best-effort) |
activationPolicy: "regular" | "accessory" | "background"
Controls whether the app appears in the dock/taskbar.
| Policy | Description |
|---|---|
"regular" | Normal app with dock icon and menu bar (default) |
"accessory" | No dock icon, no menu bar activation — ideal for launchers and utilities |
"background" | Fully hidden from dock and app switcher |
| Platform | Implementation |
|---|---|
| macOS | NSApp.setActivationPolicy() |
| Windows | WS_EX_TOOLWINDOW (removes from taskbar) |
| Linux | set_deletable(false) (best-effort) |
Standalone Window Functions
Window management is also available as standalone functions for use with window handles:
import { Window, windowHide, windowSetSize, onWindowFocusLost } from "perry/ui";
const win = Window("Panel", 400, 300);
// Hide/show
windowHide(win);
// Resize dynamically
windowSetSize(win, 600, computedHeight);
// React to focus loss
onWindowFocusLost(win, () => {
windowHide(win);
});
Platform Notes
| Platform | Implementation |
|---|---|
| macOS | NSWindow |
| Windows | CreateWindowEx (HWND) |
| Linux | GtkWindow |
| Web | Floating <div> |
| iOS/Android | Modal view controller / Dialog |
On mobile platforms, “windows” are presented as modal views or dialogs since mobile apps typically use a single-window model.
Next Steps
- Events — Global hotkeys and keyboard shortcuts
- Dialogs — Modal dialogs and sheets
- Menus — Menu bar and toolbar
- UI Overview — Full UI system overview
Theming
The perry-styling package provides a design system bridge for Perry UI — design token codegen and ergonomic styling helpers with compile-time platform detection.
Installation
npm install perry-styling
Design Token Codegen
Generate typed theme files from a JSON token definition:
perry-styling generate --tokens tokens.json --out src/theme.ts
Token Format
{
"colors": {
"primary": "#007AFF",
"primary-dark": "#0A84FF",
"background": "#FFFFFF",
"background-dark": "#1C1C1E",
"text": "#000000",
"text-dark": "#FFFFFF"
},
"spacing": {
"sm": 4,
"md": 8,
"lg": 16,
"xl": 24
},
"radius": {
"sm": 4,
"md": 8,
"lg": 16
},
"fontSize": {
"body": 14,
"heading": 20,
"caption": 12
},
"borderWidth": {
"thin": 1,
"medium": 2
}
}
Colors with a -dark suffix are used as the dark mode variant. If no dark variant is provided, the light value is used for both modes. Supported color formats: hex (#RGB, #RRGGBB, #RRGGBBAA), rgb()/rgba(), hsl()/hsla(), and CSS named colors.
Generated Types
The codegen produces typed interfaces:
interface PerryColor {
r: number; g: number; b: number; a: number; // floats in [0, 1]
}
interface PerryTheme {
light: { [key: string]: PerryColor };
dark: { [key: string]: PerryColor };
spacing: { [key: string]: number };
radius: { [key: string]: number };
fontSize: { [key: string]: number };
borderWidth: { [key: string]: number };
}
interface ResolvedTheme {
colors: { [key: string]: PerryColor };
spacing: { [key: string]: number };
radius: { [key: string]: number };
fontSize: { [key: string]: number };
borderWidth: { [key: string]: number };
}
Theme Resolution
Resolve a theme at runtime based on the system’s dark mode setting:
import { getTheme } from "perry-styling";
import { theme } from "./theme"; // generated file
const resolved = getTheme(theme);
// resolved.colors.primary → the correct light/dark variant
getTheme() calls isDarkMode() from perry/system and returns the appropriate palette.
Styling Helpers
Ergonomic functions for applying styles to widget handles:
import { applyBg, applyRadius, applyTextColor, applyFontSize, applyGradient } from "perry-styling";
const label = Text("Hello");
applyTextColor(label, resolved.colors.text);
applyFontSize(label, resolved.fontSize.heading);
const card = VStack(16, [/* ... */]);
applyBg(card, resolved.colors.background);
applyRadius(card, resolved.radius.md);
applyGradient(card, startColor, endColor, 0); // 0=vertical, 1=horizontal
Available Helpers
| Function | Description |
|---|---|
applyBg(widget, color) | Set background color |
applyRadius(widget, radius) | Set corner radius |
applyTextColor(widget, color) | Set text color |
applyFontSize(widget, size) | Set font size |
applyFontBold(widget) | Set bold font weight |
applyFontFamily(widget, family) | Set font family |
applyWidth(widget, width) | Set width |
applyTooltip(widget, text) | Set tooltip text |
applyBorderColor(widget, color) | Set border color |
applyBorderWidth(widget, width) | Set border width |
applyEdgeInsets(widget, t, r, b, l) | Set edge insets (padding) |
applyOpacity(widget, alpha) | Set opacity |
applyGradient(widget, start, end, dir) | Set gradient (0=vertical, 1=horizontal) |
applyButtonBg(btn, color) | Set button background |
applyButtonTextColor(btn, color) | Set button text color |
applyButtonBordered(btn) | Set bordered button style |
Platform Constants
perry-styling exports compile-time platform constants based on the __platform__ built-in:
import { isMac, isIOS, isAndroid, isWindows, isLinux, isDesktop, isMobile } from "perry-styling";
if (isMobile) {
applyFontSize(label, 16);
} else {
applyFontSize(label, 14);
}
These are constant-folded by LLVM at compile time — dead branches are eliminated with zero runtime cost.
Next Steps
- Styling — Widget styling basics
- State Management — Reactive bindings
Camera
The perry/ui module provides a live camera preview widget with color sampling capabilities.
import { CameraView, cameraStart, cameraStop, cameraFreeze, cameraUnfreeze, cameraSampleColor, cameraSetOnTap } from "perry/ui";
Platform support: iOS only. Other platforms are planned.
Quick Example
import { App, VStack, Text, State } from "perry/ui";
import { CameraView, cameraStart, cameraStop, cameraSampleColor, cameraSetOnTap } from "perry/ui";
const colorHex = State("#000000");
const cam = CameraView();
cameraStart(cam);
cameraSetOnTap(cam, (x, y) => {
const rgb = cameraSampleColor(x, y);
if (rgb >= 0) {
const r = Math.floor(rgb / 65536);
const g = Math.floor((rgb % 65536) / 256);
const b = Math.floor(rgb % 256);
colorHex.set(`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`);
}
});
App({
title: "Color Picker",
width: 400,
height: 600,
body: VStack(16, [
cam,
Text(`Color: ${colorHex.value}`),
]),
});
API Reference
CameraView()
Create a live camera preview widget.
const cam = CameraView();
Returns a widget handle. The camera does not start automatically — call cameraStart() to begin capture.
cameraStart(handle)
Start the live camera feed.
cameraStart(cam);
On iOS, the camera permission dialog is shown automatically on first use.
cameraStop(handle)
Stop the camera feed and release the capture session.
cameraStop(cam);
cameraFreeze(handle)
Pause the live preview (freeze the current frame).
cameraFreeze(cam);
The camera session remains active but the preview stops updating. Useful for “capture” moments where you want to inspect the frozen frame.
cameraUnfreeze(handle)
Resume the live preview after a freeze.
cameraUnfreeze(cam);
cameraSampleColor(x, y)
Sample the pixel color at normalized coordinates.
const rgb = cameraSampleColor(0.5, 0.5); // center of frame
x,yare normalized coordinates (0.0–1.0)- Returns packed RGB as a number:
r * 65536 + g * 256 + b - Returns
-1if no frame is available
To extract individual channels:
const r = Math.floor(rgb / 65536);
const g = Math.floor((rgb % 65536) / 256);
const b = Math.floor(rgb % 256);
The color is averaged over a 5x5 pixel region around the sample point for noise reduction.
cameraSetOnTap(handle, callback)
Register a tap handler on the camera view.
cameraSetOnTap(cam, (x, y) => {
// x, y are normalized coordinates (0.0-1.0)
const rgb = cameraSampleColor(x, y);
});
The callback receives normalized coordinates of the tap location, which can be passed directly to cameraSampleColor().
Implementation
On iOS, the camera uses AVCaptureSession with AVCaptureVideoPreviewLayer for GPU-accelerated live preview, and AVCaptureVideoDataOutput for frame capture. Color sampling reads pixel data from CVPixelBuffer.
Next Steps
- Widgets — All available widgets
- Audio Capture — Microphone input and sound metering
Platform Overview
Perry compiles TypeScript to native executables for 9 platform families from the same source code.
Supported Platforms
| Platform | Target Flag | UI Toolkit | Status |
|---|---|---|---|
| macOS | (default) | AppKit | Full support (127/127 FFI functions) |
| iOS | --target ios / --target ios-simulator | UIKit | Full support (127/127) |
| visionOS | --target visionos / --target visionos-simulator | UIKit (2D windows) | Core support (2D only) |
| tvOS | --target tvos / --target tvos-simulator | UIKit | Full support (focus engine + game controllers) |
| watchOS | --target watchos / --target watchos-simulator | SwiftUI (data-driven) | Core support (15 widgets) |
| Android | --target android | JNI/Android SDK | Full support (112/112) |
| Windows | --target windows | Win32 | Full support (112/112) |
| Linux | --target linux | GTK4 | Full support (112/112) |
| Web / WebAssembly | --target web (alias --target wasm) | DOM/CSS via WASM bridge | Full support (168 widgets) |
Cross-Compilation
# Default: compile for current platform
perry app.ts -o app
# Compile for a specific target
perry app.ts -o app --target ios-simulator
perry app.ts -o app --target visionos-simulator
perry app.ts -o app --target tvos-simulator
perry app.ts -o app --target watchos-simulator
perry app.ts -o app --target web # alias: --target wasm
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android
Platform Detection
Use the __platform__ compile-time constant to branch by platform:
declare const __platform__: number;
// Platform constants:
// 0 = macOS
// 1 = iOS
// 2 = Android
// 3 = Windows
// 4 = Linux
// 5 = Web (browser, --target web / --target wasm)
// 6 = tvOS
// 7 = watchOS
// 8 = visionOS
if (__platform__ === 0) {
console.log("Running on macOS");
} else if (__platform__ === 1) {
console.log("Running on iOS");
} else if (__platform__ === 3) {
console.log("Running on Windows");
}
__platform__ is resolved at compile time. The compiler constant-folds comparisons and eliminates dead branches, so platform-specific code has zero runtime cost.
Platform Feature Matrix
| Feature | macOS | iOS | visionOS | tvOS | watchOS | Android | Windows | Linux | Web (WASM) |
|---|---|---|---|---|---|---|---|---|---|
| CLI programs | Yes | — | — | — | — | — | Yes | Yes | — |
| Native UI (DOM on web) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Game engines | Yes | Yes | — | Yes | — | Yes | Yes | Yes | Via FFI |
| File system | Yes | Sandboxed | Sandboxed | Sandboxed | — | Sandboxed | Yes | Yes | File System Access API |
| Networking | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | fetch / WebSocket |
| System APIs | Yes | Partial | Partial | Partial | Minimal | Partial | Yes | Yes | Partial |
| Widgets (WidgetKit) | — | Yes | — | — | Yes | — | — | — | — |
| Threading | Native | Native | Native | Native | Native | Native | Native | Native | Web Workers |
Next Steps
macOS
macOS is Perry’s primary development platform. It uses AppKit for native UI.
Requirements
- macOS 13+ (Ventura or later)
- Xcode Command Line Tools:
xcode-select --install
Building
# macOS is the default target
perry app.ts -o app
./app
No additional flags needed — macOS is the default compilation target.
UI Toolkit
Perry maps UI widgets to AppKit controls:
| Perry Widget | AppKit Class |
|---|---|
| Text | NSTextField (label mode) |
| Button | NSButton |
| TextField | NSTextField |
| SecureField | NSSecureTextField |
| Toggle | NSSwitch |
| Slider | NSSlider |
| Picker | NSPopUpButton |
| Image | NSImageView |
| VStack/HStack | NSStackView |
| ScrollView | NSScrollView |
| Table | NSTableView |
| Canvas | NSView + Core Graphics |
Code Signing
For distribution, apps need to be signed. Perry supports automatic signing:
perry publish
This auto-detects your signing identity from the macOS Keychain, exports it to a temporary .p12 file, and signs the binary.
For manual signing:
codesign --sign "Developer ID Application: Your Name" ./app
App Store Distribution
perry app.ts -o MyApp
# Sign with App Store certificate
codesign --sign "3rd Party Mac Developer Application: Your Name" MyApp
# Package
productbuild --sign "3rd Party Mac Developer Installer: Your Name" --component MyApp /Applications MyApp.pkg
macOS-Specific Features
- Menu bar: Full NSMenu support with keyboard shortcuts
- Toolbar: NSToolbar integration
- Dock icon: Automatic for GUI apps
- Dark mode:
isDarkMode()detects system appearance - Keychain: Secure storage via Security.framework
- Notifications: Local notifications via UNUserNotificationCenter
- File dialogs: NSOpenPanel/NSSavePanel
System APIs
import { openURL, isDarkMode, preferencesSet, preferencesGet } from "perry/system";
openURL("https://example.com"); // Opens in default browser
const dark = isDarkMode(); // Check appearance
preferencesSet("key", "value"); // NSUserDefaults
const val = preferencesGet("key"); // NSUserDefaults
Next Steps
- iOS — Cross-compile for iPhone/iPad
- UI Overview — Full UI documentation
- System APIs — System integration
iOS
Perry can cross-compile TypeScript apps for iOS devices and the iOS Simulator.
Requirements
- macOS host (cross-compilation from Linux/Windows is not supported)
- Xcode (full install, not just Command Line Tools) for iOS SDK and Simulator
- Rust iOS targets:
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
Building for Simulator
perry app.ts -o app --target ios-simulator
This uses LLVM cross-compilation with the iOS Simulator SDK. The binary can be run in the Xcode Simulator.
Building for Device
perry app.ts -o app --target ios
This produces an ARM64 binary for physical iOS devices. You’ll need to code sign and package it in an .app bundle for deployment.
Running with perry run
The easiest way to build and run on iOS is perry run:
perry run ios # Auto-detect device/simulator
perry run ios --console # Stream live stdout/stderr
perry run ios --remote # Use Perry Hub build server
Perry auto-discovers available simulators (via simctl) and physical devices (via devicectl). When multiple targets are found, an interactive prompt lets you choose.
For physical devices, Perry handles code signing automatically — it reads your signing identity and team ID from ~/.perry/config.toml (set up via perry setup ios), embeds the provisioning profile, and signs the .app before installing.
If you don’t have the iOS cross-compilation toolchain installed locally, perry run ios automatically falls back to Perry Hub’s remote build server.
UI Toolkit
Perry maps UI widgets to UIKit controls:
| Perry Widget | UIKit Class |
|---|---|
| Text | UILabel |
| Button | UIButton (TouchUpInside) |
| TextField | UITextField |
| SecureField | UITextField (secureTextEntry) |
| Toggle | UISwitch |
| Slider | UISlider (Float32, cast at boundary) |
| Picker | UIPickerView |
| Image | UIImageView |
| VStack/HStack | UIStackView |
| ScrollView | UIScrollView |
App Lifecycle
iOS apps use UIApplicationMain with a deferred creation pattern:
import { App, Text, VStack } from "perry/ui";
App({
title: "My iOS App",
width: 400,
height: 800,
body: VStack(16, [
Text("Hello, iPhone!"),
]),
});
The App() call triggers UIApplicationMain, and your render function is called via PerryAppDelegate once the app is ready.
iOS Widgets (WidgetKit)
Perry can compile TypeScript widget declarations to native SwiftUI WidgetKit extensions:
perry widget.ts --target ios-widget
See Widgets (WidgetKit) for details.
Splash Screen
Perry auto-generates a native LaunchScreen.storyboard from the perry.splash config in package.json. The splash screen appears instantly during cold start.
{
"perry": {
"splash": {
"image": "logo/icon-256.png",
"background": "#FFF5EE"
}
}
}
The image is centered at 128x128pt with scaleAspectFit. You can provide a custom storyboard for full control:
{
"perry": {
"splash": {
"ios": { "storyboard": "splash/LaunchScreen.storyboard" }
}
}
}
See Project Configuration for the full config reference.
Resource Bundling
Perry automatically bundles logo/ and assets/ directories from your project root into the .app bundle. These resources are available at runtime via standard file APIs relative to the app bundle path.
Keyboard Avoidance
Perry apps automatically handle keyboard avoidance on iOS. When the keyboard appears, the root view adjusts its bottom constraint with an animated layout transition, and focused TextFields are auto-scrolled into view above the keyboard.
Differences from macOS
- No menu bar: iOS doesn’t support menu bars. Use toolbar or navigation patterns.
- Touch events:
onHoveris not available. UseonClick(mapped to touch). - Slider precision: iOS UISlider uses Float32 internally (automatically converted).
- File dialogs: Limited to UIDocumentPicker.
- Keyboard shortcuts: Not applicable on iOS.
Next Steps
- Widgets (WidgetKit) — iOS home screen widgets
- Platform Overview — All platforms
- UI Overview — UI system
visionOS
Perry can compile TypeScript apps for Apple Vision Pro devices and the visionOS Simulator.
This first pass targets 2D windowed apps only. Perry uses the same UIKit-style perry/ui model as iOS, packaged for visionOS app bundles and scene lifecycle.
Prerequisites
- macOS with Xcode installed
- Rust visionOS targets:
rustup target add aarch64-apple-visionos aarch64-apple-visionos-sim
Compile
perry compile app.ts -o app --target visionos-simulator
perry compile app.ts -o app --target visionos
This produces a .app bundle with visionOS-specific Info.plist metadata and a UIWindowScene configuration.
Run
perry run visionos
perry run visionos --simulator <UDID>
perry run visionos --device <UDID>
Perry auto-detects booted Apple Vision Pro simulators via simctl. Physical device installs use devicectl, like other modern Apple platforms.
Configuration
Configure visionOS-specific settings in perry.toml:
[visionos]
bundle_id = "com.example.myvisionapp"
deployment_target = "1.0"
entry = "src/main_visionos.ts"
encryption_exempt = true
Custom Info.plist keys can be merged through [visionos.info_plist].
Platform Detection
Use __platform__ === 8 to detect visionOS at compile time:
declare const __platform__: number;
if (__platform__ === 8) {
console.log("Running on visionOS");
}
Current Scope
- Supported: 2D windowed apps, simulator/device app bundles,
perry run,perry setup,perry publish - Not supported yet: immersive spaces, volumes, RealityKit scene generation, Geisterhand
Related
- iOS — shared UIKit foundation
- Platform Overview
tvOS
Perry can compile TypeScript apps for Apple TV devices and the tvOS Simulator.
tvOS uses UIKit (the same framework as iOS), so Perry’s tvOS support shares the same UIKit-based widget system. The primary difference is input: Apple TV apps are controlled via the Siri Remote and game controllers rather than touch, and all apps run full-screen.
Requirements
- macOS host (cross-compilation from Linux/Windows is not supported)
- Xcode (full install) for tvOS SDK and Simulator
- Rust tvOS targets:
rustup target add aarch64-apple-tvos aarch64-apple-tvos-sim
Building for Simulator
perry compile app.ts -o app --target tvos-simulator
This produces an ARM64 binary linked with clang against the tvOS Simulator SDK, wrapped in a .app bundle.
Building for Device
perry compile app.ts -o app --target tvos
This produces an ARM64 binary for physical Apple TV hardware.
Running with perry run
perry run tvos # Auto-detect booted Apple TV simulator
perry run tvos --simulator <UDID> # Target a specific simulator
Perry auto-discovers booted Apple TV simulators. To install and launch manually:
xcrun simctl install booted app.app
xcrun simctl launch booted com.perry.app
UI Toolkit
Perry maps UI widgets to UIKit controls on tvOS, identical to iOS:
| Perry Widget | UIKit Class | Notes |
|---|---|---|
| Text | UILabel | |
| Button | UIButton | Focus-based navigation |
| TextField | UITextField | On-screen keyboard via Siri Remote |
| Toggle | UISwitch | |
| Slider | UISlider | |
| Picker | UIPickerView | |
| Image | UIImageView | |
| VStack/HStack | UIStackView | |
| ScrollView | UIScrollView | Focus-based scrolling |
Focus Engine
tvOS uses a focus-based navigation model instead of direct touch. The Siri Remote’s touchpad and directional buttons move focus between focusable views. Perry widgets that support interaction (buttons, text fields, toggles, etc.) are automatically focusable.
Game Engine Support
tvOS is particularly well-suited for game engines. When using a native library like Bloom, the game engine handles its own windowing, rendering, and input:
import { initWindow, windowShouldClose, beginDrawing, endDrawing,
clearBackground, isGamepadButtonDown, Colors } from "bloom";
initWindow(1920, 1080, "My Apple TV Game");
while (!windowShouldClose()) {
beginDrawing();
clearBackground(Colors.BLACK);
if (isGamepadButtonDown(0)) {
// A button (Siri Remote select) pressed
}
endDrawing();
}
Input on tvOS
The Siri Remote acts as a game controller:
| Input | Mapping |
|---|---|
| Touchpad swipe | Gamepad axes 0/1 (left stick) |
| Touchpad click (Select) | Gamepad button 0 (A) + mouse button 0 |
| Menu button | Gamepad button 1 (B) |
| Play/Pause button | Gamepad button 9 (Start) |
| Arrow presses (up/down/left/right) | Gamepad D-pad buttons (12-15) |
Extended game controllers (MFi, PlayStation, Xbox) are fully supported with all axes, buttons, triggers, and D-pad mapped through the standard gamepad API.
App Lifecycle
tvOS apps use UIApplicationMain with the same lifecycle as iOS. When using perry/ui:
import { App, Text, VStack } from "perry/ui";
App({
title: "My TV App",
width: 1920,
height: 1080,
body: VStack(16, [
Text("Hello, Apple TV!"),
]),
});
When using a game engine with --features ios-game-loop, the runtime starts UIApplicationMain on the main thread and runs your game code on a dedicated game thread.
Configuration
Configure tvOS settings in perry.toml:
[tvos]
bundle_id = "com.example.mytvapp"
deployment_target = "17.0"
Platform Detection
Use __platform__ === 6 to detect tvOS at compile time:
declare const __platform__: number;
if (__platform__ === 6) {
console.log("Running on tvOS");
}
App Bundle
Perry generates a .app bundle with an Info.plist containing:
| Key | Value | Notes |
|---|---|---|
UIDeviceFamily | [3] | Apple TV |
MinimumOSVersion | 17.0 | tvOS 17+ |
UIRequiresFullScreen | true | All tvOS apps are full-screen |
UILaunchStoryboardName | LaunchScreen | Required by tvOS |
Limitations
tvOS has inherent platform constraints compared to other Perry targets:
- No camera: Apple TV has no camera hardware
- No clipboard: UIPasteboard is not available on tvOS
- No file dialogs: No document picker
- No QR code: No camera for scanning
- No multi-window: Single full-screen window only
- No direct touch: Input is via Siri Remote focus engine and game controllers
- Resolution: Design for 1920x1080 (1080p) or 3840x2160 (4K) displays
Differences from iOS
| Aspect | tvOS | iOS |
|---|---|---|
| Input | Siri Remote + game controllers (focus engine) | Direct touch |
| Display | Full-screen only (1080p/4K) | Variable screen sizes |
| Device family | [3] (Apple TV) | [1, 2] (iPhone/iPad) |
| Camera | Not available | Available |
| Clipboard | Not available | Available |
| Deployment target | 17.0 | 17.0 |
| UI framework | UIKit (same as iOS) | UIKit |
Next Steps
- iOS — iOS platform reference (shared UIKit base)
- watchOS — watchOS platform reference
- Platform Overview — All platforms
watchOS
Perry can compile TypeScript apps for Apple Watch devices and the watchOS Simulator.
Since watchOS does not support UIKit views, Perry uses a data-driven SwiftUI renderer: your TypeScript code builds a UI tree via the standard perry/ui API, and a fixed SwiftUI runtime (shipped with Perry) queries the tree and renders it reactively. No code generation or transpilation is involved — the binary is fully native.
Requirements
- macOS host (cross-compilation from Linux/Windows is not supported)
- Xcode (full install) for watchOS SDK and Simulator
- Rust watchOS targets:
rustup target add arm64_32-apple-watchos aarch64-apple-watchos-sim
Building for Simulator
perry compile app.ts -o app --target watchos-simulator
This produces an ARM64 binary linked with swiftc against the watchOS Simulator SDK, wrapped in a .app bundle.
Building for Device
perry compile app.ts -o app --target watchos
This produces an arm64_32 (ILP32) binary for physical Apple Watch hardware. Apple Watch uses 32-bit pointers on 64-bit ARM.
Running with perry run
perry run watchos # Auto-detect booted watch simulator
perry run watchos --simulator <UDID> # Target a specific simulator
Perry auto-discovers booted Apple Watch simulators. To install and launch manually:
xcrun simctl install booted app_watchos/app.app
xcrun simctl launch booted com.perry.app
UI Toolkit
Perry maps UI widgets to SwiftUI views via a data-driven bridge:
| Perry Widget | SwiftUI View | Notes |
|---|---|---|
| Text | Text | Font size, weight, color, wrapping |
| Button | Button | Tap action via native closure callback |
| VStack | VStack | With spacing |
| HStack | HStack | With spacing |
| ZStack | ZStack | Layered views |
| Spacer | Spacer | |
| Divider | Divider | |
| Toggle | Toggle | Two-way state binding |
| Slider | Slider | Min/max/value, state binding |
| Image | Image(systemName:) | SF Symbols |
| ScrollView | ScrollView | |
| ProgressView | ProgressView | Linear |
| Picker | Picker | Selection list |
| Form | List | Maps to List on watchOS |
| NavigationStack | NavigationStack | Push navigation |
Modifiers
All widgets support these styling modifiers:
foregroundColor/backgroundColorfont(size, weight, family)frame(width, height)padding(uniform or per-edge)cornerRadiusopacityhidden/disabled
App Lifecycle
watchOS apps use SwiftUI’s @main App pattern. Perry’s PerryWatchApp.swift runtime handles the app lifecycle automatically:
import { App, Text, VStack, Button } from "perry/ui";
App({
title: "My Watch App",
width: 200,
height: 200,
body: VStack(8, [
Text("Hello, Apple Watch!"),
Button("Tap me", () => {
console.log("Button tapped!");
}),
]),
});
Under the hood:
perry_main_init()runs your compiled TypeScript, which builds the UI tree in memory- The SwiftUI
@mainstruct observes the tree version and renders it - User interactions (button taps, toggle changes) call back into native closures
State Management
Reactive state works the same as other platforms:
import { App, Text, VStack, Button, State } from "perry/ui";
const count = State(0);
App({
title: "Counter",
width: 200,
height: 200,
body: VStack(8, [
Text(`Count: ${count.value}`),
Button("+1", () => {
count.set(count.value + 1);
}),
]),
});
When state.set() is called, the tree version increments and SwiftUI re-renders the affected views automatically.
How It Works
Unlike iOS (UIKit) and macOS (AppKit), where Perry calls native view APIs directly via FFI, watchOS uses a data-driven architecture:
TypeScript code
|
v
perry_ui_*() FFI calls → Node tree stored in memory (Rust)
|
v
PerryWatchApp.swift queries tree via FFI
|
v
SwiftUI renders views reactively
|
v
User interaction → FFI callback → native closure
The PerryWatchApp.swift file is a fixed runtime (~280 lines) that ships with Perry. It never changes per-app — it’s the watchOS equivalent of libperry_ui_ios.a.
Configuration
Configure watchOS settings in perry.toml:
[watchos]
bundle_id = "com.example.mywatch"
deployment_target = "10.0"
[watchos.info_plist]
NSLocationWhenInUseUsageDescription = "Used for location features"
Set up signing credentials with:
perry setup watchos
This shares App Store Connect credentials with iOS/macOS (same team, API key, issuer).
Platform Detection
Use __platform__ === 7 to detect watchOS at compile time:
declare const __platform__: number;
if (__platform__ === 7) {
console.log("Running on watchOS");
}
watchOS Widgets (WidgetKit)
Perry also supports watchOS WidgetKit complications (separate from full apps):
perry compile widget.ts --target watchos-widget --app-bundle-id com.example.app
See watchOS Complications for widget-specific documentation.
Limitations
watchOS apps have inherent platform constraints compared to other Perry targets:
- No Canvas: CoreGraphics drawing is not available
- No Camera: watchOS does not support camera APIs
- No TextField: Text input is extremely limited on Apple Watch
- No File Dialogs: No document picker
- No Menu Bar / Toolbar: Not applicable on watch
- No Multi-Window: Single window only
- No QR Code: Screen too small for practical QR display
- Memory: watchOS devices have ~50-75MB available RAM — keep apps lightweight
- Screen size: Design for 40-49mm watch faces
Differences from iOS
- SwiftUI vs UIKit: watchOS uses SwiftUI rendering; iOS uses UIKit directly
- No splash screen: watchOS apps don’t use launch storyboards
- Standalone: watchOS apps are standalone (no iPhone companion required,
WKWatchOnly = true) - Device family:
UIDeviceFamily = [4](watch) vs[1, 2](iPhone/iPad)
Next Steps
- watchOS Complications — WidgetKit complications
- iOS — iOS platform reference
- Platform Overview — All platforms
- UI Overview — UI system
Android
Perry compiles TypeScript apps for Android using JNI (Java Native Interface).
Requirements
- Android NDK
- Android SDK
- Rust Android targets:
rustup target add aarch64-linux-android armv7-linux-androideabi
Building
perry app.ts -o app --target android
UI Toolkit
Perry maps UI widgets to Android views via JNI:
| Perry Widget | Android Class |
|---|---|
| Text | TextView |
| Button | Button |
| TextField | EditText |
| SecureField | EditText (ES_PASSWORD) |
| Toggle | Switch |
| Slider | SeekBar |
| Picker | Spinner + ArrayAdapter |
| Image | ImageView |
| VStack | LinearLayout (vertical) |
| HStack | LinearLayout (horizontal) |
| ZStack | FrameLayout |
| ScrollView | ScrollView |
| Canvas | Canvas + Bitmap |
| NavigationStack | FrameLayout |
Android-Specific APIs
- Dark mode:
Configuration.uiModedetection - Preferences: SharedPreferences
- Keychain: Android Keystore
- Notifications: NotificationManager
- Open URL:
Intent.ACTION_VIEW - Alerts:
PerryBridge.showAlert - Sheets: Dialog (modal)
Splash Screen
Perry’s Android template includes a splash theme (Theme.Perry.Splash) that displays a windowBackground drawable during cold start. Configure it via perry.splash in package.json:
{
"perry": {
"splash": {
"image": "logo/icon-256.png",
"background": "#FFF5EE"
}
}
}
The image is centered via a layer-list drawable with a solid background color. The activity switches to the normal theme in onCreate before inflating the layout, so the splash disappears as soon as the app is ready.
For full control, provide custom drawable and theme XML files:
{
"perry": {
"splash": {
"android": {
"layout": "splash/splash_background.xml",
"theme": "splash/themes.xml"
}
}
}
}
See Project Configuration for the full config reference.
Differences from Desktop
- Touch-only: No hover events, no right-click context menus
- Single window: Multi-window maps to Dialog views
- Toolbar: Horizontal LinearLayout
- Font: Typeface-based font family support
Next Steps
- Platform Overview — All platforms
- UI Overview — UI system
Windows
Perry compiles TypeScript apps for Windows using the Win32 API.
Requirements
- Windows 10 or later
- A linker toolchain — either of these two options:
Option A — Lightweight (recommended, ~1.5 GB, no Visual Studio)
Uses LLVM’s clang + lld-link plus an xwin’d copy of the Microsoft CRT + Windows SDK libraries. No admin rights, no Visual Studio install.
winget install LLVM.LLVM
perry setup windows
perry setup windows downloads ~700 MB (unpacks to ~1.5 GB) at %LOCALAPPDATA%\perry\windows-sdk after prompting you to accept the Microsoft redistributable license. Pass --accept-license to skip the prompt in CI. Partial downloads resume safely on re-run.
Option B — Visual Studio (~8 GB)
If you already have Visual Studio installed, add the C++ workload via the Visual Studio Installer → Modify → check Desktop development with C++. Or install standalone Build Tools:
winget install Microsoft.VisualStudio.2022.BuildTools --override `
"--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
Both options produce identical binaries — Perry picks Option A when the xwin’d sysroot is present, Option B otherwise. Run perry doctor to see which is active.
Building
perry compile app.ts -o app.exe --target windows
UI Toolkit
Perry maps UI widgets to Win32 controls:
| Perry Widget | Win32 Class |
|---|---|
| Text | Static HWND |
| Button | HWND Button |
| TextField | Edit HWND |
| SecureField | Edit (ES_PASSWORD) |
| Toggle | Checkbox |
| Slider | Trackbar (TRACKBAR_CLASSW) |
| Picker | ComboBox |
| ProgressView | PROGRESS_CLASSW |
| Image | GDI |
| VStack/HStack | Manual layout |
| ScrollView | WS_VSCROLL |
| Canvas | GDI drawing |
| Form/Section | GroupBox |
Windows-Specific APIs
- Menu bar: HMENU / SetMenu
- Dark mode: Windows Registry detection
- Preferences: Windows Registry
- Keychain: CredWrite/CredRead/CredDelete (Windows Credential Manager)
- Notifications: Toast notifications
- File dialogs: IFileOpenDialog / IFileSaveDialog (COM)
- Alerts: MessageBoxW
- Open URL: ShellExecuteW
Next Steps
- Platform Overview — All platforms
- UI Overview — UI system
Linux (GTK4)
Perry compiles TypeScript apps for Linux using GTK4.
Requirements
- GTK4 development libraries:
# Ubuntu/Debian sudo apt install libgtk-4-dev # Fedora sudo dnf install gtk4-devel # Arch sudo pacman -S gtk4 - Cairo development libraries (for canvas):
sudo apt install libcairo2-dev
Building
perry app.ts -o app --target linux
./app
UI Toolkit
Perry maps UI widgets to GTK4 widgets:
| Perry Widget | GTK4 Widget |
|---|---|
| Text | GtkLabel |
| Button | GtkButton |
| TextField | GtkEntry |
| SecureField | GtkPasswordEntry |
| Toggle | GtkSwitch |
| Slider | GtkScale |
| Picker | GtkDropDown |
| ProgressView | GtkProgressBar |
| Image | GtkImage |
| VStack | GtkBox (vertical) |
| HStack | GtkBox (horizontal) |
| ZStack | GtkOverlay |
| ScrollView | GtkScrolledWindow |
| Canvas | Cairo drawing |
| NavigationStack | GtkStack |
Linux-Specific APIs
- Menu bar: GMenu / set_menubar
- Toolbar: GtkHeaderBar
- Dark mode: GTK settings detection
- Preferences: GSettings or file-based
- Keychain: libsecret
- Notifications: GNotification
- File dialogs: GtkFileChooserDialog
- Alerts: GtkMessageDialog
Styling
GTK4 styling uses CSS under the hood. Perry’s styling methods (colors, fonts, corner radius) are translated to CSS properties applied via CssProvider.
Testing with Geisterhand
Perry’s built-in UI fuzzer works on Linux/GTK4. Screenshots use WidgetPaintable + GskRenderer for pixel-accurate capture.
perry app.ts -o app --target linux --enable-geisterhand
./app
# In another terminal:
curl http://127.0.0.1:7676/widgets
curl http://127.0.0.1:7676/screenshot -o screenshot.png
See Geisterhand for full API reference.
Next Steps
- Platform Overview — All platforms
- UI Overview — UI system
Web
--target web and --target wasm are aliases for the same backend. Both produce a self-contained HTML file with embedded WebAssembly and a JavaScript bridge for DOM widgets.
perry app.ts -o app --target web # same output as --target wasm
open app.html
See WebAssembly / Web for the full documentation: how it works, supported features, UI mapping, FFI, threading, limitations, and examples.
Why one target instead of two?
Perry used to have two browser backends:
--target web(perry-codegen-js) — transpiled HIR to JavaScript--target wasm(perry-codegen-wasm) — compiled HIR to WebAssembly
These were consolidated into the WASM target so browser apps get near-native performance, FFI imports, and Web Worker threading without needing a separate JS-emit pipeline. The DOM widget runtime that the old --target web provided is now embedded in wasm_runtime.js. Both flags route through perry-codegen-wasm and produce identical HTML output.
Next Steps
- WebAssembly / Web — full target documentation
- Platform Overview — all platforms
WebAssembly / Web
Perry compiles TypeScript apps to WebAssembly for the browser using --target wasm or its alias --target web. Both flags route through the same backend (perry-codegen-wasm) and produce the same output: a self-contained HTML file with embedded WASM bytecode and a thin JavaScript bridge for DOM widgets and host APIs.
There used to be a separate JavaScript-emitting --target web (perry-codegen-js); it was consolidated into the WASM target so browser apps get near-native performance, FFI imports, and Web Worker threading “for free”.
Building
# Self-contained HTML (default)
perry app.ts -o app --target web
open app.html
# Same thing
perry app.ts -o app --target wasm
# Raw .wasm binary (no HTML wrapper)
perry app.ts -o app.wasm --target wasm
The default output is a single .html file containing a base64-embedded WASM binary, the wasm_runtime.js bridge, and a bootPerryWasm() call that instantiates the module. Open it directly in any modern browser — no build step, no server required for simple apps.
Note: Apps that use
fetch()or other web platform APIs that depend on a real origin must be served over HTTP (file:// URLs run into CORS / “Failed to fetch” errors). Any local static server works:python3 -m http.server 8765 open http://localhost:8765/app.html
How It Works
The perry-codegen-wasm crate compiles HIR directly to WASM bytecode using wasm-encoder. The output WASM:
- Imports ~280 host functions under the
rtnamespace (string ops, math, console, JSON, classes, closures, promises, fetch, etc.) - Imports user-declared FFI functions under the
ffinamespace - Exports
_start,memory,__indirect_function_table, and every user function as__wasm_func_<idx>(so async function bodies compiled to JS can call back into WASM)
The NaN-boxing scheme matches the native perry-runtime — f64 values with STRING_TAG/POINTER_TAG/INT32_TAG — so the same value representation is used across native and WASM targets. The JS bridge wraps every host import with bit-level reinterpretation so f64 NaN-boxed values pass through the BigInt-based JS↔WASM i64 boundary intact (BigInt(NaN) would otherwise throw).
Supported Features
- Full TypeScript language: classes (with constructors, methods, getters/setters, inheritance, fields), async/await, closures (with captures), generators, destructuring, template literals, generics, enums, try/catch/finally
- Module system: cross-module imports, top-level
const/let(promoted to WASM globals), circular imports - Standard library: String/Array/Object methods, Map/Set, JSON, Date, RegExp, Math, Error, URL/URLSearchParams, Buffer, Promise (with
.then/.catch/.allSettled/.race/.any/.all) - Async:
async/await(compiled to JS Promises),setTimeout/setInterval,fetch()with full request options (method, headers, body) - Threading:
perry/threadparallelMap/parallelFilter/spawnvia Web Worker pool with one WASM instance per worker (see Threading) - DOM-based UI: every widget in
perry/ui(VStack,HStack,ZStack,Text,Button,TextField,Toggle,Slider,ScrollView,Picker,Image,Canvas,Form,Section,NavigationStack,Table,LazyVStack,TextArea, etc.) maps to a DOM element with flexbox layout. State bindings (bindText/bindSlider/bindToggle/bindForEach/…) work via reactive subscribers. - System APIs:
localStorage-backed preferences/keychain, dark mode detection (prefers-color-scheme), Web Notifications, clipboard, file open/save dialogs, File System Access API, Web Audio capture - FFI:
declare functiondeclarations become WASM imports under theffinamespace - Compile-time i18n:
perry/i18nt()calls work the same as native targets
UI Mapping
Perry widgets map to HTML elements:
| Perry Widget | HTML Element |
|---|---|
Text | <span> |
Button | <button> |
TextField | <input type="text"> |
SecureField | <input type="password"> |
Toggle | <input type="checkbox"> |
Slider | <input type="range"> |
Picker | <select> |
ProgressView | <progress> |
Image / ImageFile | <img> |
VStack | <div> (flexbox column) |
HStack | <div> (flexbox row) |
ZStack | <div> (position: relative + absolute children) |
ScrollView | <div> (overflow: auto) |
Canvas | <canvas> (2D context) |
Table | <table> |
Divider | <hr> |
Spacer | <div> (flex: 1) |
FFI Support
The WASM target supports external FFI functions declared with declare function. They become WASM imports under the "ffi" namespace:
declare function bloom_init_window(w: number, h: number, title: number, fs: number): void;
declare function bloom_draw_rect(x: number, y: number, w: number, h: number,
r: number, g: number, b: number, a: number): void;
Provide them when instantiating:
// Via __ffiImports global (set before boot)
globalThis.__ffiImports = { bloom_init_window: ..., bloom_draw_rect: ... };
// Or via bootPerryWasm second argument
await bootPerryWasm(wasmBase64, { bloom_init_window: ..., bloom_draw_rect: ... });
Auto-stub for missing imports. The ffi namespace is wrapped in a Proxy so any FFI function the host doesn’t provide is auto-stubbed with a no-op that returns TAG_UNDEFINED. This means apps that use native libraries (e.g. Hone Editor’s 56 hone_editor_* functions) can still instantiate and run in the browser even without the native bindings — the relevant features are simply no-ops.
Module-Level Constants
Top-level const/let declarations are promoted to dedicated WASM globals so functions in the same module can read them, and so two modules’ identical LocalIds don’t collide:
// telemetry.ts
const CHIRP_URL = 'https://api.chirp247.com/api/v1/event';
const API_KEY = 'my-key';
export function trackEvent(event: string): void {
fetch(CHIRP_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Chirp-Key': API_KEY },
body: JSON.stringify({ event }),
});
}
Both CHIRP_URL and API_KEY become WASM globals indexed by (module_idx, LocalId). Reading them from trackEvent emits a global.get instead of trying to look up a function-local that doesn’t exist.
JavaScript Runtime Bridge
The bridge (wasm_runtime.js) is embedded in the HTML and provides ~280 imports across:
- NaN-boxing helpers:
f64ToU64/u64ToF64/nanboxString/nanboxPointer/toJsValue/fromJsValue - String table: dynamic JS string array indexed by string ID
- Handle store: maps integer handle IDs to JS objects, arrays, closures, promises, DOM elements
- Core ops: console, math, JSON, JSON.parse/stringify, Date, RegExp, URL, Map, Set, Buffer, fetch
- Closure dispatch: indirect function table + capture array, with
closure_call_0/1/2/3/spread - Class dispatch:
class_new,class_call_method,class_get_field,class_set_field, parent table for inheritance - DOM widgets: 168+
perry_ui_*functions covering every widget inperry/ui - Async functions: compiled to JS function bodies and merged into the import object as
__async_<name>
All host imports are wrapped via wrapImportsForI64() so they automatically reinterpret BigInt args (from WASM i64 params) into f64 internally and reinterpret Number returns back into BigInt. Without this wrapping, every NaN-valued f64 return would crash with “Cannot convert NaN to a BigInt”.
Web Worker Threading
perry/thread works in the browser via a Web Worker pool:
import { parallelMap } from "perry/thread";
const numbers = [1, 2, 3, 4, 5, 6, 7, 8];
const squares = parallelMap(numbers, (n) => n * n);
Each worker instantiates its own WASM module with the same bytecode and bridge. Values cross between the main thread and workers via structured-clone serialization. See Threading.
Limitations
- No file system access beyond the File System Access API (
window.showDirectoryPicker()) - No raw TCP/UDP sockets — only
fetch()andWebSocket - No subprocess spawning —
child_process.execetc. are no-ops - No native databases — SQLite, Postgres, MySQL drivers don’t compile to web
- CORS applies to all
fetch()calls — third-party APIs must allow your origin - localStorage, not real keychain — fine for preferences, not for secrets
- Source-mapped stack traces are JS-only; WASM stack frames show
wasm-function[N]
Minification
Use --minify to minify the embedded JS runtime bridge in the HTML output. The Rust-native JS minifier strips comments, collapses whitespace, and mangles internal identifiers, compressing the runtime from ~3,400 lines to ~180.
perry app.ts -o app --target web --minify
Example: Counter App
import { App, VStack, Text, Button, State } from "perry/ui";
const count = State(0);
App({
title: "Counter",
width: 400,
height: 300,
body: VStack(16, [
Text(`Count: ${count.value}`),
Button("Increment", () => count.set(count.value + 1)),
]),
});
perry counter.ts -o counter --target web
open counter.html
Example: Real-World App (Mango MongoDB GUI)
The Mango MongoDB GUI — 50 modules, 998 functions, classes, async functions, fetch with custom headers, the Hone code editor — compiles to a single 4 MB HTML file via --target web and renders its full UI (welcome screen, query view, edit view) in the browser. SQLite-backed connection storage gracefully degrades to an in-memory transient store on web; the rest of the app works the same as the native version.
Next Steps
- Platform Overview — All platforms
- UI Overview — UI system
- Threading — Web Worker threading
Standard Library Overview
Perry natively implements many popular npm packages and Node.js APIs. When you import a supported package, Perry compiles it to native code — no JavaScript runtime involved.
How It Works
import fastify from "fastify";
import mysql from "mysql2/promise";
Perry recognizes these imports at compile time and routes them to native Rust implementations in the perry-stdlib crate. The API surface matches the original npm package, so existing code often works unchanged.
Supported Packages
Networking & HTTP
- fastify — HTTP server framework
- axios — HTTP client
- node-fetch / fetch — HTTP fetch API
- ws — WebSocket client/server
Databases
- mysql2 — MySQL client
- pg — PostgreSQL client
- better-sqlite3 — SQLite
- mongodb — MongoDB client
- ioredis / redis — Redis client
Cryptography
- bcrypt — Password hashing
- argon2 — Password hashing (Argon2)
- jsonwebtoken — JWT signing/verification
- crypto — Node.js crypto module
- ethers — Ethereum library
Utilities
- lodash — Utility functions
- dayjs / moment — Date manipulation
- uuid — UUID generation
- nanoid — ID generation
- slugify — String slugification
- validator — String validation
CLI & Data
- commander — CLI argument parsing
- decimal.js — Arbitrary precision decimals
- bignumber.js — Big number math
- lru-cache — LRU caching
Other
- sharp — Image processing
- cheerio — HTML parsing
- nodemailer — Email sending
- zlib — Compression
- cron — Job scheduling
- worker_threads — Background workers
- exponential-backoff — Retry logic
- async_hooks — AsyncLocalStorage
Node.js Built-ins
- fs — File system
- path — Path manipulation
- child_process — Process spawning
- crypto — Cryptographic functions
Binary Size
Perry automatically detects which stdlib features your code uses:
| Usage | Binary Size |
|---|---|
| No stdlib imports | ~300KB |
| fs + path only | ~3MB |
| Full stdlib | ~48MB |
The compiler links only the required runtime components.
compilePackages
For npm packages not natively supported, you can compile pure TypeScript/JavaScript packages natively:
{
"perry": {
"compilePackages": ["@noble/curves", "@noble/hashes"]
}
}
See Project Configuration for details.
JavaScript Runtime Fallback
For packages that can’t be compiled natively (native addons, dynamic code, etc.), Perry includes a QuickJS-based JavaScript runtime as a fallback:
import { jsEval } from "perry/jsruntime";
Next Steps
File System
Perry implements Node.js file system APIs for reading, writing, and managing files.
Reading Files
import { readFileSync } from "fs";
const content = readFileSync("config.json", "utf-8");
console.log(content);
Binary File Reading
import { readFileBuffer } from "fs";
const buffer = readFileBuffer("image.png");
console.log(`Read ${buffer.length} bytes`);
readFileBuffer reads files as binary data (uses fs::read() internally, not read_to_string()).
Writing Files
import { writeFileSync } from "fs";
writeFileSync("output.txt", "Hello, World!");
writeFileSync("data.json", JSON.stringify({ key: "value" }, null, 2));
File Information
import { existsSync, statSync } from "fs";
if (existsSync("config.json")) {
const stat = statSync("config.json");
console.log(`Size: ${stat.size}`);
}
Directory Operations
import { mkdirSync, readdirSync, rmRecursive } from "fs";
// Create directory
mkdirSync("output");
// Read directory contents
const files = readdirSync("src");
for (const file of files) {
console.log(file);
}
// Remove directory recursively
rmRecursive("output"); // Uses fs::remove_dir_all
Path Utilities
import { join, dirname, basename, resolve } from "path";
import { fileURLToPath } from "url";
const dir = dirname(fileURLToPath(import.meta.url));
const configPath = join(dir, "config.json");
const name = basename(configPath); // "config.json"
const abs = resolve("relative/path"); // Absolute path
Next Steps
- HTTP & Networking
- Overview — All stdlib modules
HTTP & Networking
Perry natively implements HTTP servers, clients, and WebSocket support.
Fastify Server
import fastify from "fastify";
const app = fastify();
app.get("/", async (request, reply) => {
return { hello: "world" };
});
app.get("/users/:id", async (request, reply) => {
const { id } = request.params;
return { id, name: "User " + id };
});
app.post("/data", async (request, reply) => {
const body = request.body;
reply.code(201);
return { received: body };
});
app.listen({ port: 3000 }, () => {
console.log("Server running on port 3000");
});
Perry’s Fastify implementation is API-compatible with the npm package. Routes, request/reply objects, params, query strings, and JSON body parsing all work.
Fetch API
// GET request
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
const data = await response.json();
// POST request
const result = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "hello", body: "world", userId: 1 }),
});
Axios
import axios from "axios";
const { data } = await axios.get("https://jsonplaceholder.typicode.com/users/1");
const response = await axios.post("https://jsonplaceholder.typicode.com/users", {
name: "Perry",
email: "perry@example.com",
});
WebSocket
import { WebSocket } from "ws";
const ws = new WebSocket("ws://localhost:8080");
ws.on("open", () => {
ws.send("Hello, server!");
});
ws.on("message", (data) => {
console.log(`Received: ${data}`);
});
ws.on("close", () => {
console.log("Connection closed");
});
Next Steps
Databases
Perry natively implements clients for MySQL, PostgreSQL, SQLite, MongoDB, and Redis.
MySQL
import mysql from "mysql2/promise";
const connection = await mysql.createConnection({
host: "localhost",
user: "root",
password: "password",
database: "mydb",
});
const [rows] = await connection.execute("SELECT * FROM users WHERE id = ?", [1]);
console.log(rows);
await connection.end();
PostgreSQL
import { Client } from "pg";
const client = new Client({
host: "localhost",
port: 5432,
user: "postgres",
password: "password",
database: "mydb",
});
await client.connect();
const result = await client.query("SELECT * FROM users WHERE id = $1", [1]);
console.log(result.rows);
await client.end();
SQLite
import Database from "better-sqlite3";
const db = new Database("mydb.sqlite");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
`);
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
insert.run("Perry", "perry@example.com");
const users = db.prepare("SELECT * FROM users").all();
console.log(users);
MongoDB
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://localhost:27017");
await client.connect();
const db = client.db("mydb");
const users = db.collection("users");
await users.insertOne({ name: "Perry", email: "perry@example.com" });
const user = await users.findOne({ name: "Perry" });
console.log(user);
await client.close();
Redis
import Redis from "ioredis";
const redis = new Redis();
await redis.set("key", "value");
const value = await redis.get("key");
console.log(value); // "value"
await redis.del("key");
await redis.quit();
Next Steps
- Cryptography
- Overview — All stdlib modules
Cryptography
Perry natively implements password hashing, JWT tokens, and Ethereum cryptography.
bcrypt
import bcrypt from "bcrypt";
const hash = await bcrypt.hash("mypassword", 10);
const match = await bcrypt.compare("mypassword", hash);
console.log(match); // true
Argon2
import argon2 from "argon2";
const hash = await argon2.hash("mypassword");
const valid = await argon2.verify(hash, "mypassword");
console.log(valid); // true
JSON Web Tokens
import jwt from "jsonwebtoken";
const secret = "my-secret-key";
// Sign a token
const token = jwt.sign({ userId: 123, role: "admin" }, secret, {
expiresIn: "1h",
});
// Verify a token
const decoded = jwt.verify(token, secret);
console.log(decoded.userId); // 123
Node.js Crypto
import crypto from "crypto";
// Hash
const hash = crypto.createHash("sha256").update("data").digest("hex");
// HMAC
const hmac = crypto.createHmac("sha256", "secret").update("data").digest("hex");
// Random bytes
const bytes = crypto.randomBytes(32);
Ethers
import { ethers } from "ethers";
// Create a wallet
const wallet = ethers.Wallet.createRandom();
console.log(wallet.address);
// Sign a message
const signature = await wallet.signMessage("Hello, Ethereum!");
Next Steps
Utilities
Perry natively implements common utility packages.
lodash
import _ from "lodash";
_.chunk([1, 2, 3, 4, 5], 2); // [[1,2], [3,4], [5]]
_.uniq([1, 2, 2, 3, 3]); // [1, 2, 3]
_.groupBy(users, "role");
_.sortBy(users, ["name"]);
_.cloneDeep(obj);
_.merge(defaults, overrides);
_.debounce(fn, 300);
_.throttle(fn, 100);
dayjs
import dayjs from "dayjs";
const now = dayjs();
console.log(now.format("YYYY-MM-DD"));
console.log(now.add(7, "day").format("YYYY-MM-DD"));
console.log(now.subtract(1, "month").toISOString());
const diff = dayjs("2025-12-31").diff(now, "day");
console.log(`${diff} days until end of year`);
moment
import moment from "moment";
const now = moment();
console.log(now.format("MMMM Do YYYY"));
console.log(now.fromNow());
console.log(moment("2025-01-01").isBefore(now));
uuid
import { v4 as uuidv4 } from "uuid";
const id = uuidv4();
console.log(id); // e.g., "550e8400-e29b-41d4-a716-446655440000"
nanoid
import { nanoid } from "nanoid";
const id = nanoid(); // Default 21 chars
const short = nanoid(10); // Custom length
console.log(id);
slugify
import slugify from "slugify";
const slug = slugify("Hello World!", { lower: true });
console.log(slug); // "hello-world"
validator
import validator from "validator";
validator.isEmail("test@example.com"); // true
validator.isURL("https://example.com"); // true
validator.isUUID(id); // true
validator.isEmpty(""); // true
Next Steps
- Other Modules
- Overview — All stdlib modules
Other Modules
Additional npm packages and Node.js APIs supported by Perry.
sharp (Image Processing)
import sharp from "sharp";
await sharp("input.jpg")
.resize(300, 200)
.toFile("output.png");
cheerio (HTML Parsing)
import cheerio from "cheerio";
const html = "<html><body><h1>Hello</h1><p>World</p></body></html>";
const $ = cheerio.load(html);
console.log($("h1").text()); // "Hello"
nodemailer (Email)
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "user", pass: "pass" },
});
await transporter.sendMail({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Perry",
text: "This email was sent from a compiled TypeScript binary!",
});
zlib (Compression)
import zlib from "zlib";
const compressed = zlib.gzipSync("Hello, World!");
const decompressed = zlib.gunzipSync(compressed);
cron (Job Scheduling)
import { CronJob } from "cron";
const job = new CronJob("*/5 * * * *", () => {
console.log("Runs every 5 minutes");
});
job.start();
worker_threads
import { Worker, parentPort, workerData } from "worker_threads";
if (parentPort) {
// Worker thread
const data = workerData;
parentPort.postMessage({ result: data.value * 2 });
} else {
// Main thread
const worker = new Worker("./worker.ts", {
workerData: { value: 21 },
});
worker.on("message", (msg) => {
console.log(msg.result); // 42
});
}
commander (CLI Parsing)
import { Command } from "commander";
const program = new Command();
program.name("my-cli").version("1.0.0").description("My CLI tool");
program
.command("serve")
.option("-p, --port <number>", "Port number")
.option("--verbose", "Verbose output")
.action((options) => {
console.log(`Starting server on port ${options.port}`);
});
program.parse(process.argv);
decimal.js (Arbitrary Precision)
import Decimal from "decimal.js";
const a = new Decimal("0.1");
const b = new Decimal("0.2");
const sum = a.plus(b); // Exactly 0.3 (no floating point errors)
sum.toFixed(2); // "0.30"
sum.toNumber(); // 0.3
a.times(b); // 0.02
a.div(b); // 0.5
a.pow(10); // 1e-10
a.sqrt(); // 0.316...
lru-cache
import LRUCache from "lru-cache";
const cache = new LRUCache(100); // max 100 entries
cache.set("key", "value");
cache.get("key"); // "value"
cache.has("key"); // true
cache.delete("key");
cache.clear();
child_process
import { spawnBackground, getProcessStatus, killProcess } from "child_process";
// Spawn a background process
const { pid, handleId } = spawnBackground("sleep", ["10"], "/tmp/log.txt");
// Check if it's still running
const status = getProcessStatus(handleId);
console.log(status.alive); // true
// Kill it
killProcess(handleId);
Next Steps
- Overview — All stdlib modules
- File System — fs and path APIs
Internationalization (i18n)
Perry’s i18n system lets you write natural English strings and have them automatically translated at compile time. Zero ceremony, near-zero runtime cost.
import { Button, Text } from "perry/ui";
Button("Next") // Automatically localized
Text("Hello, {name}!", { name: user.name }) // With interpolation
Design Principles
- Zero ceremony: String literals in UI components are automatically localizable keys
- Compile-time validation: Missing translations, parameter mismatches, and plural form errors caught during build
- Embedded string table: All translations baked into the binary as a flat 2D table. Near-zero runtime lookup cost
- Platform-native locale detection: Uses OS APIs on every platform (no env vars needed on mobile)
Quick Start
1. Add i18n config to perry.toml
[i18n]
locales = ["en", "de"]
default_locale = "en"
2. Extract strings from your code
perry i18n extract src/main.ts
This scans your source files and creates locales/en.json and locales/de.json:
// locales/en.json
{
"Next": "Next",
"Back": "Back"
}
// locales/de.json (empty values = needs translation)
{
"Next": "",
"Back": ""
}
3. Translate
Fill in locales/de.json:
{
"Next": "Weiter",
"Back": "Zurck"
}
4. Build
perry compile src/main.ts -o myapp
Perry validates all translations at compile time and bakes them into the binary. At runtime, the app detects the user’s system locale and shows the right language.
How It Works
- Detection: String literals in UI component calls (
Button,Text,Label, etc.) are automatically treated as i18n keys - Transform: The compiler replaces
Expr::String("Next")withExpr::I18nString { key: "Next", string_idx: 0 }in the HIR - Codegen: For each
I18nString, the compiler emits a locale branch that selects the correct translation at runtime - Locale detection: At startup,
perry_i18n_init()detects the system locale via native APIs and sets the global locale index
Locale Detection
| Platform | Method |
|---|---|
| macOS | CFLocaleCopyCurrent() (CoreFoundation) |
| iOS | CFLocaleCopyCurrent() (CoreFoundation) |
| Android | __system_property_get("persist.sys.locale") |
| Windows | GetUserDefaultLocaleName() (Win32) |
| Linux | LANG / LC_ALL / LC_MESSAGES env vars |
The detected locale is fuzzy-matched against your configured locales: de_DE.UTF-8 matches de, en-US matches en, etc.
Platform Output
When compiling for mobile targets, Perry generates platform-native locale resources alongside the binary:
| Platform | Output |
|---|---|
| iOS/macOS | {locale}.lproj/Localizable.strings inside .app bundle |
| Android | res/values-{locale}/strings.xml |
| Desktop | Strings embedded in binary (no extra files) |
Next Steps
Interpolation & Plurals
Parameterized Strings
Use {param} placeholders in your strings and pass values as a second argument:
import { Text } from "perry/ui";
Text("Hello, {name}!", { name: user.name })
Text("Total: {price}", { price: order.total })
Translation files use the same {param} syntax:
// locales/en.json
{
"Hello, {name}!": "Hello, {name}!",
"Total: {price}": "Total: {price}"
}
// locales/de.json
{
"Hello, {name}!": "Hallo, {name}!",
"Total: {price}": "Gesamt: {price}"
}
Parameters are substituted at runtime after the locale-appropriate template is selected. The substitution handles any value type (numbers, strings, dates) by converting to string.
Compile-Time Validation
Perry validates parameters across all locales during compilation:
| Condition | Severity |
|---|---|
{param} in translation but not provided in code | Error |
Param in code but {param} not in translation | Error |
| Parameter set differs between locales for same key | Error |
Plural Rules
Plural forms use dot-suffix keys based on CLDR plural categories: .zero, .one, .two, .few, .many, .other.
Locale Files
// locales/en.json
{
"You have {count} items.one": "You have {count} item.",
"You have {count} items.other": "You have {count} items."
}
// locales/de.json
{
"You have {count} items.one": "Du hast {count} Artikel.",
"You have {count} items.other": "Du hast {count} Artikel."
}
// locales/pl.json (Polish: one, few, many)
{
"You have {count} items.one": "Masz {count} element.",
"You have {count} items.few": "Masz {count} elementy.",
"You have {count} items.many": "Masz {count} elementow.",
"You have {count} items.other": "Masz {count} elementu."
}
Usage in Code
Reference the base key without any suffix. Perry detects the plural variants automatically:
Text("You have {count} items", { count: cart.items.length })
Perry determines which plural form to use based on the count parameter value and the current locale’s CLDR rules.
Supported Locales
Perry includes hand-rolled CLDR plural rules for 30+ locales:
| Pattern | Locales |
|---|---|
| one/other | English, German, Dutch, Swedish, Danish, Norwegian, Finnish, Estonian, Hungarian, Turkish, Greek, Hebrew, Italian, Spanish, Portuguese, Catalan, Bulgarian, Hindi, Bengali, Swahili, … |
| one (0-1) / other | French |
| no distinction | Japanese, Chinese, Korean, Vietnamese, Thai, Indonesian, Malay |
| one/few/many | Russian, Ukrainian, Serbian, Croatian, Bosnian, Polish |
| one/few/other | Czech, Slovak |
| zero/one/two/few/many/other | Arabic |
| one/few/other | Romanian, Lithuanian |
| zero/one/other | Latvian |
Compile-Time Validation
| Condition | Severity |
|---|---|
.other form missing for any locale | Error |
Required CLDR category missing (e.g., .few for Polish) | Error |
Extra category locale doesn’t use (e.g., .few for English) | Warning |
Explicit API for Non-UI Strings
For strings outside UI components (API responses, notifications, etc.), use t():
import { t } from "perry/i18n";
const message = t("Your order has been shipped.");
const welcome = t("Welcome back, {name}!", { name: user.name });
This uses the same key lookup, validation, and interpolation as UI strings.
Locale-Aware Formatting
Perry provides format wrapper functions that automatically format values according to the current locale. Import them from perry/i18n:
import { Currency, Percent, ShortDate, LongDate, FormatNumber, FormatTime, Raw } from "perry/i18n";
Format Wrappers
Currency
Formats a number as currency with the locale’s symbol, decimal separator, and symbol placement:
Text("Total: {price}", { price: Currency(23.10) })
// en: "Total: $23.10"
// de: "Total: 23,10 €"
// fr: "Total: 23,10 €"
// ja: "Total: ¥23.10"
Percent
Formats a decimal as a percentage (value is multiplied by 100):
Text("Discount: {rate}", { rate: Percent(0.15) })
// en: "Discount: 15%"
// de: "Discount: 15 %"
// fr: "Discount: 15 %"
FormatNumber
Formats a number with locale-appropriate grouping and decimal separators:
Text("Population: {n}", { n: FormatNumber(1234567.89) })
// en: "Population: 1,234,567.89"
// de: "Population: 1.234.567,89"
// fr: "Population: 1 234 567,89"
ShortDate / LongDate / FormatDate
Formats a timestamp (milliseconds since epoch) as a date:
const now = Date.now();
Text("Due: {d}", { d: ShortDate(now) })
// en: "Due: 3/22/2026"
// de: "Due: 22.03.2026"
// ja: "Due: 2026/03/22"
Text("Event: {d}", { d: LongDate(now) })
// en: "Event: March 22, 2026"
// de: "Event: 22. März 2026"
// fr: "Event: 22 mars 2026"
FormatTime
Formats a timestamp as time (12h vs 24h based on locale):
Text("At: {t}", { t: FormatTime(timestamp) })
// en: "At: 3:45 PM"
// de: "At: 15:45"
// fr: "At: 15:45"
Raw
Pass-through — prevents any automatic formatting. Use when a parameter name might trigger auto-formatting but you want the raw value:
Text("Code: {amount}", { amount: Raw(12345) })
// All locales: "Code: 12345" (no currency formatting despite the name)
Locale-Specific Formatting Rules
Perry includes hand-rolled formatting rules for 25+ locales:
| Feature | Example Locales |
|---|---|
Decimal: . / Thousands: , | en, ja, zh, ko |
Decimal: , / Thousands: . | de, nl, tr, es, it, pt |
Decimal: , / Thousands: (narrow space) | fr |
Decimal: , / Thousands: (non-breaking space) | ru, uk, pl, sv, da, no, fi |
Currency before number: $23.10 | en, ja, zh, ko |
Currency after number: 23,10 € | de, fr, es, it, ru |
Percent with space: 42 % | de, fr, es, ru |
Percent without space: 42% | en, ja, zh |
| Date order: M/D/Y | en |
| Date order: D.M.Y | de, fr, es, ru |
| Date order: Y/M/D | ja, zh, ko, sv |
| 24-hour time | de, fr, es, ja, zh, ru (most) |
| 12-hour time (AM/PM) | en |
Currency Configuration
Configure default currency codes per locale in perry.toml:
[i18n]
locales = ["en", "de", "fr"]
default_locale = "en"
[i18n.currencies]
en = "USD"
de = "EUR"
fr = "EUR"
When Currency(value) is called, the locale’s configured currency code determines the symbol and formatting rules.
i18n CLI Tools
perry i18n extract
Scans your TypeScript source files for localizable strings and generates or updates locale JSON files.
perry i18n extract src/main.ts
What It Does
- Recursively scans all
.tsand.tsxfiles in the source directory - Detects string literals in UI component calls:
Button("..."),Text("..."),Label("..."), etc. - Also detects
t("...")calls fromperry/i18n - Creates
locales/directory if it doesn’t exist - For each configured locale, creates or updates a JSON file:
- Default locale: New keys are pre-filled with themselves as values
- Non-default locales: New keys are added with empty string values (indicating “needs translation”)
Example Output
Scanning for localizable strings...
Found 12 localizable string(s)
Updated locales/en.json (3 new, 1 unused)
Updated locales/de.json (3 new, 1 unused)
Updated locales/fr.json (3 new, 1 unused)
Done.
Workflow
Typical translation workflow:
# 1. Write code with English strings
# Button("Next"), Text("Hello, {name}!", { name })
# 2. Extract strings to locale files
perry i18n extract src/main.ts
# 3. Send locales/de.json to translators (empty values need filling)
# 4. Build — Perry validates everything
perry compile src/main.ts -o myapp
Detected Patterns
The scanner detects these UI component patterns:
Button("string")Text("string")Label("string")TextField("string")TextArea("string")Tab("string")NavigationTitle("string")SectionHeader("string")SecureField("string")Alert("string")t("string")(explicit i18n API)
Both double-quoted and single-quoted strings are supported. Escaped quotes are handled correctly.
Build Output
During compilation, Perry reports i18n status:
i18n: 2 locale(s) [en, de], default: en
Loaded locales/en.json (12 keys)
Loaded locales/de.json (12 keys)
i18n: 12 localizable string(s) detected
i18n warning: Missing translation for key "Settings" in locale "de"
i18n warning: Unused i18n key "Old Label" in locale "en"
Key Registry
Perry maintains a .perry/i18n-keys.json file, updated on every build:
{
"keys": [
{ "key": "Next", "string_idx": 0 },
{ "key": "Hello, {name}!", "string_idx": 1 },
{ "key": "You have {count} items", "string_idx": 2 }
]
}
This file serves as the source of truth for what strings exist in the codebase.
System APIs Overview
The perry/system module provides access to platform-native system features: preferences, secure storage, notifications, URL opening, dark mode detection, and app introspection.
import { openURL, isDarkMode, preferencesSet, preferencesGet, getAppIcon } from "perry/system";
Available APIs
| Function | Description | Platforms |
|---|---|---|
openURL(url) | Open URL in default browser/app | All |
isDarkMode() | Check system dark mode | All |
preferencesSet(key, value) | Store a preference | All |
preferencesGet(key) | Read a preference | All |
keychainSet(key, value) | Secure storage write | All |
keychainGet(key) | Secure storage read | All |
sendNotification(title, body) | Local notification | All |
clipboardGet() | Read clipboard | All |
clipboardSet(text) | Write clipboard | All |
audioStart() | Start microphone capture | All |
audioStop() | Stop microphone capture | All |
audioGetLevel() | Current dB(A) sound level | All |
audioGetPeak() | Current peak amplitude (0–1) | All |
audioGetWaveformSamples(n) | Recent dB samples for visualization | All |
getLocale() | Device language code (e.g. "de", "en") | All |
getDeviceModel() | Device model identifier | All |
getAppIcon(path) | Get app/file icon as Image widget | macOS, Linux |
Quick Example
import { isDarkMode, preferencesGet, preferencesSet, openURL } from "perry/system";
// Detect dark mode
if (isDarkMode()) {
console.log("Dark mode is active");
}
// Store user preferences
preferencesSet("theme", "dark");
const theme = preferencesGet("theme");
// Open a URL
openURL("https://example.com");
Next Steps
Preferences
Store and retrieve user preferences using the platform’s native storage.
Usage
import { preferencesSet, preferencesGet } from "perry/system";
// Store a preference
preferencesSet("username", "perry");
preferencesSet("fontSize", "14");
preferencesSet("darkMode", "true");
// Read a preference
const username = preferencesGet("username"); // "perry"
const fontSize = preferencesGet("fontSize"); // "14"
Values are stored as strings. Convert numbers and booleans as needed:
preferencesSet("count", String(42));
const count = Number(preferencesGet("count"));
Platform Storage
| Platform | Backend |
|---|---|
| macOS | NSUserDefaults |
| iOS | NSUserDefaults |
| Android | SharedPreferences |
| Windows | Windows Registry |
| Linux | GSettings / file-based |
| Web | localStorage |
Preferences persist across app launches. They are not encrypted — use Keychain for sensitive data.
Next Steps
Keychain
Securely store sensitive data like tokens, passwords, and API keys using the platform’s secure storage.
Usage
import { keychainSet, keychainGet } from "perry/system";
// Store a secret
keychainSet("api-token", "sk-abc123...");
// Retrieve a secret
const token = keychainGet("api-token");
Platform Storage
| Platform | Backend |
|---|---|
| macOS | Security.framework (Keychain) |
| iOS | Security.framework (Keychain) |
| Android | Android Keystore |
| Windows | Windows Credential Manager (CredWrite/CredRead/CredDelete) |
| Linux | libsecret |
| Web | localStorage (not truly secure) |
Web: The web platform uses
localStorage, which is not encrypted. For web apps handling sensitive data, consider server-side storage instead.
Next Steps
- Preferences — Non-sensitive preferences
- Notifications — Local notifications
- Overview — All system APIs
Notifications
Send local notifications using the platform’s notification system.
Usage
import { sendNotification } from "perry/system";
sendNotification("Download Complete", "Your file has been downloaded successfully.");
Platform Implementation
| Platform | Backend |
|---|---|
| macOS | UNUserNotificationCenter |
| iOS | UNUserNotificationCenter |
| Android | NotificationManager |
| Windows | Toast notifications |
| Linux | GNotification |
| Web | Web Notification API |
Permissions: On macOS, iOS, and Web, the user may need to grant notification permissions. On first use, the system will prompt for permission automatically.
Next Steps
Audio Capture
The perry/system module provides real-time audio capture from the device microphone, with A-weighted dB(A) level metering and waveform sampling — everything needed to build a sound meter, audio visualizer, or voice-level indicator.
import { audioStart, audioStop, audioGetLevel, audioGetPeak, audioGetWaveformSamples } from "perry/system";
Quick Example
import { App, Text, VStack, State, Canvas } from "perry/ui";
import { audioStart, audioStop, audioGetLevel, audioGetPeak, audioGetWaveformSamples } from "perry/system";
audioStart();
const db = State(0);
// Poll the level every 100ms
setInterval(() => {
db.set(audioGetLevel());
}, 100);
App({
title: "Sound Meter",
width: 400,
height: 300,
body: VStack(16, [
Text(`${db.value} dB`),
]),
});
API Reference
audioStart()
Start capturing audio from the device microphone.
const ok = audioStart(); // 1 = success, 0 = failure
On platforms that require permission (iOS, Android, Web), the system permission dialog is shown automatically. Returns 1 on success, 0 on failure (e.g., permission denied, no microphone).
audioStop()
Stop audio capture and release the microphone.
audioStop();
audioGetLevel()
Get the current A-weighted sound level in dB(A).
const db = audioGetLevel(); // e.g. 45.2
Returns a smoothed dB(A) value (EMA with 125ms time constant). Typical ranges:
- ~30 dB — quiet room
- ~50 dB — normal conversation
- ~70 dB — busy street
- ~90 dB — loud music
- ~110+ dB — dangerously loud
audioGetPeak()
Get the current peak sample amplitude.
const peak = audioGetPeak(); // 0.0 to 1.0
Returns a normalized amplitude value (0.0 = silence, 1.0 = clipping). Useful for simple level indicators without dB conversion.
audioGetWaveformSamples(count)
Get recent dB samples for waveform visualization.
const samples = audioGetWaveformSamples(64); // array of up to 64 dB values
Returns an array of recent dB(A) readings from a 256-sample ring buffer. Pass the number of samples you want (max 256). Useful for drawing waveform displays or level history charts.
getDeviceModel()
Get the device model identifier.
import { getDeviceModel } from "perry/system";
const model = getDeviceModel(); // e.g. "MacBookPro18,3", "iPhone15,2"
Platform Implementations
| Platform | Audio Backend | Permissions |
|---|---|---|
| macOS | AVAudioEngine | Microphone permission dialog |
| iOS | AVAudioSession + AVAudioEngine | System permission dialog |
| Android | AudioRecord (JNI) | RECORD_AUDIO permission |
| Linux | PulseAudio (libpulse-simple) | None (system-level) |
| Windows | WASAPI (shared mode) | None |
| Web | getUserMedia + AnalyserNode | Browser permission dialog |
All platforms capture at 48kHz mono and apply the same A-weighting filter (IEC 61672 standard, 3 cascaded biquad sections).
Next Steps
Other System APIs
Additional platform-level APIs.
Open URL
Open a URL in the default browser or application:
import { openURL } from "perry/system";
openURL("https://example.com");
openURL("mailto:user@example.com");
| Platform | Implementation |
|---|---|
| macOS | NSWorkspace.open |
| iOS | UIApplication.open |
| Android | Intent.ACTION_VIEW |
| Windows | ShellExecuteW |
| Linux | xdg-open |
| Web | window.open |
Dark Mode Detection
import { isDarkMode } from "perry/system";
if (isDarkMode()) {
// Use dark theme colors
}
| Platform | Detection |
|---|---|
| macOS | NSApp.effectiveAppearance |
| iOS | UITraitCollection |
| Android | Configuration.uiMode |
| Windows | Registry (AppsUseLightTheme) |
| Linux | GTK settings |
| Web | prefers-color-scheme media query |
Clipboard
import { clipboardGet, clipboardSet } from "perry/system";
clipboardSet("Copied text!");
const text = clipboardGet();
Locale Detection
Get the device’s language as a 2-letter ISO 639-1 code:
import { getLocale } from "perry/system";
const lang = getLocale(); // "de", "en", "fr", "es", etc.
if (lang === "de") {
// Use German translations
}
| Platform | Implementation |
|---|---|
| macOS | [NSLocale preferredLanguages] |
| iOS | [NSLocale preferredLanguages] |
| Android | Locale.getDefault().getLanguage() |
| Windows | LANG / LC_ALL environment variable |
| Linux | LANG / LC_ALL environment variable |
| tvOS | [NSLocale preferredLanguages] |
| watchOS | Stub ("en") |
App Icon Extraction
Get the icon for an application or file as a native Image widget. Useful for building app launchers, file browsers, and search UIs:
import { getAppIcon } from "perry/system";
import { VStack, HStack, Text, Image } from "perry/ui";
// macOS: pass .app bundle path
const finderIcon = getAppIcon("/System/Applications/Finder.app");
const safariIcon = getAppIcon("/Applications/Safari.app");
// Linux: pass .desktop file path
const firefoxIcon = getAppIcon("/usr/share/applications/firefox.desktop");
// Use icons in your UI
HStack(8, [
finderIcon,
Text("Finder"),
]);
Returns an Image widget handle (32x32 by default). Returns 0 if the icon cannot be loaded.
| Platform | Implementation |
|---|---|
| macOS | NSWorkspace.shared.icon(forFile:) — works for any file path, .app bundle, or folder |
| Linux | Parses .desktop files for Icon= field, looks up via GTK icon theme, falls back to direct image file loading |
| Windows | Not yet implemented (returns 0) |
Next Steps
- Overview — All system APIs
- UI Overview — Building UIs
Widgets (WidgetKit) Overview
Perry can compile TypeScript widget declarations to native widget extensions across 4 platforms: iOS (WidgetKit), Android (App Widgets), watchOS (Complications), and Wear OS (Tiles).
What Are Widgets?
Home screen widgets display glanceable information outside your app. Perry’s perry/widget module lets you define widgets in TypeScript that compile to each platform’s native widget system.
import { Widget, Text, VStack } from "perry/widget";
Widget({
kind: "MyWidget",
displayName: "My Widget",
description: "Shows a greeting",
entryFields: { name: "string" },
render: (entry) =>
VStack([
Text(`Hello, ${entry.name}!`),
]),
});
How It Works
TypeScript widget declaration
↓ Parse & Lower to WidgetDecl HIR
↓ Platform-specific codegen
↓
iOS/watchOS: SwiftUI WidgetKit extension (Entry, View, TimelineProvider, WidgetBundle, Info.plist)
Android: AppWidgetProvider + layout XML + AppWidgetProviderInfo
Wear OS: TileService + layout
The compiler generates a complete native widget extension for each platform — no platform-specific language knowledge required.
Building
perry widget.ts --target ios-widget # iOS WidgetKit extension
perry widget.ts --target android-widget # Android App Widget
perry widget.ts --target watchos-widget # watchOS Complication
perry widget.ts --target watchos-widget-simulator # watchOS Simulator
perry widget.ts --target wearos-tile # Wear OS Tile
Each target produces the appropriate native widget extension for that platform.
Next Steps
- Creating Widgets — Widget() API in detail
- Components & Modifiers — Available widget components
- Configuration — Widget configuration options
- Data Fetching — Timeline providers and data loading
- Cross-Platform Reference — Platform-specific details
- watchOS Complications — watchOS-specific guide
- Wear OS Tiles — Wear OS-specific guide
Creating Widgets
Define home screen widgets using the Widget() function.
Widget Declaration
import { Widget, Text, VStack, HStack, Image, Spacer } from "perry/widget";
Widget({
kind: "WeatherWidget",
displayName: "Weather",
description: "Shows current weather",
entryFields: {
temperature: "number",
condition: "string",
location: "string",
},
render: (entry) =>
VStack([
HStack([
Text(entry.location),
Spacer(),
Image("cloud.sun.fill"),
]),
Text(`${entry.temperature}°`),
Text(entry.condition),
]),
});
Widget Options
| Property | Type | Description |
|---|---|---|
kind | string | Unique identifier for the widget |
displayName | string | Name shown in widget gallery |
description | string | Description in widget gallery |
entryFields | object | Data fields with types ("string", "number", "boolean", arrays, optionals, objects) |
render | function | Render function receiving entry data, returns widget tree. Optional 2nd param for family. |
config | object | Configurable parameters the user can edit (see below) |
provider | function | Timeline provider function for dynamic data (see below) |
appGroup | string | App group identifier for sharing data with the host app |
Entry Fields
Entry fields define the data your widget displays. Each field has a name and type:
entryFields: {
title: "string",
count: "number",
isActive: "boolean",
}
Array, Optional, and Object Fields
Entry fields support richer types beyond primitives:
entryFields: {
items: [{ name: "string", value: "number" }], // Array of objects
subtitle: "string?", // Optional string
stats: { wins: "number", losses: "number" }, // Nested object
}
These compile to a Swift TimelineEntry struct:
struct WeatherEntry: TimelineEntry {
let date: Date
let temperature: Double
let condition: String
let location: String
}
Conditionals in Render
Use ternary expressions for conditional rendering:
render: (entry) =>
VStack([
Text(entry.isActive ? "Active" : "Inactive"),
entry.count > 0 ? Text(`${entry.count} items`) : Spacer(),
]),
Template Literals
Template literals in widget text are compiled to Swift string interpolation:
Text(`${entry.name}: ${entry.score} points`)
// Compiles to: Text("\(entry.name): \(entry.score) points")
Configuration Parameters
The config field defines user-editable parameters that appear in the widget’s edit UI:
Widget({
kind: "CityWeather",
displayName: "City Weather",
description: "Weather for a chosen city",
config: {
city: { type: "string", displayName: "City", default: "New York" },
units: { type: "enum", displayName: "Units", values: ["Celsius", "Fahrenheit"], default: "Celsius" },
},
entryFields: { temperature: "number", condition: "string" },
render: (entry) => Text(`${entry.temperature}° ${entry.condition}`),
});
Provider Function
The provider field defines a timeline provider that fetches data for the widget:
Widget({
kind: "StockWidget",
displayName: "Stock Price",
description: "Shows current stock price",
config: { symbol: { type: "string", displayName: "Symbol", default: "AAPL" } },
entryFields: { price: "number", change: "string" },
provider: async (config) => {
const res = await fetch(`https://api.example.com/stock/${config.symbol}`);
const data = await res.json();
return { price: data.price, change: data.change };
},
render: (entry) =>
VStack([
Text(`$${entry.price}`).font("title"),
Text(entry.change).color("green"),
]),
});
Placeholder Data
When the widget has no data yet (e.g., first load), the provider can return placeholder data by providing a placeholder field:
Widget({
kind: "NewsWidget",
entryFields: { headline: "string", source: "string" },
placeholder: { headline: "Loading...", source: "---" },
// ...
});
Family-Specific Rendering
The render function accepts an optional second parameter for the widget family, allowing different layouts per size:
render: (entry, family) =>
family === "systemLarge"
? VStack([
Text(entry.title).font("title"),
ForEach(entry.items, (item) => Text(item.name)),
])
: HStack([
Image("star.fill"),
Text(entry.title).font("headline"),
]),
Supported families: "systemSmall", "systemMedium", "systemLarge", "accessoryCircular", "accessoryRectangular", "accessoryInline".
App Group
The appGroup field specifies a shared container for data exchange between the host app and the widget:
Widget({
kind: "AppDataWidget",
appGroup: "group.com.example.myapp",
// ...
});
Multiple Widgets
Define multiple widgets in a single file. They’re bundled into a WidgetBundle:
Widget({
kind: "SmallWidget",
// ...
});
Widget({
kind: "LargeWidget",
// ...
});
Next Steps
- Components — Available widget components and modifiers
- Overview — Widget system overview
Widget Components & Modifiers
Available components and modifiers for widgets.
Text
Text("Hello, World!")
Text(`${entry.name}: ${entry.value}`)
Text Modifiers
const t = Text("Styled");
t.font("title"); // .title, .headline, .body, .caption, etc.
t.color("blue"); // Named color or hex
t.bold();
Layout
VStack
VStack([
Text("Top"),
Text("Bottom"),
])
HStack
HStack([
Text("Left"),
Spacer(),
Text("Right"),
])
ZStack
ZStack([
Image("background"),
Text("Overlay"),
])
Spacer
Flexible space that expands to fill available room:
HStack([
Text("Left"),
Spacer(),
Text("Right"),
])
Image
Display SF Symbols or asset images:
Image("star.fill") // SF Symbol
Image("cloud.sun.rain.fill") // SF Symbol
ForEach
Iterate over array entry fields to render a list of components:
ForEach(entry.items, (item) =>
HStack([
Text(item.name),
Spacer(),
Text(`${item.value}`),
])
)
Divider
A visual separator line:
VStack([
Text("Above"),
Divider(),
Text("Below"),
])
Label
A label with text and an SF Symbol icon:
Label("Downloads", "arrow.down.circle")
Label(`${entry.count} items`, "folder.fill")
Gauge
A circular or linear progress indicator:
Gauge(entry.progress, 0, 100) // value, min, max
Gauge(entry.battery, 0, 1.0)
Modifiers
Widget components support SwiftUI-style modifiers:
Font
Text("Title").font("title")
Text("Body").font("body")
Text("Caption").font("caption")
Color
Text("Red text").color("red")
Text("Custom").color("#FF6600")
Padding
VStack([...]).padding(16)
Frame
widget.frame(width, height)
Max Width
widget.maxWidth("infinity") // Expand to fill available width
Minimum Scale Factor
Allow text to shrink to fit:
Text("Long text").minimumScaleFactor(0.5)
Container Background
Set background color for the widget container:
VStack([...]).containerBackground("blue")
Widget URL
Make the widget tappable with a deep link:
VStack([...]).url("myapp://detail/123")
Edge-Specific Padding
Apply padding to specific edges:
VStack([...]).paddingEdge("top", 8)
VStack([...]).paddingEdge("horizontal", 16)
Conditionals
Render different components based on entry data:
render: (entry) =>
VStack([
entry.isOnline
? Text("Online").color("green")
: Text("Offline").color("red"),
]),
Complete Example
import { Widget, Text, VStack, HStack, Image, Spacer } from "perry/widget";
Widget({
kind: "StatsWidget",
displayName: "Stats",
description: "Shows daily stats",
entryFields: {
steps: "number",
calories: "number",
distance: "string",
},
render: (entry) =>
VStack([
HStack([
Image("figure.walk"),
Text("Daily Stats").font("headline"),
]),
Spacer(),
HStack([
VStack([
Text(`${entry.steps}`).font("title").bold(),
Text("steps").font("caption").color("gray"),
]),
Spacer(),
VStack([
Text(`${entry.calories}`).font("title").bold(),
Text("cal").font("caption").color("gray"),
]),
Spacer(),
VStack([
Text(entry.distance).font("title").bold(),
Text("km").font("caption").color("gray"),
]),
]),
]).padding(16),
});
Next Steps
- Creating Widgets — Widget() API
- Overview — Widget system overview
Widget Configuration
Perry widgets support user-configurable parameters. On iOS/watchOS, these compile to AppIntent configurations (the “Edit Widget” sheet). On Android/Wear OS, they compile to a Configuration Activity.
Defining Config Fields
Add a config object to your Widget() declaration. Each field specifies a type, allowed values, a default, and a display title.
import { Widget, Text, VStack, HStack, Spacer } from "perry/widget";
Widget({
kind: "TopSitesWidget",
displayName: "Top Sites",
description: "Your top performing sites",
supportedFamilies: ["systemSmall", "systemMedium"],
appGroup: "group.com.example.shared",
config: {
sortBy: {
type: "enum",
values: ["clicks", "impressions", "ctr", "position"],
default: "clicks",
title: "Sort By",
},
dateRange: {
type: "enum",
values: ["7d", "28d", "90d"],
default: "7d",
title: "Date Range",
},
},
entryFields: {
total: "number",
label: "string",
},
provider: async (config: { sortBy: string; dateRange: string }) => {
const res = await fetch(
`https://api.example.com/stats?sort=${config.sortBy}&range=${config.dateRange}`
);
const data = await res.json();
return {
entries: [{ total: data.total, label: data.label }],
reloadPolicy: { after: { minutes: 30 } },
};
},
render: (entry) =>
VStack([
Text(`${entry.total}`, { font: "title", fontWeight: "bold" }),
Text(entry.label, { font: "caption", color: "secondary" }),
]),
});
Supported Parameter Types
| Type | TypeScript | Description |
|---|---|---|
| Enum | { type: "enum", values: [...], default: "...", title: "..." } | Picker with fixed choices |
| Boolean | { type: "bool", default: true, title: "..." } | Toggle switch |
| String | { type: "string", default: "...", title: "..." } | Free-text input |
Accessing Config in the Provider
The provider function receives the current config values as its argument. The config object keys match the field names you defined:
provider: async (config: { sortBy: string; dateRange: string }) => {
// config.sortBy === "clicks" | "impressions" | "ctr" | "position"
// config.dateRange === "7d" | "28d" | "90d"
const url = `https://api.example.com/data?sort=${config.sortBy}`;
const res = await fetch(url);
const data = await res.json();
return { entries: [data] };
},
When the user changes a config value, the system calls your provider again with the updated config.
Boolean Config Example
config: {
showDetails: {
type: "bool",
default: true,
title: "Show Details",
},
},
Platform Mapping
iOS / watchOS (AppIntent)
Perry generates a Swift WidgetConfigurationIntent struct with @Parameter properties and AppEnum types for each enum field. The widget uses AppIntentConfiguration instead of StaticConfiguration.
Generated output (auto-generated, not hand-written):
{Name}Intent.swift– contains the AppEnum cases and the intent struct- The provider conforms to
AppIntentTimelineProviderinstead ofTimelineProvider - Config values are serialized to JSON and passed to the native provider function
Users configure the widget by long-pressing and selecting “Edit Widget”, which presents the system-generated AppIntent UI.
Android / Wear OS (Configuration Activity)
Perry generates a {Name}ConfigActivity.kt with Spinner controls for enum fields and Switch controls for boolean fields. Values are persisted in SharedPreferences keyed by widget ID.
Generated output:
{Name}ConfigActivity.kt– Activity with UI controls and a Save buttonwidget_info_{name}.xml– includesandroid:configurepointing to the config activity- AndroidManifest snippet includes an
<activity>entry withAPPWIDGET_CONFIGUREintent filter
The config activity launches automatically when the user first adds the widget.
Build Commands
# iOS
perry widget.ts --target ios-widget --app-bundle-id com.example.app -o widget_out
# Android
perry widget.ts --target android-widget --app-bundle-id com.example.app -o widget_out
Next Steps
- Data Fetching – Provider function and shared storage
- Components – Available widget components
- Cross-Platform Reference – Feature matrix and build targets
Provider Function and Data Fetching
The provider function is the heart of a dynamic widget. It fetches data, transforms it, and returns timeline entries that the system renders on schedule.
Provider Lifecycle
- The system calls your provider when the widget is first added, when a snapshot is needed, and when the reload policy expires.
- Your provider runs as native LLVM-compiled code linked into the widget extension.
- The provider returns one or more timeline entries. The system renders each entry at its scheduled time.
- After the last entry, the reload policy determines when the provider runs again.
Basic Provider
import { Widget, Text, VStack } from "perry/widget";
Widget({
kind: "WeatherWidget",
displayName: "Weather",
description: "Current conditions",
supportedFamilies: ["systemSmall"],
entryFields: {
temperature: "number",
condition: "string",
},
provider: async () => {
const res = await fetch("https://api.weather.example.com/current");
const data = await res.json();
return {
entries: [
{ temperature: data.temp, condition: data.description },
],
reloadPolicy: { after: { minutes: 15 } },
};
},
render: (entry) =>
VStack([
Text(`${entry.temperature}°`, { font: "title" }),
Text(entry.condition, { font: "caption" }),
]),
});
Authenticated Requests with Shared Storage
Widgets run in a separate process and cannot access your app’s memory. Use sharedStorage() to read values that your app has written to a shared container.
iOS / watchOS: App Groups
On Apple platforms, shared storage maps to UserDefaults(suiteName:) backed by an App Group container. Set the appGroup field in your widget declaration:
Widget({
kind: "DashboardWidget",
displayName: "Dashboard",
description: "Account summary",
appGroup: "group.com.example.shared",
entryFields: {
revenue: "number",
users: "number",
},
provider: async () => {
const token = sharedStorage("auth_token");
const res = await fetch("https://api.example.com/dashboard", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
return {
entries: [{ revenue: data.revenue, users: data.activeUsers }],
reloadPolicy: { after: { minutes: 30 } },
};
},
render: (entry) =>
VStack([
Text(`$${entry.revenue}`, { font: "title" }),
Text(`${entry.users} active users`, { font: "caption" }),
]),
});
Your main app writes the token to the shared container:
import { preferencesSet } from "perry/system";
// In your app's login flow:
preferencesSet("auth_token", token);
Setup requirement (iOS): Add an App Group capability in Xcode to both the main app target and the widget extension target. The identifier must match the appGroup value.
Android / Wear OS: SharedPreferences
On Android, shared storage maps to SharedPreferences with the name perry_shared. The generated Bridge.kt reads values via context.getSharedPreferences("perry_shared", MODE_PRIVATE).
Reload Policies
The reloadPolicy field controls when the system next calls your provider:
return {
entries: [{ ... }],
reloadPolicy: { after: { minutes: 30 } },
};
| Policy | Behavior |
|---|---|
{ after: { minutes: N } } | Re-fetch after N minutes. Compiles to .after(Date().addingTimeInterval(N*60)) on iOS and setFreshnessIntervalMillis(N*60000) on Wear OS. |
| (omitted) | Defaults to 30 minutes on iOS, 30 minutes on Android/Wear OS. |
Budget limits: iOS restricts widget refreshes. Typical budget is 40–70 refreshes per day. watchOS is stricter (see watchOS Complications). Request only what you need.
JSON Response Handling
The provider function receives the parsed JSON directly. Entry field types must match your entryFields declaration:
entryFields: {
items: { type: "array", items: { type: "object", fields: { name: "string", count: "number" } } },
total: "number",
},
provider: async () => {
const res = await fetch("https://api.example.com/items");
const data = await res.json();
return {
entries: [{
items: data.results.map((r: any) => ({ name: r.name, count: r.count })),
total: data.total,
}],
};
},
Error Handling
If the fetch fails or JSON parsing throws, the widget extension falls back to the placeholder data:
Widget({
// ...
placeholder: { temperature: 0, condition: "Loading..." },
provider: async () => {
const res = await fetch("https://api.example.com/weather");
if (!res.ok) {
// Return stale/fallback data with a short retry interval
return {
entries: [{ temperature: 0, condition: "Unavailable" }],
reloadPolicy: { after: { minutes: 5 } },
};
}
const data = await res.json();
return {
entries: [{ temperature: data.temp, condition: data.desc }],
reloadPolicy: { after: { minutes: 15 } },
};
},
});
The placeholder field provides data shown in the widget gallery and during loading. If the provider throws an unhandled exception, the generated Swift/Kotlin code catches it and renders the placeholder instead.
Multiple Timeline Entries
Return multiple entries to schedule future content without re-fetching:
provider: async () => {
const res = await fetch("https://api.example.com/hourly");
const hours = await res.json();
return {
entries: hours.map((h: any) => ({
temperature: h.temp,
condition: h.condition,
})),
reloadPolicy: { after: { minutes: 60 } },
};
},
Each entry is rendered at the corresponding date in the timeline. The system transitions between entries automatically.
Next Steps
- Configuration – User-configurable parameters
- Cross-Platform Reference – Build targets and platform differences
Cross-Platform Reference
Perry widgets compile from a single TypeScript source to four platforms. The same Widget({...}) declaration produces native code for each target.
Target Flags
| Platform | Target Flag | Output |
|---|---|---|
| iOS | --target ios-widget | SwiftUI .swift + Info.plist |
| iOS Simulator | --target ios-widget-simulator | Same, simulator SDK |
| Android | --target android-widget | Kotlin/Glance .kt + widget_info XML |
| watchOS | --target watchos-widget | SwiftUI .swift (accessory families) |
| watchOS Simulator | --target watchos-widget-simulator | Same, simulator SDK |
| Wear OS | --target wearos-tile | Kotlin Tiles .kt + manifest |
Feature Matrix
| Feature | iOS | Android | watchOS | Wear OS |
|---|---|---|---|---|
| Text | Yes | Yes | Yes | Yes |
| VStack/HStack/ZStack | Yes | Column/Row/Box | Yes | Column/Row/Box |
| Image (SF Symbols) | Yes | R.drawable | Yes | R.drawable |
| Spacer | Yes | Yes | Yes | Yes |
| Divider | Yes | Spacer+bg | Yes | Spacer |
| ForEach | Yes | forEach | Yes | forEach |
| Label | Yes | Row compound | Yes | Text fallback |
| Gauge | N/A | Text fallback | Yes | CircularProgressIndicator |
| Conditional | Yes | if | Yes | if |
| FamilySwitch | Yes | LocalSize | Yes | requestedSize |
| Config (AppIntent) | Yes | Config Activity | Yes (10+) | SharedPrefs |
| Native provider | Yes | JNI | Yes | JNI |
| sharedStorage | UserDefaults | SharedPrefs | UserDefaults | SharedPrefs |
| Deep linking (url) | widgetURL | clickable Intent | widgetURL | N/A |
Platform-Specific Notes
iOS
- Minimum deployment: iOS 17.0
- AppIntentConfiguration requires
import AppIntents - Widget extension memory limit: ~30MB
Android
- Requires Glance dependency:
androidx.glance:glance-appwidget:1.1.0 - Widget sizes mapped from iOS families: systemSmall=2x2, systemMedium=4x2, systemLarge=4x4
minimumScaleFactornot supported in Glance (skipped with warning)
watchOS
- Minimum deployment: watchOS 9.0
- Accessory families only (circular, rectangular, inline)
- Tighter memory (~15-20MB) and refresh budgets (hourly)
- AppIntent requires watchOS 10+; older versions get StaticConfiguration
Wear OS
- Same native compilation as Android phone (Wear OS = Android)
- Requires Horologist + Tiles Material 3 dependencies
- Tiles are full-screen cards in the carousel
Gaugemaps toCircularProgressIndicator
Build Instructions
iOS
perry widget.ts --target ios-widget --app-bundle-id com.example.app -o widget_out
xcrun --sdk iphoneos swiftc -target arm64-apple-ios17.0 \
widget_out/*.swift -framework WidgetKit -framework SwiftUI \
-o widget_out/WidgetExtension
Android
perry widget.ts --target android-widget --app-bundle-id com.example.app -o widget_out
# Copy .kt files to app/src/main/java/com/example/app/
# Copy xml/ to app/src/main/res/xml/
# Merge AndroidManifest_snippet.xml into AndroidManifest.xml
watchOS
perry widget.ts --target watchos-widget --app-bundle-id com.example.app -o widget_out
xcrun --sdk watchos swiftc -target arm64-apple-watchos9.0 \
widget_out/*.swift -framework WidgetKit -framework SwiftUI \
-o widget_out/WidgetExtension
Wear OS
perry widget.ts --target wearos-tile --app-bundle-id com.example.app -o widget_out
# Copy .kt files to Wear OS module
# Add Horologist + Tiles Material 3 dependencies to build.gradle
# Merge AndroidManifest_snippet.xml
watchOS Complications
Perry widgets can compile to watchOS WidgetKit complications using --target watchos-widget. The same Widget({...}) source produces both iOS and watchOS widgets — the supported families determine the rendering.
Accessory Families
watchOS complications use accessory families instead of system families:
| Family | Size | Best For |
|---|---|---|
accessoryCircular | ~76x76pt | Single icon, number, or Gauge |
accessoryRectangular | ~160x76pt | 2-3 lines of text |
accessoryInline | Single line | Short text only |
Gauge Component
The Gauge component is designed for watchOS circular complications:
import { Widget, Text, VStack, Gauge } from "perry/widget";
Widget({
kind: "QuickStats",
displayName: "Quick Stats",
supportedFamilies: ["accessoryCircular", "accessoryRectangular"],
render(entry: { progress: number; label: string }, family) {
if (family === "accessoryCircular") {
return Gauge(entry.progress, {
label: "Done", style: "circular"
})
}
return VStack([
Text(entry.label, { font: "headline" }),
Gauge(entry.progress, { label: "Progress", style: "linear" }),
])
},
})
Gauge Styles
circular— Ring gauge, maps to.gaugeStyle(.accessoryCircularCapacity)in SwiftUIlinear/linearCapacity— Horizontal bar, maps to.gaugeStyle(.linearCapacity)
Refresh Budgets
watchOS has stricter refresh budgets than iOS:
- Recommended: refresh every 60 minutes (
reloadPolicy: { after: { minutes: 60 } }) - Maximum: system may throttle more aggressively than iOS
- Background refresh uses
BackgroundTaskframework
Compilation
# For Apple Watch device
perry widget.ts --target watchos-widget --app-bundle-id com.example.app -o widget_out
# For Apple Watch Simulator
perry widget.ts --target watchos-widget-simulator --app-bundle-id com.example.app -o widget_out
Build:
xcrun --sdk watchos swiftc -target arm64-apple-watchos9.0 \
widget_out/*.swift \
-framework WidgetKit -framework SwiftUI \
-o widget_out/WidgetExtension
Configuration
- watchOS 10+ supports AppIntent for widget configuration (same as iOS 17+)
- Older watchOS versions automatically get
StaticConfigurationfallback configparams work identically to iOS
Memory Considerations
watchOS widget extensions have tighter memory limits (~15-20MB) compared to iOS (~30MB). The provider-only compilation approach is critical — only the data-fetching code runs natively, keeping memory usage minimal.
Wear OS Tiles
Perry widgets can compile to Wear OS Tiles using --target wearos-tile. Tiles are glanceable surfaces in the Wear OS tile carousel and watch face complications.
Concepts
- Tiles are full-screen cards users swipe through on their watch
- Complications are small data displays on the watch face
- Perry compiles
Widget({...})to aSuspendingTileServicewith layout builders
Supported Components
| Widget API | Wear OS Mapping |
|---|---|
Text | LayoutElementBuilders.Text |
VStack | LayoutElementBuilders.Column |
HStack | LayoutElementBuilders.Row |
Spacer | LayoutElementBuilders.Spacer |
Divider | Spacer with 1dp height |
Gauge(circular) | LayoutElementBuilders.Arc + ArcLine |
Gauge(linear) | Text fallback |
Image | Resource-based (provide drawable) |
Example
import { Widget, Text, VStack, Gauge } from "perry/widget";
Widget({
kind: "StepsTile",
displayName: "Steps",
description: "Daily step count",
supportedFamilies: ["accessoryCircular"],
provider: async () => {
return {
entries: [{ steps: 7500, goal: 10000 }],
reloadPolicy: { after: { minutes: 60 } }
}
},
render(entry: { steps: number; goal: number }) {
return VStack([
Gauge(entry.steps / entry.goal, {
label: "Steps", style: "circular"
}),
Text(`${entry.steps}`, { font: "caption2" }),
])
},
})
Compilation
perry widget.ts --target wearos-tile --app-bundle-id com.example.app -o tile_out
Output:
{Name}TileService.kt—SuspendingTileServicewith tile layout{Name}TileBridge.kt— JNI bridge for native provider (if provider exists)AndroidManifest_snippet.xml— Service declaration
Gradle Integration
Add to your Wear OS module’s build.gradle:
dependencies {
implementation "com.google.android.horologist:horologist-tiles:0.6.5"
implementation "androidx.wear.tiles:tiles-material:1.4.0"
implementation "androidx.wear.tiles:tiles:1.4.0"
}
Merge the manifest snippet into your AndroidManifest.xml:
<service
android:name=".StepsTileService"
android:exported="true"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
</service>
Native Provider
Same as Android phone widgets — Wear OS is Android:
- Target triple:
aarch64-linux-android libwidget_provider.soloaded viaSystem.loadLibrary- JNI bridge pattern identical to phone Glance widgets
sharedStorage()usesSharedPreferences
Refresh
Wear Tiles use freshnessIntervalMillis on the Tile builder. Set via reloadPolicy: { after: { minutes: N } } in the provider return value. Default: 60 minutes.
Plugin System Overview
Perry supports native plugins as shared libraries (.dylib/.so). Plugins extend Perry applications with custom hooks, tools, services, and routes.
How It Works
- A plugin is a Perry-compiled shared library with
activate(api)anddeactivate()entry points - The host application loads plugins with
loadPlugin(path) - Plugins register hooks, tools, and services via the API handle
- The host dispatches events to plugins via
emitHook(name, data)
Host Application
↓ loadPlugin("./my-plugin.dylib")
↓ calls plugin_activate(api_handle)
Plugin
↓ api.registerHook("beforeSave", callback)
↓ api.registerTool("format", callback)
Host
↓ emitHook("beforeSave", data) → plugin callback runs
Quick Example
Plugin (compiled with --output-type dylib)
// my-plugin.ts
export function activate(api: PluginAPI) {
api.setMetadata("my-plugin", "1.0.0", "A sample plugin");
api.registerHook("beforeSave", (data) => {
console.log("About to save:", data);
return data; // Return modified data (filter mode)
});
api.registerTool("greet", (args) => {
return `Hello, ${args.name}!`;
});
}
export function deactivate() {
console.log("Plugin deactivated");
}
perry my-plugin.ts --output-type dylib -o my-plugin.dylib
Host Application
import { loadPlugin, emitHook, invokeTool, listPlugins } from "perry/plugin";
loadPlugin("./my-plugin.dylib");
// List loaded plugins
const plugins = listPlugins();
console.log(plugins); // [{ name: "my-plugin", version: "1.0.0", ... }]
// Emit a hook
const result = emitHook("beforeSave", { content: "..." });
// Invoke a tool
const greeting = invokeTool("greet", { name: "Perry" });
console.log(greeting); // "Hello, Perry!"
Plugin ABI
Plugins must export these symbols:
perry_plugin_abi_version()— Returns ABI version (for compatibility checking)plugin_activate(api_handle)— Called when plugin is loadedplugin_deactivate()— Called when plugin is unloaded
Perry generates these automatically from your activate/deactivate exports.
Native Extensions
Perry also supports native extensions — packages that bundle platform-specific Rust/Swift/JNI code and compile directly into your binary. These are used for accessing platform APIs like the App Store review prompt or StoreKit in-app purchases.
See Native Extensions for details.
Next Steps
- Creating Plugins — Build a plugin step by step
- Hooks & Events — Hook modes, event bus, tools
- Native Extensions — Extensions with platform-native code
- App Store Review — Native review prompt (iOS/Android)
Creating Plugins
Build Perry plugins as shared libraries that extend host applications.
Step 1: Write the Plugin
// counter-plugin.ts
let count = 0;
export function activate(api: PluginAPI) {
api.setMetadata("counter", "1.0.0", "Counts hook invocations");
api.registerHook("onRequest", (data) => {
count++;
console.log(`Request #${count}`);
return data;
});
api.registerTool("getCount", () => {
return count;
});
}
export function deactivate() {
console.log(`Total requests processed: ${count}`);
}
Step 2: Compile as Shared Library
perry counter-plugin.ts --output-type dylib -o counter-plugin.dylib
The --output-type dylib flag tells Perry to produce a .dylib (macOS) or .so (Linux) instead of an executable.
Perry automatically:
- Generates
perry_plugin_abi_version()returning the current ABI version - Generates
plugin_activate(api_handle)calling youractivate()function - Generates
plugin_deactivate()calling yourdeactivate()function - Exports symbols with
-rdynamicfor the host to find
Step 3: Load from Host
// host-app.ts
import { loadPlugin, emitHook, invokeTool, discoverPlugins } from "perry/plugin";
// Load a specific plugin
loadPlugin("./counter-plugin.dylib");
// Or discover plugins in a directory
discoverPlugins("./plugins/");
// Use the plugin
emitHook("onRequest", { path: "/api/users" });
const count = invokeTool("getCount", {});
console.log(`Processed ${count} requests`);
Plugin API Reference
The api object passed to activate() provides:
Metadata
api.setMetadata(name: string, version: string, description: string)
Hooks
api.registerHook(name: string, callback: (data: any) => any, priority?: number)
Hooks are called in priority order (lower number = called first).
Tools
api.registerTool(name: string, callback: (args: any) => any)
Tools are invoked by name from the host.
Configuration
const value = api.getConfig(key: string) // Read host-provided config
Events
api.on(event: string, handler: (data: any) => void) // Listen for events
api.emit(event: string, data: any) // Emit to other plugins
Next Steps
- Hooks & Events — Hook modes, event bus
- Overview — Plugin system overview
Hooks & Events
Perry plugins communicate through hooks, events, and tools.
Hook Modes
Hooks support three execution modes:
Filter Mode (default)
Each plugin receives data and returns (possibly modified) data. The output of one plugin becomes the input of the next:
api.registerHook("transform", (data) => {
data.content = data.content.toUpperCase();
return data; // Returned data goes to next plugin
});
Action Mode
Plugins receive data but return value is ignored. Used for side effects:
api.registerHook("onSave", (data) => {
console.log(`Saved: ${data.path}`);
// Return value ignored
});
Waterfall Mode
Like filter mode, but specifically for accumulating/building up a result through the chain:
api.registerHook("buildMenu", (items) => {
items.push({ label: "My Plugin Action", action: () => {} });
return items;
});
Hook Priority
Lower priority numbers run first:
api.registerHook("beforeSave", validate, 10); // Runs first
api.registerHook("beforeSave", transform, 20); // Runs second
api.registerHook("beforeSave", log, 100); // Runs last
Default priority is 50.
Event Bus
Plugins can communicate with each other through events:
Emitting Events
// From a plugin
api.emit("dataUpdated", { source: "my-plugin", records: 42 });
// From the host
import { emitEvent } from "perry/plugin";
emitEvent("dataUpdated", { source: "host", records: 100 });
Listening for Events
api.on("dataUpdated", (data) => {
console.log(`${data.source} updated ${data.records} records`);
});
Tools
Plugins register callable tools:
// Plugin registers a tool
api.registerTool("formatCode", (args) => {
return formatSource(args.code, args.language);
});
// Host invokes the tool
import { invokeTool } from "perry/plugin";
const formatted = invokeTool("formatCode", {
code: "const x=1",
language: "typescript",
});
Configuration
Hosts can pass configuration to plugins:
// Host sets config
import { setConfig } from "perry/plugin";
setConfig("theme", "dark");
setConfig("maxRetries", "3");
// Plugin reads config
export function activate(api: PluginAPI) {
const theme = api.getConfig("theme"); // "dark"
const retries = api.getConfig("maxRetries"); // "3"
}
Introspection
Query loaded plugins and their registrations:
import { listPlugins, listHooks, listTools } from "perry/plugin";
const plugins = listPlugins(); // [{ name, version, description }]
const hooks = listHooks(); // [{ name, pluginName, priority }]
const tools = listTools(); // [{ name, pluginName }]
Next Steps
- Creating Plugins — Build a plugin
- Overview — Plugin system overview
Native Extensions
Perry supports native extensions — packages that bundle platform-specific code (Rust, Swift, JNI) alongside a TypeScript API. Unlike dynamic plugins loaded at runtime, native extensions are compiled directly into your binary.
Native extensions are how you access platform APIs that aren’t part of Perry’s built-in System APIs or Standard Library. Examples include App Store Review and StoreKit for in-app purchases.
Using a native extension
1. Add the extension to your project
Place the extension directory alongside your project, or in a shared extensions directory:
my-app/
├── package.json
├── src/
│ └── index.ts
└── extensions/
└── perry-appstore-review/
├── package.json
├── src/
│ └── index.ts
├── crate-ios/
├── crate-android/
└── crate-stub/
2. Compile with --bundle-extensions
Pass the extensions directory when building:
perry src/index.ts -o app --target ios --bundle-extensions ./extensions
Perry discovers every subdirectory with a package.json, compiles its native crates for the target platform, and links them into your binary.
3. Import and use
import { requestReview } from "perry-appstore-review";
await requestReview();
The import resolves at compile time to the extension’s entry point. No runtime module loading is involved — the function compiles to a direct native call.
How native extensions work
A native extension is a directory with a package.json that declares a perry.nativeLibrary section. This tells Perry which native functions exist, their signatures, and which Rust crate to compile for each platform.
package.json manifest
{
"name": "perry-appstore-review",
"version": "0.1.0",
"main": "src/index.ts",
"perry": {
"nativeLibrary": {
"functions": [
{ "name": "sb_appreview_request", "params": [], "returns": "f64" }
],
"targets": {
"ios": {
"crate": "crate-ios",
"lib": "libperry_appreview.a",
"frameworks": ["StoreKit"]
},
"android": {
"crate": "crate-android",
"lib": "libperry_appreview.a",
"frameworks": []
},
"macos": {
"crate": "crate-ios",
"lib": "libperry_appreview.a",
"frameworks": ["StoreKit"]
}
}
}
}
}
functions
Each entry declares a native function the extension exports:
| Field | Description |
|---|---|
name | Symbol name — must match the #[no_mangle] Rust function exactly |
params | Array of LLVM types: "i64" for pointers/strings, "f64" for numbers, "i32" for integers |
returns | Return type — typically "f64" (NaN-boxed value or promise handle) |
targets
Each target platform maps to a Rust crate that implements the native functions:
| Field | Description |
|---|---|
crate | Relative path to the Rust crate directory |
lib | Name of the static library produced by cargo build |
frameworks | System frameworks to link (iOS/macOS only) |
Multiple targets can share the same crate (e.g., iOS and macOS often share an implementation). Platforms without an entry fall back to the stub.
Extension directory layout
perry-appstore-review/
├── package.json # Manifest with perry.nativeLibrary
├── src/
│ └── index.ts # TypeScript API (what users import)
├── crate-ios/ # iOS/macOS native implementation
│ ├── Cargo.toml # [lib] crate-type = ["staticlib"]
│ ├── build.rs # Compiles Swift if needed
│ ├── src/
│ │ └── lib.rs # Rust FFI: #[no_mangle] pub extern "C" fn ...
│ └── swift/
│ └── bridge.swift # Swift bridge for Apple APIs (@_cdecl)
├── crate-android/ # Android native implementation
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Rust FFI with JNI calls
└── crate-stub/ # Fallback for unsupported platforms
├── Cargo.toml
└── src/
└── lib.rs # Returns error immediately
TypeScript side
The src/index.ts declares native functions and optionally wraps them in a friendlier API:
// Declare the native function (name must match package.json)
declare function sb_appreview_request(): number;
// Wrap it with a proper TypeScript signature
export async function requestReview(): Promise<void> {
await (sb_appreview_request() as any);
}
declare function tells Perry the function is provided by native code. The raw return type is number because all values cross the FFI boundary as NaN-boxed f64 values. Promise handles are NaN-boxed pointers that Perry’s runtime knows how to await.
Rust side
Each platform crate is a staticlib that implements the declared functions using #[no_mangle] pub extern "C":
#![allow(unused)]
fn main() {
// Perry runtime FFI
extern "C" {
fn js_promise_new() -> *mut u8;
fn js_promise_resolve(promise: *mut u8, value: f64);
fn js_nanbox_string(ptr: i64) -> f64;
fn js_nanbox_pointer(ptr: i64) -> f64;
}
#[no_mangle]
pub extern "C" fn sb_appreview_request() -> f64 {
unsafe {
let promise = js_promise_new();
// ... call platform API, resolve promise when done ...
js_nanbox_pointer(promise as i64)
}
}
}
Key runtime functions available to native code:
| Function | Purpose |
|---|---|
js_promise_new() | Create a new Perry promise, returns pointer |
js_promise_resolve(promise, value) | Resolve a promise with a NaN-boxed value |
js_nanbox_string(ptr) | Convert a C string pointer to a NaN-boxed string |
js_nanbox_pointer(ptr) | Convert a pointer to a NaN-boxed object reference |
js_get_string_pointer_unified(val) | Extract string pointer from a NaN-boxed value |
js_string_from_bytes(ptr, len) | Create a Perry string from bytes |
Swift bridge (iOS/macOS)
Apple platform APIs are often easiest to call from Swift. The pattern is:
- Write a Swift file with
@_cdecl("function_name")exports - Compile it to a static library in
build.rs - Call the Swift functions from Rust via
extern "C"
import StoreKit
typealias Callback = @convention(c) (UnsafeMutableRawPointer, UnsafePointer<CChar>) -> Void
@_cdecl("swift_appreview_request")
func swiftRequestReview(_ callback: @escaping Callback, _ context: UnsafeMutableRawPointer) {
DispatchQueue.main.async {
if let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
let result = "{\"success\":true}"
result.withCString { callback(context, $0) }
}
}
The build.rs compiles the Swift source into a static library using swiftc, targeting the correct platform SDK:
// build.rs (simplified)
fn main() {
// Detect target: aarch64-apple-ios → arm64-apple-ios16.0, iphoneos SDK
// Compile: swiftc -emit-library -static -target ... -sdk ... -framework StoreKit
// Link: cargo:rustc-link-lib=static=review_bridge
}
JNI bridge (Android)
Android platform APIs are accessed through JNI. The pattern:
- Get the
JavaVMviaJNI_GetCreatedJavaVMs() - Attach the current thread to get a
JNIEnv - Call Java/Kotlin APIs through JNI method invocations
- Resolve the Perry promise with the result
#![allow(unused)]
fn main() {
use jni::JavaVM;
use jni::objects::JValue;
fn request_review_impl() -> Result<(), String> {
let vm = get_java_vm()?;
let mut env = vm.attach_current_thread_as_daemon().map_err(|e| e.to_string())?;
// Get Activity from PerryBridge
let bridge = env.find_class("com/perry/app/PerryBridge").map_err(|e| e.to_string())?;
let activity = env.call_static_method(bridge, "getActivity", "()Landroid/app/Activity;", &[])
.map_err(|e| e.to_string())?.l().map_err(|e| e.to_string())?;
// Call platform APIs via JNI...
Ok(())
}
}
If the Android implementation requires a Java library (e.g., Google Play In-App Review), the app’s build.gradle must include the dependency. Document this requirement clearly for your extension’s users.
Stub crate
For platforms without a native implementation, the stub immediately resolves the promise with an error:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn sb_appreview_request() -> f64 {
unsafe {
let promise = js_promise_new();
let msg = "{\"error\":\"Not available on this platform\"}";
let c_str = std::ffi::CString::new(msg).unwrap();
let val = js_nanbox_string(c_str.as_ptr() as i64);
std::mem::forget(c_str);
js_promise_resolve(promise, val);
js_nanbox_pointer(promise as i64)
}
}
}
Build requirements
| Platform | Requirements |
|---|---|
| iOS | macOS host, Xcode, rustup target add aarch64-apple-ios |
| iOS Simulator | macOS host, Xcode, rustup target add aarch64-apple-ios-sim |
| macOS | macOS host, Xcode Command Line Tools |
| Android | Android NDK, rustup target add aarch64-linux-android |
When Perry encounters a perry.nativeLibrary manifest during compilation, it:
- Selects the crate for the current
--targetplatform - Runs
cargo build --release --target <triple>in the crate directory - Links the resulting
.astatic library into the final binary - Adds any declared frameworks (e.g.,
-framework StoreKit)
Creating your own native extension
- Create the directory structure shown above
- Define your functions in
package.jsonunderperry.nativeLibrary - Implement each function in the platform crates with matching
#[no_mangle] pub extern "C"signatures - Write a TypeScript entry point that declares and optionally wraps the native functions
- Add a stub crate for unsupported platforms
- Test with
--bundle-extensions:perry app.ts --target ios-simulator --bundle-extensions ./extensions
Next Steps
- App Store Review — Native review prompt extension (iOS/Android)
- Creating Plugins — Dynamic plugins loaded at runtime
- Overview — Plugin system overview
App Store Review
Prompt users to rate your app using the native app store review dialog on iOS and Android.
The perry-appstore-review extension exposes a single function — requestReview() — that opens the platform’s native review prompt. It does nothing else: when and how often to ask is entirely up to you.
Repository: github.com/PerryTS/appstorereview
Quick start
1. Add the extension
Clone or copy the extension into your project’s extensions directory:
mkdir -p extensions
cd extensions
git clone https://github.com/PerryTS/appstorereview.git perry-appstore-review
cd ..
Your project structure:
my-app/
├── package.json
├── src/
│ └── index.ts
└── extensions/
└── perry-appstore-review/
2. Use in your app
import { requestReview } from "perry-appstore-review";
// Show the review prompt when the user completes a meaningful action
async function onLevelComplete() {
await requestReview();
}
3. Build
perry src/index.ts -o app --target ios --bundle-extensions ./extensions
The --bundle-extensions flag tells Perry to discover, compile, and link all native extensions in the given directory. The app store review native code is compiled and statically linked into your binary — no runtime dependencies.
API
requestReview(): Promise<void>
Opens the native app store review prompt. Returns a promise that resolves when the prompt has been presented (or skipped by the OS).
import { requestReview } from "perry-appstore-review";
await requestReview();
The function only triggers the prompt. It does not:
- Track whether the user has already reviewed
- Throttle how often the prompt appears (iOS does this automatically; Android does not)
- Return whether the user actually left a review (neither platform provides this)
Platform behavior
iOS
Uses SKStoreReviewController.requestReview(in:) from StoreKit.
| Detail | Value |
|---|---|
| Native API | SKStoreReviewController.requestReview(in: UIWindowScene) |
| Minimum iOS version | 14.0 |
| Framework | StoreKit |
| Thread | Dispatched to main thread automatically |
| Throttling | Apple limits display to 3 times per 365-day period per app. The system may silently ignore the call. |
| Development builds | Always shown in debug/TestFlight builds |
| User control | Users can disable review prompts in Settings > App Store |
Important: Apple’s throttling means the prompt is not guaranteed to appear every time requestReview() is called. Design your app flow so that not showing the prompt doesn’t break the user experience.
macOS
Uses the same StoreKit API. Shares the iOS native crate (both compile from crate-ios).
| Detail | Value |
|---|---|
| Native API | SKStoreReviewController.requestReview() |
| Minimum macOS version | 13.0 |
| Framework | StoreKit |
| Throttling | Same as iOS — system-controlled |
Only works for apps distributed through the Mac App Store.
Android
Uses the Google Play In-App Review API.
| Detail | Value |
|---|---|
| Native API | ReviewManager.requestReviewFlow() + launchReviewFlow() |
| Library | com.google.android.play:review |
| Minimum API level | 21 (Android 5.0) |
| Throttling | Google enforces a quota — the prompt may not appear every time |
| Execution | Runs on a background thread to avoid blocking the UI |
Required Gradle dependency: The Google Play In-App Review API is not part of the Android SDK. You must add it to your app’s build.gradle:
dependencies {
implementation 'com.google.android.play:review:2.0.2'
}
Without this dependency, requestReview() will resolve with an error explaining the missing library.
Other platforms
On unsupported platforms (Linux, Windows, Web), requestReview() resolves immediately with an error. It will not throw — your app continues normally.
Best practices
Do ask at the right moment. Prompt after a positive experience — completing a level, finishing a task, achieving a goal. Don’t ask on first launch or during onboarding.
Don’t ask too often. Even though iOS throttles automatically, Android does not have the same strict limits. Implement your own logic to track when you last asked:
import { requestReview } from "perry-appstore-review";
import { preferencesGet, preferencesSet } from "perry/system";
async function maybeAskForReview() {
const lastAsked = Number(preferencesGet("lastReviewAsk") || "0");
const now = Date.now();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (now - lastAsked > thirtyDays) {
preferencesSet("lastReviewAsk", String(now));
await requestReview();
}
}
Don’t condition app behavior on the review. Neither iOS nor Android tells you whether the user left a review, gave a rating, or dismissed the prompt. The promise resolving does not mean a review was submitted.
Don’t use custom review dialogs before the native one. Both Apple and Google discourage showing your own “Rate this app?” dialog before the native prompt. The native prompt is designed to be low-friction — adding a pre-prompt increases abandonment.
Extension structure
The extension follows the standard native extension layout:
perry-appstore-review/
├── package.json # Declares sb_appreview_request function
├── src/
│ └── index.ts # Exports requestReview()
├── crate-ios/ # iOS/macOS: Swift → SKStoreReviewController
│ ├── Cargo.toml
│ ├── build.rs # Compiles Swift to static library
│ ├── src/lib.rs # Rust FFI bridge
│ └── swift/review_bridge.swift
├── crate-android/ # Android: JNI → Play In-App Review API
│ ├── Cargo.toml
│ └── src/lib.rs
└── crate-stub/ # Other platforms: resolves with error
├── Cargo.toml
└── src/lib.rs
One native function is declared in package.json:
{
"perry": {
"nativeLibrary": {
"functions": [
{ "name": "sb_appreview_request", "params": [], "returns": "f64" }
]
}
}
}
The TypeScript layer wraps this into the public requestReview() function. The native layer creates a Perry promise, calls the platform API, and resolves the promise when done.
Next Steps
- Native Extensions — How native extensions work, creating your own
- iOS Platform — iOS platform guide
- Android Platform — Android platform guide
Geisterhand — In-Process UI Testing
Geisterhand (German for “ghost hand”) embeds a lightweight HTTP server inside your Perry app that lets you interact with every widget programmatically. Click buttons, type into text fields, drag sliders, toggle switches, capture screenshots, and run chaos-mode random fuzzing — all via simple HTTP calls.
It works on all 5 native platforms (macOS, iOS, Android, Linux/GTK4, Windows) with zero external dependencies. The server starts automatically when you compile with --enable-geisterhand.
Quick Start
# 1. Compile with geisterhand enabled (libs auto-build on first use)
perry app.ts -o app --enable-geisterhand
# 2. Run the app
./app
# [geisterhand] listening on http://127.0.0.1:7676
# 3. In another terminal — interact with the app
curl http://127.0.0.1:7676/widgets # List all widgets
curl -X POST http://127.0.0.1:7676/click/3 # Click button with handle 3
curl http://127.0.0.1:7676/screenshot -o s.png # Capture window screenshot
Custom Port
The default port is 7676. Use --geisterhand-port to change it (this implies --enable-geisterhand, so you don’t need both flags):
perry app.ts -o app --geisterhand-port 9090
# or with perry run:
perry run --geisterhand-port 9090
With perry run
perry run --enable-geisterhand
perry run macos --geisterhand-port 8080
perry run ios --enable-geisterhand
API Reference
All endpoints return JSON unless noted otherwise. All responses include Access-Control-Allow-Origin: * for browser-based tools. OPTIONS requests are supported for CORS preflight.
Health Check
GET /health
→ {"status":"ok"}
Use this to wait for the app to be ready before running tests.
List Widgets
GET /widgets
Returns a JSON array of all registered widgets:
[
{"handle": 3, "widget_type": 0, "callback_kind": 0, "label": "Click Me", "shortcut": ""},
{"handle": 4, "widget_type": 1, "callback_kind": 1, "label": "Type here...", "shortcut": ""},
{"handle": 5, "widget_type": 2, "callback_kind": 1, "label": "", "shortcut": ""},
{"handle": 6, "widget_type": 3, "callback_kind": 1, "label": "Enable", "shortcut": ""},
{"handle": 7, "widget_type": 5, "callback_kind": 0, "label": "Save", "shortcut": "s"},
{"handle": 8, "widget_type": 8, "callback_kind": 0, "label": "", "shortcut": ""}
]
Supports query parameter filters:
GET /widgets?label=Save— filter by label substring (case-insensitive)GET /widgets?type=button— filter by widget type name or codeGET /widgets?label=Save&type=5— combine filters
Widget Types
| Code | Type | Description |
|---|---|---|
| 0 | Button | Push button with onClick |
| 1 | TextField | Text input field |
| 2 | Slider | Numeric slider |
| 3 | Toggle | On/off switch |
| 4 | Picker | Dropdown selector |
| 5 | Menu | Menu item |
| 6 | Shortcut | Keyboard shortcut |
| 7 | Table | Data table |
| 8 | ScrollView | Scrollable container |
Callback Kinds
| Code | Kind | Description |
|---|---|---|
| 0 | onClick | Triggered on click/tap |
| 1 | onChange | Triggered on value change |
| 2 | onSubmit | Triggered on submit (e.g., pressing Enter) |
| 3 | onHover | Triggered on mouse hover |
| 4 | onDoubleClick | Triggered on double-click |
| 5 | onFocus | Triggered on focus |
A single widget may appear multiple times in the list with different callback kinds. For example, a button with both onClick and onHover handlers produces two entries (same handle, different callback_kind).
Click a Widget
POST /click/:handle
→ {"ok":true}
Fires the widget’s onClick callback. Works with buttons, menu items, shortcuts, and table rows.
curl -X POST http://127.0.0.1:7676/click/3
Type into a TextField
POST /type/:handle
Content-Type: application/json
{"text": "hello world"}
Sets the text field’s content and fires its onChange callback with the new text as a NaN-boxed string.
curl -X POST http://127.0.0.1:7676/type/4 \
-H 'Content-Type: application/json' \
-d '{"text":"hello world"}'
Move a Slider
POST /slide/:handle
Content-Type: application/json
{"value": 0.75}
Sets the slider position and fires onChange with the numeric value.
curl -X POST http://127.0.0.1:7676/slide/5 \
-H 'Content-Type: application/json' \
-d '{"value":0.75}'
Toggle a Switch
POST /toggle/:handle
→ {"ok":true}
Fires the toggle’s onChange callback with a boolean value.
curl -X POST http://127.0.0.1:7676/toggle/6
Set State Directly
POST /state/:handle
Content-Type: application/json
{"value": 42}
Directly sets a State cell’s value, bypassing widget callbacks. This triggers any reactive bindings attached to the state (bound text labels, visibility, forEach loops, etc.).
curl -X POST http://127.0.0.1:7676/state/2 \
-H 'Content-Type: application/json' \
-d '{"value":42}'
Hover
POST /hover/:handle
→ {"ok":true}
Fires the widget’s onHover callback. Useful for testing hover-dependent UI (tooltips, color changes, etc.).
Double-Click
POST /doubleclick/:handle
→ {"ok":true}
Fires the widget’s onDoubleClick callback.
Trigger Keyboard Shortcut
POST /key
Content-Type: application/json
{"shortcut": "s"}
Finds a registered menu item whose shortcut matches and fires its callback. Shortcut strings are case-insensitive and match the key string passed to menuAddItem (e.g., "s" for Cmd+S, "S" for Cmd+Shift+S, "n" for Cmd+N).
curl -X POST http://127.0.0.1:7676/key \
-H 'Content-Type: application/json' \
-d '{"shortcut":"s"}'
Returns {"ok":true} if a matching shortcut was found, or 404 if no match.
Scroll a ScrollView
POST /scroll/:handle
Content-Type: application/json
{"x": 0, "y": 100}
Sets the scroll offset of a ScrollView widget. Both x and y are in points.
curl -X POST http://127.0.0.1:7676/scroll/8 \
-H 'Content-Type: application/json' \
-d '{"x":0,"y":200}'
Capture Screenshot
GET /screenshot
→ (binary PNG image, Content-Type: image/png)
Captures the app window as a PNG image. The response is raw binary data, not JSON.
curl http://127.0.0.1:7676/screenshot -o screenshot.png
Screenshot capture is synchronous from the caller’s perspective — the HTTP request blocks until the main thread completes the capture (timeout: 5 seconds).
Platform-specific capture methods:
| Platform | Method | Notes |
|---|---|---|
| macOS | CGWindowListCreateImage | Retina resolution, reads from window ID |
| iOS | UIGraphicsImageRenderer | Draws view hierarchy into image context |
| Android | JNI View.draw() on Canvas | Creates Bitmap, compresses to PNG |
| Linux (GTK4) | WidgetPaintable + GskRenderer | Renders to texture, saves as PNG bytes |
| Windows | PrintWindow + GetDIBits | Inline PNG encoder (stored zlib blocks) |
Chaos Mode
Chaos mode randomly interacts with widgets at a configurable interval — useful for stress testing, finding edge cases, and crash hunting.
Start
POST /chaos/start
Content-Type: application/json
{"interval_ms": 200}
# Fire random inputs every 200ms
curl -X POST http://127.0.0.1:7676/chaos/start \
-H 'Content-Type: application/json' \
-d '{"interval_ms":200}'
If interval_ms is omitted, a default interval is used. The chaos thread randomly selects a registered widget and fires an appropriate input based on widget type:
| Widget Type | Random Input |
|---|---|
| Button | Fires onClick (no args) |
| TextField | Random alphanumeric string, 5-20 characters |
| Slider | Random float between 0.0 and 1.0 |
| Toggle | Random true/false |
| Picker | Random index 0-9 |
| Menu | Fires onClick (no args) |
| Shortcut | Fires onClick (no args) |
| Table | Fires onClick (no args) |
Status
GET /chaos/status
→ {"running":true,"events_fired":247,"uptime_secs":12}
Returns whether chaos mode is active, how many random events have been fired, and uptime in seconds.
Stop
POST /chaos/stop
→ {"ok":true,"chaos":"stopped"}
Error Responses
All endpoints return errors as JSON with an appropriate HTTP status code:
{"error": "widget handle 99 not found"}
Common errors:
404— widget handle not found400— malformed JSON body or missing required field405— unsupported HTTP method
Platform Setup
macOS
No extra setup needed. The server binds to 0.0.0.0:7676 and is accessible on localhost.
perry app.ts -o app --enable-geisterhand
./app
curl http://127.0.0.1:7676/widgets
iOS Simulator
The iOS Simulator shares the host’s network stack — access the server directly on localhost:
perry app.ts -o app --target ios-simulator --enable-geisterhand
xcrun simctl install booted app.app
xcrun simctl launch booted com.perry.app
curl http://127.0.0.1:7676/widgets
iOS Device
For physical iOS devices, you need a network route to the device (same Wi-Fi network) or use iproxy from libimobiledevice:
perry app.ts -o app --target ios --enable-geisterhand
# Install and launch via Xcode/devicectl
# Then connect via the device's IP:
curl http://192.168.1.42:7676/widgets
Android (Emulator or Device)
Use adb forward to bridge the port. Ensure INTERNET permission is in your manifest (or add it to perry.toml):
[android]
permissions = ["INTERNET"]
perry app.ts -o app --target android --enable-geisterhand
# Package into APK and install
adb forward tcp:7676 tcp:7676
curl http://127.0.0.1:7676/widgets
Linux (GTK4)
Install GTK4 development libraries first:
# Ubuntu/Debian
sudo apt install libgtk-4-dev libcairo2-dev
perry app.ts -o app --target linux --enable-geisterhand
./app
curl http://127.0.0.1:7676/widgets
Windows
perry app.ts -o app --target windows --enable-geisterhand
./app.exe
curl http://127.0.0.1:7676/widgets
Test Automation
Geisterhand turns your Perry app into a testable HTTP service. Here are practical patterns for automated testing.
Shell Script Tests
A simple end-to-end test using bash:
#!/bin/bash
set -e
# Build with geisterhand
perry app.ts -o testapp --enable-geisterhand
# Start the app in background
./testapp &
APP_PID=$!
trap "kill $APP_PID 2>/dev/null" EXIT
# Wait for the app to be ready
for i in $(seq 1 30); do
curl -sf http://127.0.0.1:7676/health && break
sleep 0.1
done
# Get widgets
WIDGETS=$(curl -sf http://127.0.0.1:7676/widgets)
echo "Registered widgets: $WIDGETS"
# Find the button labeled "Submit"
SUBMIT_HANDLE=$(echo "$WIDGETS" | jq -r '.[] | select(.label == "Submit") | .handle')
# Click it
curl -sf -X POST "http://127.0.0.1:7676/click/$SUBMIT_HANDLE"
# Take a screenshot after interaction
curl -sf http://127.0.0.1:7676/screenshot -o after-click.png
echo "Test passed"
Python Test Example
import subprocess, time, requests, json
# Start the app
proc = subprocess.Popen(["./testapp"])
time.sleep(1) # Wait for startup
try:
# List widgets
widgets = requests.get("http://127.0.0.1:7676/widgets").json()
# Find widgets by label
buttons = [w for w in widgets if w["widget_type"] == 0]
fields = [w for w in widgets if w["widget_type"] == 1]
# Type into the first text field
if fields:
requests.post(
f"http://127.0.0.1:7676/type/{fields[0]['handle']}",
json={"text": "test@example.com"}
)
# Click the first button
if buttons:
requests.post(f"http://127.0.0.1:7676/click/{buttons[0]['handle']}")
# Capture screenshot for visual regression
png = requests.get("http://127.0.0.1:7676/screenshot").content
with open("test-result.png", "wb") as f:
f.write(png)
# Assert the app is still healthy
assert requests.get("http://127.0.0.1:7676/health").json()["status"] == "ok"
print("All tests passed")
finally:
proc.terminate()
Stress Testing with Chaos Mode
Run chaos mode against your app to find crashes, freezes, or unexpected state:
# Build and launch
perry app.ts -o app --enable-geisterhand
./app &
# Wait for startup
sleep 1
# Start aggressive chaos (every 50ms)
curl -X POST http://127.0.0.1:7676/chaos/start \
-H 'Content-Type: application/json' \
-d '{"interval_ms":50}'
# Let it run for 30 seconds
sleep 30
# Check stats
curl -sf http://127.0.0.1:7676/chaos/status
# {"running":true,"events_fired":600,"uptime_secs":30}
# Take a screenshot to see final state
curl http://127.0.0.1:7676/screenshot -o chaos-result.png
# Stop chaos
curl -X POST http://127.0.0.1:7676/chaos/stop
# Check the app is still alive
curl -sf http://127.0.0.1:7676/health
Visual Regression Testing
Capture screenshots at key interaction points and compare against baselines:
# Initial state
curl http://127.0.0.1:7676/screenshot -o baseline.png
# Interact
curl -X POST http://127.0.0.1:7676/click/3
curl -X POST http://127.0.0.1:7676/type/4 -d '{"text":"Hello"}'
# Capture after interaction
curl http://127.0.0.1:7676/screenshot -o current.png
# Compare (using ImageMagick)
compare baseline.png current.png diff.png
CI Pipeline Integration
# GitHub Actions example
jobs:
ui-test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build with geisterhand
run: perry app.ts -o testapp --enable-geisterhand
- name: Run UI tests
run: |
./testapp &
sleep 2
# Run your test script
./tests/ui-test.sh
kill %1
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: "*.png"
Example App
A complete Perry UI app demonstrating all widget types that geisterhand can interact with:
import {
App, VStack, HStack, Text, Button, TextField,
Slider, Toggle, Picker, State
} from "perry/ui";
// State for reactive UI
const counterState = State(0);
const textState = State("");
// Labels
const title = Text("Geisterhand Demo");
const counterLabel = Text("Count: 0");
// Bind counter state to label
counterState.onChange((val: number) => {
counterLabel.setText("Count: " + val);
});
// Button — handle 3 (approx), widget_type=0
const incrementBtn = Button("Increment", () => {
counterState.set(counterState.value + 1);
});
const resetBtn = Button("Reset", () => {
counterState.set(0);
});
// TextField — widget_type=1
const nameField = TextField("Enter your name", (text: string) => {
textState.set(text);
console.log("Name:", text);
});
// Slider — widget_type=2
const volumeSlider = Slider(0, 100, 50, (value: number) => {
console.log("Volume:", value);
});
// Toggle — widget_type=3
const darkModeToggle = Toggle("Dark Mode", false, (on: boolean) => {
console.log("Dark mode:", on);
});
// Layout
const buttonRow = HStack(8, [incrementBtn, resetBtn]);
const stack = VStack(12, [
title, counterLabel, buttonRow,
nameField, volumeSlider, darkModeToggle
]);
App({
title: "Geisterhand Demo",
width: 400,
height: 400,
body: stack
});
After compiling with --enable-geisterhand and running:
# See all interactive widgets
curl -s http://127.0.0.1:7676/widgets | jq .
# [
# {"handle":3,"widget_type":0,"callback_kind":0,"label":"Increment"},
# {"handle":4,"widget_type":0,"callback_kind":0,"label":"Reset"},
# {"handle":5,"widget_type":1,"callback_kind":1,"label":"Enter your name"},
# {"handle":6,"widget_type":2,"callback_kind":1,"label":""},
# {"handle":7,"widget_type":3,"callback_kind":1,"label":"Dark Mode"}
# ]
# Click Increment 3 times
for i in 1 2 3; do curl -sX POST http://127.0.0.1:7676/click/3; done
# Counter label now shows "Count: 3"
# Type a name
curl -sX POST http://127.0.0.1:7676/type/5 -d '{"text":"Perry"}'
# Set slider to 80%
curl -sX POST http://127.0.0.1:7676/slide/6 -d '{"value":0.8}'
# Toggle dark mode on
curl -sX POST http://127.0.0.1:7676/toggle/7
# Screenshot
curl -s http://127.0.0.1:7676/screenshot -o demo.png
Architecture
Geisterhand operates as three cooperating components connected by thread-safe queues:
┌──────────────────────────┐
│ HTTP Server │
│ (background thread) │
│ tiny-http on :7676 │
│ │
│ GET /widgets │
│ POST /click/:h │
│ POST /type/:h │
│ ... │
└────────┬─────────────────┘
│
queue actions via
Mutex<Vec<PendingAction>>
│
▼
┌────────────────────────────────────────────────┐
│ Main Thread │
│ │
│ perry_geisterhand_pump() ← called every 8ms │
│ by platform timer (NSTimer / glib / WM_TIMER) │
│ │
│ Drains PendingAction queue: │
│ • InvokeCallback → js_closure_call0/1 │
│ • SetState → perry_ui_state_set │
│ • CaptureScreenshot → perry_ui_screenshot_* │
└────────────────────────────────────────────────┘
│
widget callbacks registered
at creation time via
perry_geisterhand_register()
│
▼
┌────────────────────────────────────────────────┐
│ Global Widget Registry │
│ Mutex<Vec<RegisteredWidget>> │
│ │
│ { handle, widget_type, callback_kind, │
│ closure_f64, label } │
└────────────────────────────────────────────────┘
Lifecycle
-
Startup: When
--enable-geisterhandis used, the compiled binary callsperry_geisterhand_start(port)during initialization. This spawns a background thread running atiny-httpserver. -
Widget Registration: As UI widgets are created (Button, TextField, Slider, etc.), each one calls
perry_geisterhand_register(handle, widget_type, callback_kind, closure_f64, label)to register its callback in the global registry. This is gated behind#[cfg(feature = "geisterhand")]so normal builds have zero overhead. -
HTTP Requests: When a request arrives (e.g.,
POST /click/3), the server looks up handle 3 in the registry, finds the associated closure, and pushes aPendingAction::InvokeCallbackonto the pending actions queue. -
Main-Thread Dispatch: The platform’s timer (NSTimer on macOS, glib timeout on GTK4, WM_TIMER on Windows, etc.) calls
perry_geisterhand_pump()every ~8ms. This drains the pending actions queue and executes callbacks on the main thread, which is required for UI safety. -
Screenshot Capture: Screenshots use
Condvarsynchronization — the HTTP thread queues aCaptureScreenshotaction, then blocks waiting on a condition variable. The main thread’s pump executes the platform-specific capture, stores the PNG data, and signals the condvar. Timeout: 5 seconds.
Thread Safety
- Widget Registry: Protected by
Mutex. Read by the HTTP server (to list widgets and look up handles), written by the main thread (during widget creation). - Pending Actions Queue: Protected by
Mutex. Written by HTTP server thread, drained by main thread inpump(). - Screenshot Result: Protected by
Mutex+Condvar. HTTP thread waits, main thread signals. - Chaos Mode State: Uses
AtomicBool(running flag) andAtomicU64(event counter) for lock-free status checks.
NaN-Boxing Bridge
When geisterhand needs to pass values to widget callbacks, it must create properly NaN-boxed values:
- Strings (for TextField): Calls
js_string_from_bytes(ptr, len)to allocate a runtime string, thenjs_nanbox_string(ptr)to wrap it with STRING_TAG (0x7FFF). - Numbers (for Slider): Passes the raw
f64value directly (numbers are their own NaN-boxed representation). - Booleans (for Toggle/chaos): Uses
TAG_TRUE(0x7FFC000000000004) orTAG_FALSE(0x7FFC000000000003).
Build Details
Auto-Build
When you pass --enable-geisterhand (or --geisterhand-port), Perry automatically builds the required libraries on first use if they’re not already cached:
cargo build --release \
-p perry-runtime --features perry-runtime/geisterhand \
-p perry-ui-{platform} --features perry-ui-{platform}/geisterhand \
-p perry-ui-geisterhand
Platform crate selection is automatic based on --target:
| Target | UI Crate |
|---|---|
| (default/macOS) | perry-ui-macos |
ios / ios-simulator | perry-ui-ios |
android | perry-ui-android |
linux | perry-ui-gtk4 |
windows | perry-ui-windows |
Separate Target Directory
Geisterhand libraries are built into target/geisterhand/ (via CARGO_TARGET_DIR) to avoid interfering with normal builds. This means your first geisterhand build takes a moment, but subsequent builds reuse the cached libraries.
Feature Flags
All geisterhand code is behind #[cfg(feature = "geisterhand")] feature gates:
perry-runtime/geisterhand: Compiles thegeisterhand_registrymodule — widget registry, action queue, pump function, screenshot coordination.perry-ui-{platform}/geisterhand: Addsperry_geisterhand_register()calls to widget constructors andperry_geisterhand_pump()to the platform timer.
When the feature is not enabled, no geisterhand code is compiled — zero binary size overhead and zero runtime cost.
Linking
The compiled binary links three additional static libraries:
libperry_runtime.a(geisterhand-featured build, replaces the normal runtime)libperry_ui_{platform}.a(geisterhand-featured build, replaces the normal UI lib)libperry_ui_geisterhand.a(HTTP server + chaos mode)
Manual Build
If auto-build fails or you want to cross-compile manually:
# Build geisterhand libs for macOS
CARGO_TARGET_DIR=target/geisterhand cargo build --release \
-p perry-runtime --features perry-runtime/geisterhand \
-p perry-ui-macos --features perry-ui-macos/geisterhand \
-p perry-ui-geisterhand
# Build for iOS (cross-compile)
CARGO_TARGET_DIR=target/geisterhand cargo build --release \
--target aarch64-apple-ios \
-p perry-runtime --features perry-runtime/geisterhand \
-p perry-ui-ios --features perry-ui-ios/geisterhand \
-p perry-ui-geisterhand
Security
Geisterhand binds to 0.0.0.0 on the configured port (default 7676). This means it is accessible from the local network — any device on the same network can interact with your app, capture screenshots, or trigger chaos mode.
Do not ship geisterhand-enabled binaries to production or to end users.
Geisterhand is a development and testing tool only. The feature-gate system ensures it cannot accidentally be included in normal builds — you must explicitly pass --enable-geisterhand or --geisterhand-port.
Troubleshooting
“Connection refused” on port 7676
- Ensure you compiled with
--enable-geisterhandor--geisterhand-port - Check that the app has fully started (look for
[geisterhand] listening on...in stderr) - Verify the port isn’t in use by another process:
lsof -i :7676
Widget handles not found
- Handles are assigned at widget creation time. If you query
/widgetsbefore the UI is fully constructed, some widgets may not be registered yet. - Wait for
GET /healthto return{"status":"ok"}before interacting.
Screenshot returns empty data
- Screenshot capture has a 5-second timeout. If the main thread is blocked (e.g., by a long-running synchronous operation), the screenshot will time out and return empty data.
- On macOS, ensure the app has a visible window (minimized windows may not capture correctly).
Auto-build fails
- Ensure you have a working Rust toolchain (
rustup show) - For cross-compilation targets, install the appropriate target:
rustup target add aarch64-apple-ios - Check that the Perry source tree is accessible (auto-build searches upward from the
perryexecutable for the workspace root)
Chaos mode crashes the app
That’s the point — chaos mode found a bug. Check the app’s stderr output for panic messages or stack traces. Common causes:
- Callback handlers that assume valid state but receive unexpected values
- Missing null checks on state values
- Race conditions in state updates
CLI Commands
Perry provides 11 commands for compiling, checking, running, publishing, and managing your projects.
See also: perry.toml Reference for project configuration.
compile
Compile TypeScript to a native executable.
perry compile main.ts -o app
# Or shorthand (auto-detects compile):
perry main.ts -o app
| Flag | Description |
|---|---|
-o, --output <PATH> | Output file path |
--target <TARGET> | Platform target (see Compiler Flags) |
--output-type <TYPE> | executable (default) or dylib (plugin) |
--print-hir | Print HIR intermediate representation |
--no-link | Produce object file only, skip linking |
--keep-intermediates | Keep .o and .asm files |
--enable-js-runtime | Enable V8 JavaScript runtime fallback |
--type-check | Enable type checking via tsgo |
--minify | Minify and obfuscate output (auto-enabled for --target web) |
--app-bundle-id <ID> | Bundle ID (required for widget targets) |
--bundle-extensions <DIR> | Bundle TypeScript extensions from directory |
# Basic compilation
perry compile app.ts -o app
# Cross-compile for iOS Simulator
perry compile app.ts -o app --target ios-simulator
# Build a plugin
perry compile plugin.ts --output-type dylib -o plugin.dylib
# Debug: view intermediate representation
perry compile app.ts --print-hir
# Build an iOS widget
perry compile widget.ts --target ios-widget --app-bundle-id com.myapp.widget
run
Compile and launch your app in one step.
perry run # Auto-detect entry file
perry run ios # Run on iOS device/simulator
perry run visionos # Run on Apple Vision Pro simulator/device
perry run android # Run on Android device
perry run -- --port 3000 # Forward args to your program
| Argument / Flag | Description |
|---|---|
ios | Target iOS (device or simulator) |
visionos | Target visionOS (device or simulator) |
macos | Target macOS (default on macOS host) |
web | Target web (opens in browser) |
android | Target Android device |
--simulator <UDID> | Specify iOS simulator by UDID |
--device <UDID> | Specify iOS physical device by UDID |
--local | Force local compilation (no remote fallback) |
--remote | Force remote build via Perry Hub |
--enable-js-runtime | Enable V8 JavaScript runtime |
--type-check | Enable type checking via tsgo |
-- | Separator for program arguments |
Entry file detection (checked in order):
perry.toml→[project] entryfieldsrc/main.tsmain.ts
Device detection: When targeting iOS, Perry auto-discovers available simulators (via simctl) and physical devices (via devicectl). For Android, it uses adb. When multiple targets are found, an interactive prompt lets you choose.
Remote build fallback: If cross-compilation toolchains aren’t installed locally (e.g., Apple mobile targets on a machine without Xcode), perry run ios and perry run visionos can fall back to Perry Hub’s build server when the backend supports the target. Use --local or --remote to force either path.
# Run a CLI program
perry run
# Run on a specific simulator
perry run ios --simulator 12345-ABCDE
# Force remote build
perry run ios --remote
# Run web target
perry run web
dev
Watch your TypeScript source tree and auto-recompile + relaunch on every save.
perry dev src/main.ts # watch + rebuild + relaunch on save
perry dev src/server.ts -- --port 3000 # forward args to the child
perry dev src/app.ts --watch shared/ # watch an extra directory
perry dev src/app.ts -o build/dev-app # override output path
| Flag | Description |
|---|---|
-o, --output <PATH> | Output binary path (default: .perry-dev/<entry-stem>) |
--watch <DIR> | Extra directories to watch (comma-separated or repeated) |
-- | Separator — everything after is forwarded to the compiled binary |
How it works:
- Resolves the entry, computes the project root (walks up until it finds a
package.jsonorperry.toml; falls back to the entry’s parent directory). - Does an initial
perry compile, then spawns the resulting binary with stdio inherited. - Watches the project root (plus any
--watchdirs) recursively using thenotifycrate. A 300 ms debounce window collapses editor “save storms” into one rebuild. - On each relevant change: kill the running child, recompile, relaunch. A failed build leaves the old child dead and waits for the next change; no crash loop.
What counts as a “relevant” change:
- Trigger extensions:
.ts,.tsx,.mts,.cts,.json,.toml - Ignored directories (not watched, never retrigger):
node_modules,target,.git,dist,build,.perry-dev,.perry-cache
Benchmarks (trivial single-file program, macOS):
| Phase | Time |
|---|---|
| Initial build (cold — runtime + stdlib rebuilt by auto-optimize) | ~15 s |
| Post-edit rebuild (hot libs cached on disk) | ~330 ms |
The speedup on hot rebuilds comes from Perry’s existing auto-optimize library cache. Multi-module projects will still recompile every changed module on each save — see the V2 note below for planned incremental work.
Not yet in scope (V2+):
- In-memory AST cache (reuse SWC parses across rebuilds).
- Per-module
.ocache on disk (only re-codegen the changed module). - State preservation across rebuilds / HMR — “fast restart” is the honest target.
check
Validate TypeScript for Perry compatibility without compiling.
perry check src/
| Flag | Description |
|---|---|
--check-deps | Check node_modules for compatibility |
--deep-deps | Scan all transitive dependencies |
--all | Show all issues including hints |
--strict | Treat warnings as errors |
--fix | Automatically apply fixes |
--fix-dry-run | Preview fixes without modifying files |
--fix-unsafe | Include medium-confidence fixes |
# Check a single file
perry check src/index.ts
# Check with dependency analysis
perry check . --check-deps
# Auto-fix issues
perry check . --fix
# Preview fixes without applying
perry check . --fix-dry-run
init
Create a new Perry project.
perry init my-project
cd my-project
| Flag | Description |
|---|---|
--name <NAME> | Project name (defaults to directory name) |
Creates perry.toml, src/main.ts, and .gitignore.
doctor
Check your Perry installation and environment.
perry doctor
| Flag | Description |
|---|---|
--quiet | Only report failures |
Checks:
- Perry version
- System linker availability (cc/MSVC)
- Runtime library
- Project configuration
- Available updates
explain
Get detailed explanations for error codes.
perry explain U001
Error code families:
- P — Parse errors
- T — Type errors
- U — Unsupported features
- D — Dependency issues
Each explanation includes the error description, example code, and suggested fix.
publish
Build, sign, and distribute your app.
perry publish macos
perry publish ios
perry publish visionos
perry publish android
| Argument / Flag | Description |
|---|---|
macos | Build for macOS (App Store/notarization) |
ios | Build for iOS (App Store/TestFlight) |
visionos | Build for visionOS |
android | Build for Android (Google Play) |
linux | Build for Linux (AppImage/deb/rpm) |
--server <URL> | Build server (default: https://hub.perryts.com) |
--license-key <KEY> | Perry Hub license key |
--project <PATH> | Project directory |
-o, --output <PATH> | Artifact output directory (default: dist) |
--no-download | Skip artifact download |
Apple-specific flags:
| Flag | Description |
|---|---|
--apple-team-id <ID> | Developer Team ID |
--apple-identity <NAME> | Signing identity |
--apple-p8-key <PATH> | App Store Connect .p8 key |
--apple-key-id <ID> | App Store Connect API Key ID |
--apple-issuer-id <ID> | App Store Connect Issuer ID |
--certificate <PATH> | .p12 certificate bundle |
--provisioning-profile <PATH> | .mobileprovision file (iOS) |
Android-specific flags:
| Flag | Description |
|---|---|
--android-keystore <PATH> | .jks/.keystore file |
--android-keystore-password <PASS> | Keystore password |
--android-key-alias <ALIAS> | Key alias |
--android-key-password <PASS> | Key password |
--google-play-key <PATH> | Google Play service account JSON |
On first use, publish auto-registers a free license key.
setup
Interactive credential wizard for app distribution, plus toolchain setup for Windows.
perry setup # Show platform menu
perry setup macos # macOS setup (signing credentials)
perry setup ios # iOS setup (signing credentials)
perry setup visionos # visionOS setup (signing credentials)
perry setup android # Android setup (signing credentials)
perry setup windows # Windows toolchain (downloads MS CRT + Windows SDK via xwin)
perry setup windows downloads the Microsoft CRT + Windows SDK libraries (~1.5 GB) so Perry can link without Visual Studio Build Tools. Requires LLVM (winget install LLVM.LLVM) and prompts to accept the Microsoft redistributable license — pass --accept-license to skip the prompt for CI. Output lands at %LOCALAPPDATA%\perry\windows-sdk. See the Windows platform guide for the full toolchain comparison.
Credential wizards store their output in ~/.perry/config.toml.
update
Check for and install Perry updates.
perry update # Update to latest
perry update --check-only # Check without installing
perry update --force # Ignore 24h cache
Update sources (checked in order):
- Custom server (env/config)
- Perry Hub
- GitHub API
Opt out of automatic update checks with PERRY_NO_UPDATE_CHECK=1 or CI=true.
i18n
Internationalization tools for managing locale files and extracting localizable strings.
perry i18n extract
Scan source files and generate/update locale JSON scaffolds:
perry i18n extract src/main.ts
Detects string literals in UI component calls (Button, Text, Label, etc.) and t() calls. Creates locales/*.json files based on the [i18n] config in perry.toml.
See the i18n documentation for full details.
Next Steps
- Compiler Flags — Complete flag reference
- Getting Started — Installation
Compiler Flags
Complete reference for all Perry CLI flags.
Global Flags
Available on all commands:
| Flag | Description |
|---|---|
--format text|json | Output format (default: text) |
-v, --verbose | Increase verbosity (repeatable: -v, -vv, -vvv) |
-q, --quiet | Suppress non-error output |
--no-color | Disable ANSI color codes |
Compilation Targets
Use --target to cross-compile:
| Target | Platform | Notes |
|---|---|---|
| (none) | Current platform | Default behavior |
ios-simulator | iOS Simulator | ARM64 simulator binary |
ios | iOS Device | ARM64 device binary |
visionos-simulator | visionOS Simulator | Apple Vision Pro simulator build |
visionos | visionOS Device | Apple Vision Pro device build |
android | Android | ARM64/ARMv7 |
ios-widget | iOS Widget | WidgetKit extension (requires --app-bundle-id) |
ios-widget-simulator | iOS Widget (Sim) | Widget for simulator |
watchos-widget | watchOS Complication | WidgetKit extension for Apple Watch |
watchos-widget-simulator | watchOS Widget (Sim) | Widget for watchOS simulator |
android-widget | Android Widget | Android App Widget (AppWidgetProvider) |
wearos-tile | Wear OS Tile | Wear OS Tile (TileService) |
wasm | WebAssembly | Self-contained HTML with WASM or raw .wasm binary |
web | Web | Outputs HTML file with JS |
windows | Windows | Win32 executable |
linux | Linux | GTK4 executable |
Output Types
Use --output-type to change what’s produced:
| Type | Description |
|---|---|
executable | Standalone binary (default) |
dylib | Shared library (.dylib/.so) for plugins |
Debug Flags
| Flag | Description |
|---|---|
--print-hir | Print HIR (intermediate representation) to stdout |
--no-link | Produce .o object file only, skip linking |
--keep-intermediates | Keep .o and .asm intermediate files |
Output Optimization
| Flag | Description |
|---|---|
--minify | Minify and obfuscate output (auto-enabled for --target web) |
Minification strips comments, collapses whitespace, and mangles local variable/parameter/non-exported function names for smaller output.
Testing Flags
| Flag | Description |
|---|---|
--enable-geisterhand | Embed the Geisterhand HTTP server for programmatic UI testing (default port 7676) |
--geisterhand-port <PORT> | Set a custom port for the Geisterhand server (implies --enable-geisterhand) |
Runtime Flags
| Flag | Description |
|---|---|
--enable-js-runtime | Enable V8 JavaScript runtime for unsupported npm packages |
--type-check | Enable type checking via tsgo IPC |
Environment Variables
| Variable | Description |
|---|---|
PERRY_LICENSE_KEY | Perry Hub license key for perry publish |
PERRY_APPLE_CERTIFICATE_PASSWORD | Password for .p12 certificate |
PERRY_NO_UPDATE_CHECK=1 | Disable automatic update checks |
PERRY_UPDATE_SERVER | Custom update server URL |
CI=true | Auto-skip update checks (set by most CI systems) |
RUST_LOG | Debug logging level (debug, info, trace) |
Configuration Files
perry.toml (project)
[project]
name = "my-app"
entry = "src/main.ts"
version = "1.0.0"
[build]
out_dir = "build"
[app]
name = "My App"
description = "A Perry application"
[macos]
bundle_id = "com.example.myapp"
category = "public.app-category.developer-tools"
minimum_os = "13.0"
distribute = "notarize" # "appstore", "notarize", or "both"
[ios]
bundle_id = "com.example.myapp"
deployment_target = "16.0"
device_family = ["iphone", "ipad"]
[android]
package_name = "com.example.myapp"
min_sdk = 26
target_sdk = 34
[linux]
format = "appimage" # "appimage", "deb", "rpm"
category = "Development"
~/.perry/config.toml (global)
[apple]
team_id = "XXXXXXXXXX"
signing_identity = "Developer ID Application: Your Name"
[android]
keystore_path = "/path/to/keystore.jks"
key_alias = "my-key"
Examples
# Simple CLI program
perry main.ts -o app
# iOS app for simulator
perry app.ts -o app --target ios-simulator
# visionOS app for simulator
perry app.ts -o app --target visionos-simulator
# Web app (WASM with DOM bridge — alias: --target wasm)
perry app.ts -o app --target web
# Plugin shared library
perry plugin.ts --output-type dylib -o plugin.dylib
# iOS widget with bundle ID
perry widget.ts --target ios-widget --app-bundle-id com.example.app
# Debug compilation
perry app.ts --print-hir 2>&1 | less
# Verbose compilation
perry compile app.ts -o app -vvv
# Type-checked compilation
perry app.ts -o app --type-check
# Raw WASM binary (no HTML wrapper)
perry app.ts -o app.wasm --target wasm
# Minified web output (compresses embedded JS bridge)
perry app.ts -o app --target web --minify
Next Steps
- Commands — All CLI commands
- Platform Overview — Platform targets
perry.toml Reference
perry.toml is the project-level configuration file for Perry. It controls project metadata, build settings, platform-specific options, code signing, distribution, auditing, and verification.
Created automatically by perry init, it lives at the root of your project alongside package.json.
Minimal Example
[project]
name = "my-app"
entry = "src/main.ts"
[build]
out_dir = "dist"
Full Example
[project]
name = "my-app"
version = "1.2.0"
build_number = 42
bundle_id = "com.example.myapp"
description = "A cross-platform Perry application"
entry = "src/main.ts"
[project.icons]
source = "assets/icon.png"
[build]
out_dir = "dist"
[macos]
bundle_id = "com.example.myapp.macos"
category = "public.app-category.developer-tools"
minimum_os = "13.0"
entitlements = ["com.apple.security.network.client"]
distribute = "both"
signing_identity = "Developer ID Application: My Company (TEAMID)"
certificate = "certs/mac-appstore.p12"
notarize_certificate = "certs/mac-devid.p12"
notarize_signing_identity = "Developer ID Application: My Company (TEAMID)"
installer_certificate = "certs/mac-installer.p12"
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "certs/AuthKey.p8"
encryption_exempt = true
[ios]
bundle_id = "com.example.myapp.ios"
deployment_target = "16.0"
device_family = ["iphone", "ipad"]
orientations = ["portrait", "landscape-left", "landscape-right"]
capabilities = ["push-notification"]
distribute = "appstore"
entry = "src/main-ios.ts"
provisioning_profile = "certs/MyApp.mobileprovision"
certificate = "certs/ios-distribution.p12"
signing_identity = "iPhone Distribution: My Company (TEAMID)"
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "certs/AuthKey.p8"
encryption_exempt = true
[android]
package_name = "com.example.myapp"
min_sdk = "26"
target_sdk = "34"
permissions = ["INTERNET", "CAMERA"]
distribute = "playstore"
keystore = "certs/release.keystore"
key_alias = "my-key"
google_play_key = "certs/play-service-account.json"
entry = "src/main-android.ts"
[linux]
format = "appimage"
category = "Development"
description = "A cross-platform Perry application"
[i18n]
locales = ["en", "de", "fr"]
default_locale = "en"
[i18n.currencies]
en = "USD"
de = "EUR"
fr = "EUR"
[publish]
server = "https://hub.perryts.com"
[audit]
fail_on = "B"
severity = "high"
ignore = ["RULE-001", "RULE-002"]
[verify]
url = "https://verify.perryts.com"
Sections
[project]
Core project metadata. This is the primary section for identifying your application.
| Field | Type | Default | Description |
|---|---|---|---|
name | string | Directory name | Project name, used for binary output name and default bundle ID |
version | string | "1.0.0" | Semantic version string (e.g., "1.2.3") |
build_number | integer | 1 | Numeric build number; auto-incremented on perry publish for iOS, Android, and macOS App Store builds |
description | string | — | Human-readable project description |
entry | string | — | TypeScript entry file (e.g., "src/main.ts"). Used by perry run and perry publish when no input file is specified |
bundle_id | string | com.perry.<name> | Default bundle identifier, used as fallback when platform-specific sections don’t define one |
[project.icons]
| Field | Type | Default | Description |
|---|---|---|---|
source | string | — | Path to a source icon image (PNG or JPG). Perry auto-resizes this to all required sizes for each platform |
[app]
Alternative to [project] with identical fields. Useful for organizational clarity — [app] takes precedence over [project] when both are present:
# These two are equivalent:
[project]
name = "my-app"
# or:
[app]
name = "my-app"
When both exist, resolution order is: [app] field -> [project] field -> default.
[app] supports the same fields as [project]: name, version, build_number, bundle_id, description, entry, and icons.
[build]
Build output settings.
| Field | Type | Default | Description |
|---|---|---|---|
out_dir | string | "dist" | Directory for build artifacts |
[macos]
macOS-specific configuration for perry publish macos and perry compile --target macos.
App Metadata
| Field | Type | Default | Description |
|---|---|---|---|
bundle_id | string | Falls back to [app]/[project] | macOS-specific bundle identifier (e.g., "com.example.myapp") |
category | string | — | Mac App Store category. Uses Apple’s UTI format (see valid values below) |
minimum_os | string | — | Minimum macOS version required (e.g., "13.0") |
entitlements | string[] | — | macOS entitlements to include in the code signature (e.g., ["com.apple.security.network.client"]) |
encryption_exempt | bool | false | If true, adds ITSAppUsesNonExemptEncryption = false to Info.plist, skipping the export compliance prompt in App Store Connect |
Distribution
| Field | Type | Default | Description |
|---|---|---|---|
distribute | string | — | Distribution method: "appstore", "notarize", or "both" (see Distribution Modes) |
Code Signing
| Field | Type | Default | Description |
|---|---|---|---|
signing_identity | string | Auto-detected from Keychain | Code signing identity name (e.g., "3rd Party Mac Developer Application: Company (TEAMID)") |
certificate | string | Auto-exported from Keychain | Path to .p12 certificate file for App Store distribution |
notarize_certificate | string | — | Separate .p12 certificate for notarization (only used with distribute = "both") |
notarize_signing_identity | string | — | Signing identity for notarization (only used with distribute = "both") |
installer_certificate | string | — | .p12 certificate for Mac Installer Distribution (.pkg signing) |
App Store Connect
| Field | Type | Default | Description |
|---|---|---|---|
team_id | string | From ~/.perry/config.toml | Apple Developer Team ID |
key_id | string | From ~/.perry/config.toml | App Store Connect API key ID |
issuer_id | string | From ~/.perry/config.toml | App Store Connect issuer ID |
p8_key_path | string | From ~/.perry/config.toml | Path to App Store Connect .p8 API key file |
macOS Distribution Modes
The distribute field controls how your macOS app is signed and distributed:
-
"appstore"— Signs with an App Store distribution certificate and uploads to App Store Connect. Requiresteam_id,key_id,issuer_id, andp8_key_path. -
"notarize"— Signs with a Developer ID certificate and notarizes with Apple. For direct distribution outside the App Store. -
"both"— Produces two signed builds: one for the App Store and one notarized for direct distribution. Requires two separate certificates:certificate+signing_identityfor the App Store buildnotarize_certificate+notarize_signing_identityfor the notarized build- Optionally
installer_certificatefor.pkgsigning
macOS App Store Categories
Common values for the category field (Apple UTI format):
| Category | Value |
|---|---|
| Business | public.app-category.business |
| Developer Tools | public.app-category.developer-tools |
| Education | public.app-category.education |
| Entertainment | public.app-category.entertainment |
| Finance | public.app-category.finance |
| Games | public.app-category.games |
| Graphics & Design | public.app-category.graphics-design |
| Health & Fitness | public.app-category.healthcare-fitness |
| Lifestyle | public.app-category.lifestyle |
| Music | public.app-category.music |
| News | public.app-category.news |
| Photography | public.app-category.photography |
| Productivity | public.app-category.productivity |
| Social Networking | public.app-category.social-networking |
| Utilities | public.app-category.utilities |
[ios]
iOS-specific configuration for perry publish ios, perry run ios, and perry compile --target ios/--target ios-simulator.
App Metadata
| Field | Type | Default | Description |
|---|---|---|---|
bundle_id | string | Falls back to [app]/[project] | iOS-specific bundle identifier |
deployment_target | string | "17.0" | Minimum iOS version required (e.g., "16.0") |
minimum_version | string | — | Alias for deployment_target |
device_family | string[] | ["iphone", "ipad"] | Supported device families |
orientations | string[] | ["portrait"] | Supported interface orientations |
capabilities | string[] | — | App capabilities (e.g., ["push-notification"]) |
entry | string | Falls back to [project]/[app] | iOS-specific entry file (useful when iOS needs a different entry point) |
encryption_exempt | bool | false | If true, adds ITSAppUsesNonExemptEncryption = false to Info.plist |
Distribution
| Field | Type | Default | Description |
|---|---|---|---|
distribute | string | — | Distribution method: "appstore", "testflight", or "development" |
Code Signing
| Field | Type | Default | Description |
|---|---|---|---|
signing_identity | string | Auto-detected from Keychain | Code signing identity (e.g., "iPhone Distribution: Company (TEAMID)") |
certificate | string | Auto-exported from Keychain | Path to .p12 distribution certificate |
provisioning_profile | string | — | Path to .mobileprovision file. Stored as {bundle_id}.mobileprovision in ~/.perry/ by perry setup ios |
App Store Connect
| Field | Type | Default | Description |
|---|---|---|---|
team_id | string | From ~/.perry/config.toml | Apple Developer Team ID |
key_id | string | From ~/.perry/config.toml | App Store Connect API key ID |
issuer_id | string | From ~/.perry/config.toml | App Store Connect issuer ID |
p8_key_path | string | From ~/.perry/config.toml | Path to .p8 API key file |
Device Family Values
| Value | Description |
|---|---|
"iphone" | iPhone devices |
"ipad" | iPad devices |
Orientation Values
| Value | Description |
|---|---|
"portrait" | Device upright |
"portrait-upside-down" | Device upside down |
"landscape-left" | Device rotated left |
"landscape-right" | Device rotated right |
[visionos]
visionOS-specific configuration for perry publish visionos, perry run visionos, and perry compile --target visionos/--target visionos-simulator.
App Metadata
| Field | Type | Default | Description |
|---|---|---|---|
bundle_id | string | Falls back to [app]/[project]/[ios] | visionOS-specific bundle identifier |
deployment_target | string | "1.0" | Minimum visionOS version required |
minimum_version | string | — | Alias for deployment_target |
entry | string | Falls back to [project]/[app] | visionOS-specific entry file |
encryption_exempt | bool | false | If true, adds ITSAppUsesNonExemptEncryption = false to Info.plist |
info_plist | table | — | Custom key-value pairs merged into the generated Info.plist |
Distribution / Signing
| Field | Type | Default | Description |
|---|---|---|---|
distribute | string | — | Distribution method for visionOS builds |
signing_identity | string | Auto-detected from Keychain | Code signing identity |
certificate | string | Auto-exported from Keychain | Path to .p12 distribution certificate |
provisioning_profile | string | — | Path to .mobileprovision file |
team_id | string | From ~/.perry/config.toml | Apple Developer Team ID |
key_id | string | From ~/.perry/config.toml | App Store Connect API key ID |
issuer_id | string | From ~/.perry/config.toml | App Store Connect issuer ID |
p8_key_path | string | From ~/.perry/config.toml | Path to .p8 API key file |
[android]
Android-specific configuration for perry publish android, perry run android, and perry compile --target android.
| Field | Type | Default | Description |
|---|---|---|---|
package_name | string | Falls back to bundle_id chain | Java package name (e.g., "com.example.myapp") |
min_sdk | string | — | Minimum Android SDK version (e.g., "26" for Android 8.0) |
target_sdk | string | — | Target Android SDK version (e.g., "34" for Android 14) |
permissions | string[] | — | Android permissions (e.g., ["INTERNET", "CAMERA", "ACCESS_FINE_LOCATION"]) |
distribute | string | — | Distribution method: "playstore" |
keystore | string | — | Path to .jks or .keystore signing keystore |
key_alias | string | — | Alias of the signing key within the keystore |
google_play_key | string | — | Path to Google Play service account JSON file for automated uploads |
entry | string | Falls back to [project]/[app] | Android-specific entry file |
[linux]
Linux-specific configuration for perry publish linux.
| Field | Type | Default | Description |
|---|---|---|---|
format | string | — | Package format: "appimage", "deb", or "rpm" |
category | string | — | Desktop application category (e.g., "Development", "Utility", "Game") |
description | string | Falls back to [project]/[app] | Application description for package metadata |
[i18n]
Internationalization configuration. See the i18n documentation for full details.
| Field | Type | Default | Description |
|---|---|---|---|
locales | string[] | — | Supported locale codes (e.g., ["en", "de", "fr"]). Locale files must exist in /locales |
default_locale | string | "en" | Fallback locale. Used when a key is missing in another locale |
dynamic | boolean | false | false: locale set at launch, strings inlined. true: locale switchable at runtime |
[i18n.currencies]
Maps locale codes to default ISO 4217 currency codes. Used by the Currency() format wrapper.
| Key | Type | Description |
|---|---|---|
{locale} | string | Currency code for the locale (e.g., en = "USD", de = "EUR") |
[publish]
Publishing configuration.
| Field | Type | Default | Description |
|---|---|---|---|
server | string | https://hub.perryts.com | Custom Perry Hub build server URL. Useful for self-hosted or enterprise deployments |
[audit]
Security audit configuration for perry audit and pre-publish audits.
| Field | Type | Default | Description |
|---|---|---|---|
fail_on | string | "C" | Minimum acceptable audit grade. Build fails if the actual grade is below this threshold. Values: "A", "A-", "B", "C", "D", "F" |
severity | string | "all" | Filter findings by severity: "all", "critical", "high", "medium", "low" |
ignore | string[] | — | List of audit rule IDs to suppress (e.g., ["RULE-001", "RULE-042"]) |
Audit Grade Scale
Grades are ranked from highest to lowest:
| Grade | Rank | Description |
|---|---|---|
| A | 6 | Excellent — no significant findings |
| A- | 5 | Very good — minor findings only |
| B | 4 | Good — some findings |
| C | 3 | Acceptable — moderate findings |
| D | 2 | Poor — significant findings |
| F | 1 | Fail — critical findings |
Setting fail_on = "B" means any grade below B (i.e., C, D, or F) will cause the build to fail.
[verify]
Runtime verification configuration for perry verify.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | https://verify.perryts.com | Verification service endpoint URL |
Bundle ID Resolution
Perry resolves the bundle identifier using a cascading priority system. The first non-empty value wins:
For iOS builds:
[ios].bundle_id[app].bundle_id[project].bundle_id[macos].bundle_idpackage.jsonbundleIdfieldcom.perry.<app_name>(generated default)
For macOS builds:
[macos].bundle_id[app].bundle_id[project].bundle_idpackage.jsonbundleIdfieldcom.perry.<app_name>(generated default)
For Android builds:
[android].package_name[ios].bundle_id[macos].bundle_id[app].bundle_id[project].bundle_idcom.perry.<app_name>(generated default)
Entry File Resolution
When no input file is specified on the command line, Perry resolves the entry file in this order:
[ios].entry/[android].entry(when targeting that platform)[project].entryor[app].entrysrc/main.ts(if it exists)main.ts(if it exists)
Build Number Auto-Increment
The build_number field is automatically incremented by perry publish for:
- iOS builds
- Android builds
- macOS App Store builds (
distribute = "appstore"or"both")
The updated value is written back to perry.toml after a successful publish. This ensures each submission to the App Store / Play Store has a unique, monotonically increasing build number.
macOS builds with distribute = "notarize" (direct distribution) do not auto-increment the build number.
Configuration Priority
Perry resolves configuration values using a layered priority system (highest to lowest):
- CLI flags — e.g.,
--target,--output - Environment variables — e.g.,
PERRY_LICENSE_KEY - perry.toml — project-level config (platform-specific sections first, then
[app]/[project]) - ~/.perry/config.toml — user-level global config
- Built-in defaults
Environment Variables
These environment variables override perry.toml and global config values:
Apple / iOS / macOS
| Variable | Description |
|---|---|
PERRY_LICENSE_KEY | Perry Hub license key |
PERRY_APPLE_CERTIFICATE | .p12 certificate file contents (base64) |
PERRY_APPLE_CERTIFICATE_PASSWORD | Password for the .p12 certificate |
PERRY_APPLE_P8_KEY | .p8 API key file contents |
PERRY_APPLE_KEY_ID | App Store Connect API key ID |
PERRY_APPLE_NOTARIZE_CERTIFICATE_PASSWORD | Password for the notarization .p12 certificate |
PERRY_APPLE_INSTALLER_CERTIFICATE_PASSWORD | Password for the installer .p12 certificate |
Android
| Variable | Description |
|---|---|
PERRY_ANDROID_KEYSTORE | Path to .jks/.keystore file |
PERRY_ANDROID_KEY_ALIAS | Keystore key alias |
PERRY_ANDROID_KEYSTORE_PASSWORD | Keystore password |
PERRY_ANDROID_KEY_PASSWORD | Key password (within the keystore) |
PERRY_GOOGLE_PLAY_KEY_PATH | Path to Google Play service account JSON |
General
| Variable | Description |
|---|---|
PERRY_NO_TELEMETRY | Set to 1 to disable anonymous telemetry |
PERRY_NO_UPDATE_CHECK | Set to 1 to disable background update checks |
Global Config: ~/.perry/config.toml
Separate from the project-level perry.toml, Perry maintains a user-level global config at ~/.perry/config.toml. This stores credentials and preferences shared across all projects.
license_key = "perry-xxxxxxxx"
server = "https://hub.perryts.com"
default_target = "macos"
[apple]
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "/Users/me/.perry/AuthKey.p8"
[android]
keystore_path = "/Users/me/.perry/release.keystore"
key_alias = "my-key"
google_play_key_path = "/Users/me/.perry/play-service-account.json"
Fields in perry.toml (project-level) override ~/.perry/config.toml (global-level). For example, [ios].team_id in perry.toml overrides [apple].team_id in the global config.
The global config is managed by perry setup commands:
perry setup ios— configures Apple signing credentialsperry setup android— configures Android signing credentialsperry setup macos— configures macOS distribution settings
perry.toml vs package.json
Perry reads configuration from both files. Here’s what goes where:
| Setting | File | Section |
|---|---|---|
| Compile packages natively | package.json | perry.compilePackages |
| Splash screen | package.json | perry.splash |
| Project name, version, entry | perry.toml | [project] |
| Platform-specific settings | perry.toml | [ios], [macos], [android], [linux] |
| Code signing & distribution | perry.toml | Platform sections |
| Build output directory | perry.toml | [build] |
| Audit & verification | perry.toml | [audit], [verify] |
When both files define the same value (e.g., project name), perry.toml takes precedence.
Setup Wizard
Running perry setup <platform> interactively configures signing credentials and writes them back to both perry.toml and ~/.perry/config.toml:
perry setup ios # Configure iOS signing (certificate, provisioning profile)
perry setup android # Configure Android signing (keystore, Play Store key)
perry setup macos # Configure macOS distribution (App Store, notarization)
The wizard automatically:
- Sets
[ios].distribute = "testflight"if not already configured - Sets
[android].distribute = "playstore"if not already configured - Stores provisioning profiles as
~/.perry/{bundle_id}.mobileprovision - Auto-exports
.p12certificates from macOS Keychain when possible
CI/CD Example
For CI environments, use environment variables instead of storing credentials in perry.toml:
# GitHub Actions example
env:
PERRY_LICENSE_KEY: ${{ secrets.PERRY_LICENSE_KEY }}
PERRY_APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
PERRY_APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
PERRY_APPLE_P8_KEY: ${{ secrets.APPLE_P8_KEY }}
PERRY_APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
PERRY_ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
PERRY_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PERRY_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
PERRY_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- run: perry publish ios
- run: perry publish android
- run: perry publish macos
Keep perry.toml in version control with non-sensitive fields only:
[project]
name = "my-app"
version = "2.1.0"
build_number = 47
bundle_id = "com.example.myapp"
entry = "src/main.ts"
[ios]
deployment_target = "16.0"
device_family = ["iphone", "ipad"]
distribute = "appstore"
encryption_exempt = true
[android]
package_name = "com.example.myapp"
min_sdk = "26"
target_sdk = "34"
distribute = "playstore"
[macos]
distribute = "both"
category = "public.app-category.productivity"
minimum_os = "13.0"
[audit]
fail_on = "B"
Architecture
This is a brief overview for contributors. For detailed implementation notes, see the project’s CLAUDE.md.
Compilation Pipeline
TypeScript (.ts)
↓ Parse (SWC)
↓ AST
↓ Lower (perry-hir)
↓ HIR (High-level IR)
↓ Transform (inline, closure conversion, async lowering)
↓ Codegen (LLVM)
↓ Object file (.o)
↓ Link (system cc)
↓
Native Executable
Crate Map
| Crate | Purpose |
|---|---|
perry | CLI driver, command parsing, compilation orchestration |
perry-parser | SWC wrapper for TypeScript parsing |
perry-types | Type system definitions |
perry-hir | HIR data structures (ir.rs) and AST→HIR lowering (lower.rs) |
perry-transform | IR passes: function inlining, closure conversion, async lowering |
perry-codegen-llvm | LLVM-based native code generation |
perry-codegen-wasm | WebAssembly code generation for --target web / --target wasm (HIR → WASM bytecode + JS bridge) |
perry-codegen-js | Legacy JavaScript code generator (still present for the JS minifier; the JS-emit --target web path was consolidated into perry-codegen-wasm) |
perry-codegen-swiftui | SwiftUI code generation for WidgetKit extensions |
perry-runtime | Runtime library: NaN-boxed values, GC, arena allocator, objects, arrays, strings |
perry-stdlib | Node.js API implementations: mysql2, redis, fastify, bcrypt, etc. |
perry-ui | Shared UI types |
perry-ui-macos | macOS UI (AppKit) |
perry-ui-ios | iOS UI (UIKit) |
perry-jsruntime | JavaScript interop via QuickJS |
Key Concepts
NaN-Boxing
All JavaScript values are represented as 64-bit NaN-boxed values. The upper 16 bits encode the type tag:
| Tag | Type |
|---|---|
0x7FFF | String (lower 48 bits = pointer) |
0x7FFD | Pointer/Object (lower 48 bits = pointer) |
0x7FFE | Int32 (lower 32 bits = integer) |
0x7FFA | BigInt (lower 48 bits = pointer) |
| Special constants | undefined, null, true, false |
| Any other | Float64 (the full 64 bits) |
Garbage Collection
Mark-sweep GC with conservative stack scanning. Arena-allocated objects (arrays, objects) are found by linear block walking. Malloc-allocated objects (strings, closures, promises) are tracked in a thread-local Vec.
Handle-Based UI
UI widgets are represented as small integer handles NaN-boxed with POINTER_TAG. Each handle maps to a native platform widget (NSButton, UILabel, GtkButton, etc.). Two dispatch tables route method calls and property accesses to the correct FFI function.
Source Code Organization
The codegen crate is organized into focused modules:
perry-codegen-llvm/src/
codegen.rs # Main entry, module compilation
types.rs # Type definitions, context structs
util.rs # Helper functions
stubs.rs # Stub generation for unresolved deps
runtime_decls.rs # Runtime function declarations
classes.rs # Class compilation
functions.rs # Function compilation
closures.rs # Closure compilation
module_init.rs # Module initialization
stmt.rs # Statement compilation
expr.rs # Expression compilation
The HIR lowering was split into 8 modules:
perry-hir/src/
lower.rs # Main lowering entry
analysis.rs # Code analysis passes
enums.rs # Enum lowering
jsx.rs # JSX lowering
lower_types.rs # Type lowering
lower_patterns.rs # Pattern lowering
destructuring.rs # Destructuring lowering
lower_decl.rs # Declaration lowering
Next Steps
- Building from Source
- See
CLAUDE.mdin the repository root for detailed implementation notes
Building from Source
Prerequisites
- Rust toolchain (stable): rustup.rs
- System C compiler (
ccon macOS/Linux, MSVC on Windows)
Build
git clone https://github.com/skelpo/perry.git
cd perry
# Build all crates (release mode recommended)
cargo build --release
The binary is at target/release/perry.
Build Specific Crates
# Runtime only (must rebuild stdlib too!)
cargo build --release -p perry-runtime -p perry-stdlib
# Codegen only
cargo build --release -p perry-codegen-llvm
Important: When rebuilding
perry-runtime, you must also rebuildperry-stdlibbecauselibperry_stdlib.aembeds perry-runtime as a static dependency.
Run Tests
# All tests (exclude iOS crate on non-iOS host)
cargo test --workspace --exclude perry-ui-ios
# Specific crate
cargo test -p perry-hir
cargo test -p perry-codegen-llvm
Compile and Run TypeScript
# Compile a TypeScript file
cargo run --release -- hello.ts -o hello
./hello
# Debug: print HIR
cargo run --release -- hello.ts --print-hir
Development Workflow
- Make changes to the relevant crate
cargo build --releaseto buildcargo test --workspace --exclude perry-ui-iosto verify- Test with a real TypeScript file:
cargo run --release -- test.ts -o test && ./test
Project Structure
perry/
├── crates/
│ ├── perry/ # CLI driver
│ ├── perry-parser/ # SWC TypeScript parser
│ ├── perry-types/ # Type definitions
│ ├── perry-hir/ # HIR and lowering
│ ├── perry-transform/ # IR passes
│ ├── perry-codegen-llvm/ # LLVM native codegen
│ ├── perry-codegen-wasm/ # WebAssembly codegen (--target web / --target wasm)
│ ├── perry-codegen-js/ # JS minifier (formerly the web target's codegen)
│ ├── perry-codegen-swiftui/ # Widget codegen
│ ├── perry-runtime/ # Runtime library
│ ├── perry-stdlib/ # npm package implementations
│ ├── perry-ui/ # Shared UI types
│ ├── perry-ui-macos/ # macOS AppKit UI
│ ├── perry-ui-ios/ # iOS UIKit UI
│ └── perry-jsruntime/ # QuickJS integration
├── docs/ # This documentation (mdBook)
├── CLAUDE.md # Detailed implementation notes
└── CHANGELOG.md # Version history
Next Steps
- Architecture — Crate map and pipeline overview
- See
CLAUDE.mdfor detailed implementation notes and pitfalls