feat(core): expose model-declared windows to TypeScript (#351)
* feat(core): expose model-declared windows to TypeScript - Add canonical TypeScript window descriptors with close-policy and close-command routing. - Compile and hot-reload label-addressed secondary-window markup in generated launchers. - Cover quit/hide behavior end to end and port the TypeScript system-monitor settings window. * fix(core): harden TypeScript window declarations * fix(core): validate TypeScript window views * fix(core): validate returned window descriptors * fix(core): harden TypeScript window contracts
This commit is contained in:
@@ -84,6 +84,31 @@ export function utf8Bytes(s: string): Uint8Array {
|
||||
|
||||
export type Msgish = { readonly kind: string };
|
||||
|
||||
import { type WindowDescriptor, type WindowDescriptorSpec } from "./events.ts";
|
||||
|
||||
export function windowDescriptor(spec: WindowDescriptorSpec): WindowDescriptor {
|
||||
return {
|
||||
label: spec.label,
|
||||
canvasLabel: spec.canvasLabel,
|
||||
title: spec.title ?? new Uint8Array(0),
|
||||
width: spec.width ?? 480,
|
||||
height: spec.height ?? 360,
|
||||
x: spec.x ?? null,
|
||||
y: spec.y ?? null,
|
||||
resizable: spec.resizable ?? true,
|
||||
minWidth: spec.minWidth ?? 0,
|
||||
minHeight: spec.minHeight ?? 0,
|
||||
titlebar: spec.titlebar ?? "standard",
|
||||
transparent: spec.transparent ?? false,
|
||||
alwaysOnTop: spec.alwaysOnTop ?? false,
|
||||
clickThrough: spec.clickThrough ?? false,
|
||||
activateOnShow: spec.activateOnShow ?? true,
|
||||
allowsFullscreen: spec.allowsFullscreen ?? true,
|
||||
closePolicy: spec.closePolicy ?? "quit",
|
||||
onCloseCommand: spec.onCloseCommand ?? new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Cooperative cancellation capability supplied by generated service hosts. */
|
||||
export interface ServiceCancellation {
|
||||
readonly cancelled: () => boolean;
|
||||
|
||||
Vendored
+2
@@ -3,6 +3,8 @@ export declare function utf8Bytes(s: string): Uint8Array;
|
||||
export type Msgish = {
|
||||
readonly kind: string;
|
||||
};
|
||||
import { type WindowDescriptor, type WindowDescriptorSpec } from "./events.js";
|
||||
export declare function windowDescriptor(spec: WindowDescriptorSpec): WindowDescriptor;
|
||||
/** Cooperative cancellation capability supplied by generated service hosts. */
|
||||
export interface ServiceCancellation {
|
||||
/** True after Cmd.cancel or the operation deadline requests cancellation. */
|
||||
|
||||
@@ -375,6 +375,32 @@ export function utf8Bytes(s: string): Uint8Array {
|
||||
/// Every app Msg is a discriminated union on a string `kind` tag.
|
||||
export type Msgish = { readonly kind: string };
|
||||
|
||||
import { type WindowDescriptor, type WindowDescriptorSpec } from "./events.ts";
|
||||
|
||||
/// Fill the canonical defaults for a model-declared secondary window.
|
||||
export function windowDescriptor(spec: WindowDescriptorSpec): WindowDescriptor {
|
||||
return {
|
||||
label: spec.label,
|
||||
canvasLabel: spec.canvasLabel,
|
||||
title: spec.title ?? new Uint8Array(0),
|
||||
width: spec.width ?? 480,
|
||||
height: spec.height ?? 360,
|
||||
x: spec.x ?? null,
|
||||
y: spec.y ?? null,
|
||||
resizable: spec.resizable ?? true,
|
||||
minWidth: spec.minWidth ?? 0,
|
||||
minHeight: spec.minHeight ?? 0,
|
||||
titlebar: spec.titlebar ?? "standard",
|
||||
transparent: spec.transparent ?? false,
|
||||
alwaysOnTop: spec.alwaysOnTop ?? false,
|
||||
clickThrough: spec.clickThrough ?? false,
|
||||
activateOnShow: spec.activateOnShow ?? true,
|
||||
allowsFullscreen: spec.allowsFullscreen ?? true,
|
||||
closePolicy: spec.closePolicy ?? "quit",
|
||||
onCloseCommand: spec.onCloseCommand ?? new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Cooperative cancellation capability supplied by generated service hosts. */
|
||||
export interface ServiceCancellation {
|
||||
/** True after Cmd.cancel or the operation deadline requests cancellation. */
|
||||
|
||||
Vendored
+42
@@ -46,6 +46,48 @@ export interface StatusItemDescriptor {
|
||||
readonly presentation: StatusItemPresentation;
|
||||
readonly items: readonly StatusItemMenuItem[];
|
||||
}
|
||||
export type WindowClosePolicy = "quit" | "hide";
|
||||
export type WindowTitlebarStyle = "standard" | "hidden_inset" | "hidden_inset_tall" | "chromeless";
|
||||
export interface WindowDescriptorSpec {
|
||||
readonly label: Uint8Array;
|
||||
readonly canvasLabel: Uint8Array;
|
||||
readonly title?: Uint8Array;
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
readonly x?: number | null;
|
||||
readonly y?: number | null;
|
||||
readonly resizable?: boolean;
|
||||
readonly minWidth?: number;
|
||||
readonly minHeight?: number;
|
||||
readonly titlebar?: WindowTitlebarStyle;
|
||||
readonly transparent?: boolean;
|
||||
readonly alwaysOnTop?: boolean;
|
||||
readonly clickThrough?: boolean;
|
||||
readonly activateOnShow?: boolean;
|
||||
readonly allowsFullscreen?: boolean;
|
||||
readonly closePolicy?: WindowClosePolicy;
|
||||
readonly onCloseCommand?: Uint8Array;
|
||||
}
|
||||
export interface WindowDescriptor {
|
||||
readonly label: Uint8Array;
|
||||
readonly canvasLabel: Uint8Array;
|
||||
readonly title: Uint8Array;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly x: number | null;
|
||||
readonly y: number | null;
|
||||
readonly resizable: boolean;
|
||||
readonly minWidth: number;
|
||||
readonly minHeight: number;
|
||||
readonly titlebar: WindowTitlebarStyle;
|
||||
readonly transparent: boolean;
|
||||
readonly alwaysOnTop: boolean;
|
||||
readonly clickThrough: boolean;
|
||||
readonly activateOnShow: boolean;
|
||||
readonly allowsFullscreen: boolean;
|
||||
readonly closePolicy: WindowClosePolicy;
|
||||
readonly onCloseCommand: Uint8Array;
|
||||
}
|
||||
export interface ScrollState {
|
||||
readonly offsetX: number;
|
||||
readonly offsetY: number;
|
||||
|
||||
@@ -112,6 +112,62 @@ export interface StatusItemDescriptor {
|
||||
readonly items: readonly StatusItemMenuItem[];
|
||||
}
|
||||
|
||||
/// What the user's close affordance does for a model-declared secondary
|
||||
/// window. `quit` really closes it and routes `onCloseCommand`; `hide` keeps
|
||||
/// the native window and view tree alive for `Cmd.showWindow(label)`.
|
||||
export type WindowClosePolicy = "quit" | "hide";
|
||||
|
||||
export type WindowTitlebarStyle = "standard" | "hidden_inset" | "hidden_inset_tall" | "chromeless";
|
||||
|
||||
/// Author-facing input to `windowDescriptor`; omitted fields receive the
|
||||
/// same defaults as UiApp.WindowDescriptor.
|
||||
export interface WindowDescriptorSpec {
|
||||
readonly label: Uint8Array;
|
||||
readonly canvasLabel: Uint8Array;
|
||||
readonly title?: Uint8Array;
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
readonly x?: number | null;
|
||||
readonly y?: number | null;
|
||||
readonly resizable?: boolean;
|
||||
readonly minWidth?: number;
|
||||
readonly minHeight?: number;
|
||||
readonly titlebar?: WindowTitlebarStyle;
|
||||
readonly transparent?: boolean;
|
||||
readonly alwaysOnTop?: boolean;
|
||||
readonly clickThrough?: boolean;
|
||||
readonly activateOnShow?: boolean;
|
||||
readonly allowsFullscreen?: boolean;
|
||||
readonly closePolicy?: WindowClosePolicy;
|
||||
readonly onCloseCommand?: Uint8Array;
|
||||
}
|
||||
|
||||
/// One independently reconciled secondary window returned by
|
||||
/// `windows(model)`. The generated launcher compiles
|
||||
/// `src/windows/<label>.native` as its view; presence is liveness. Construct
|
||||
/// entries with `windowDescriptor` and spell `label` as a literal
|
||||
/// `asciiBytes("<label>")` so check/build can prove that root exists.
|
||||
export interface WindowDescriptor {
|
||||
readonly label: Uint8Array;
|
||||
readonly canvasLabel: Uint8Array;
|
||||
readonly title: Uint8Array;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly x: number | null;
|
||||
readonly y: number | null;
|
||||
readonly resizable: boolean;
|
||||
readonly minWidth: number;
|
||||
readonly minHeight: number;
|
||||
readonly titlebar: WindowTitlebarStyle;
|
||||
readonly transparent: boolean;
|
||||
readonly alwaysOnTop: boolean;
|
||||
readonly clickThrough: boolean;
|
||||
readonly activateOnShow: boolean;
|
||||
readonly allowsFullscreen: boolean;
|
||||
readonly closePolicy: WindowClosePolicy;
|
||||
readonly onCloseCommand: Uint8Array;
|
||||
}
|
||||
|
||||
/// The scroll-state mirror markup's `on-scroll` matches structurally: a
|
||||
/// record of exactly these eight numeric fields — the TWO-AXIS shape, one
|
||||
/// offset/velocity/viewport/content quartet per axis. Offsets and extents
|
||||
|
||||
@@ -462,6 +462,7 @@ export class SubsetChecker {
|
||||
private readonly permissions: Set<string>;
|
||||
private readonly persistRoutes: PersistRoutes | undefined;
|
||||
private readonly sdkCorePath: string;
|
||||
private readonly windowViews: ReadonlySet<string> | undefined;
|
||||
private usesPersist = false;
|
||||
private usesStore = false;
|
||||
private usesSqlite = false;
|
||||
@@ -476,6 +477,7 @@ export class SubsetChecker {
|
||||
permissions: readonly string[] = [],
|
||||
persistRoutes?: PersistRoutes,
|
||||
sdkCorePath: string = sdkCoreModulePath,
|
||||
windowViews?: readonly string[],
|
||||
) {
|
||||
this.tast = tast;
|
||||
this.table = table;
|
||||
@@ -487,6 +489,7 @@ export class SubsetChecker {
|
||||
this.permissions = new Set(permissions);
|
||||
this.persistRoutes = persistRoutes;
|
||||
this.sdkCorePath = sdkCorePath;
|
||||
this.windowViews = windowViews === undefined ? undefined : new Set(windowViews);
|
||||
}
|
||||
|
||||
check(): CheckResult {
|
||||
@@ -506,6 +509,7 @@ export class SubsetChecker {
|
||||
this.checkThemePackHelper();
|
||||
this.checkStatusItemHelper();
|
||||
this.checkStatusItemsHelper();
|
||||
this.checkWindowsHelper();
|
||||
this.checkViewUnbound();
|
||||
this.checkReservedContractConsts();
|
||||
this.checkValueRecordAliases();
|
||||
@@ -995,6 +999,254 @@ export class SubsetChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry-module function exported under its own wiring name. The
|
||||
/// entry-contract pass separately teaches re-exports and renames; this
|
||||
/// query lets related channels prove that the generated launcher will
|
||||
/// actually have the named callback available.
|
||||
private entryExportedFunction(name: string): ts.FunctionDeclaration | null {
|
||||
for (const stmt of this.entry.statements) {
|
||||
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name && hasExportModifier(stmt)) return stmt;
|
||||
}
|
||||
for (const binding of exportListBindings(this.tast, this.entry)) {
|
||||
if (
|
||||
binding.exportedName === name &&
|
||||
!binding.renamed &&
|
||||
binding.target !== null &&
|
||||
binding.target !== undefined &&
|
||||
ts.isFunctionDeclaration(binding.target) &&
|
||||
binding.target.getSourceFile() === this.entry
|
||||
) {
|
||||
return binding.target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The generated launcher owns a closed, comptime-compiled registry keyed
|
||||
/// by direct `src/windows/<label>.native` stems. Require every possible
|
||||
/// descriptor to declare that identity as a literal at its canonical
|
||||
/// constructor call, then prove the root exists before compilation. This
|
||||
/// turns a would-be first-render panic into a source diagnostic in both
|
||||
/// `native check` and every build.
|
||||
private checkWindowViewRegistry(windowsDecl: ts.FunctionDeclaration): void {
|
||||
if (this.windowViews === undefined) return;
|
||||
|
||||
type ProducedKind = "collection" | "descriptor";
|
||||
let sawConstructor = false;
|
||||
let productionErrors = 0;
|
||||
const validatingCollections = new Set<ts.FunctionDeclaration>();
|
||||
const validatingDescriptors = new Set<ts.FunctionDeclaration>();
|
||||
const validatedCollections = new Set<ts.FunctionDeclaration>();
|
||||
const validatedDescriptors = new Set<ts.FunctionDeclaration>();
|
||||
|
||||
const unwrap = (expression: ts.Expression): ts.Expression => {
|
||||
let current = expression;
|
||||
while (
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isAsExpression(current) ||
|
||||
ts.isSatisfiesExpression(current) ||
|
||||
ts.isNonNullExpression(current)
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const reportProduction = (node: ts.Node, kind: ProducedKind): void => {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
kind === "collection"
|
||||
? "Every value returned by `windows` must be an array literal of direct `windowDescriptor(...)` results (factored constructor helpers are fine); copied, spread, or mutable descriptor collections can replace the statically registered label."
|
||||
: "Every window returned by `windows` must flow directly from `windowDescriptor({...})` (or a helper whose own returns do); copying, spreading, or rebuilding a descriptor can replace its statically registered label.",
|
||||
node,
|
||||
);
|
||||
};
|
||||
|
||||
const appFunctionTarget = (call: ts.CallExpression): ts.FunctionDeclaration | null => {
|
||||
if (!ts.isIdentifier(call.expression) && !ts.isPropertyAccessExpression(call.expression)) return null;
|
||||
const target = this.tast.declarationOf(
|
||||
ts.isIdentifier(call.expression) ? call.expression : call.expression.name,
|
||||
);
|
||||
return target && ts.isFunctionDeclaration(target) && this.fileSet.has(target.getSourceFile()) ? target : null;
|
||||
};
|
||||
|
||||
const returnExpressions = (fn: ts.FunctionDeclaration): ts.Expression[] => {
|
||||
const returns: ts.Expression[] = [];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (node !== fn && (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))) return;
|
||||
if (ts.isReturnStatement(node)) {
|
||||
if (node.expression) returns.push(node.expression);
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
if (fn.body) visit(fn.body);
|
||||
return returns;
|
||||
};
|
||||
|
||||
const validateConstructor = (call: ts.CallExpression): void => {
|
||||
sawConstructor = true;
|
||||
const spec = call.arguments[0];
|
||||
if (!spec || !ts.isObjectLiteralExpression(unwrap(spec))) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
"A model-declared window must pass an inline object to `windowDescriptor`, with a static `label: asciiBytes(\"<label>\")` property selecting its compiled view.",
|
||||
spec ?? call,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const object = unwrap(spec) as ts.ObjectLiteralExpression;
|
||||
const labelProp = object.properties.find((prop): prop is ts.PropertyAssignment =>
|
||||
ts.isPropertyAssignment(prop) &&
|
||||
((ts.isIdentifier(prop.name) && prop.name.text === "label") ||
|
||||
(ts.isStringLiteral(prop.name) && prop.name.text === "label")),
|
||||
);
|
||||
const labelIndex = labelProp === undefined ? -1 : object.properties.indexOf(labelProp);
|
||||
if (object.properties.some((property, index) => ts.isSpreadAssignment(property) && index > labelIndex)) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
"A `windowDescriptor` spec cannot spread fields after its literal label: that spread can replace the identity after the registry check. Move the literal label after every spread.",
|
||||
object,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const labelExpr = labelProp?.initializer;
|
||||
const labelArg = labelExpr && ts.isCallExpression(labelExpr) &&
|
||||
this.sdkRootFunctionName(labelExpr.expression) === "asciiBytes"
|
||||
? labelExpr.arguments[0]
|
||||
: undefined;
|
||||
if (!labelArg || !ts.isStringLiteral(labelArg)) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
"A model-declared window label must be a static `asciiBytes(\"<label>\")` property inside `windowDescriptor({...})`; that literal selects `src/windows/<label>.native` at build time.",
|
||||
labelProp ?? object,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const label = labelArg.text;
|
||||
if (
|
||||
label.length === 0 ||
|
||||
label.length > 64 ||
|
||||
label === "." ||
|
||||
label === ".." ||
|
||||
label.includes("\0") ||
|
||||
label.includes("/") ||
|
||||
label.includes("\\")
|
||||
) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
"A model-declared window label must be a non-empty filename stem of at most 64 ASCII bytes, without path separators or NUL bytes.",
|
||||
labelArg,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.windowViews.has(labelArg.text)) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
`Window label \`${labelArg.text}\` has no compiled view; add \`src/windows/${labelArg.text}.native\` or change the descriptor label to an existing root.`,
|
||||
labelArg,
|
||||
);
|
||||
}
|
||||
|
||||
const propertyNamed = (name: string): ts.PropertyAssignment | undefined =>
|
||||
object.properties.find((prop): prop is ts.PropertyAssignment =>
|
||||
ts.isPropertyAssignment(prop) &&
|
||||
((ts.isIdentifier(prop.name) && prop.name.text === name) ||
|
||||
(ts.isStringLiteral(prop.name) && prop.name.text === name)),
|
||||
);
|
||||
const closePolicy = propertyNamed("closePolicy")?.initializer;
|
||||
const alwaysHides = closePolicy !== undefined && ts.isStringLiteral(unwrap(closePolicy)) && unwrap(closePolicy).text === "hide";
|
||||
const closeCommand = propertyNamed("onCloseCommand")?.initializer;
|
||||
const closeCommandArg = closeCommand !== undefined && ts.isCallExpression(unwrap(closeCommand)) &&
|
||||
this.sdkRootFunctionName((unwrap(closeCommand) as ts.CallExpression).expression) === "asciiBytes"
|
||||
? (unwrap(closeCommand) as ts.CallExpression).arguments[0]
|
||||
: undefined;
|
||||
const definitelyEmptyCloseCommand = closeCommandArg !== undefined && ts.isStringLiteral(closeCommandArg) && closeCommandArg.text.length === 0;
|
||||
if (
|
||||
closeCommand !== undefined &&
|
||||
!definitelyEmptyCloseCommand &&
|
||||
!alwaysHides &&
|
||||
this.entryExportedFunction("commandMsg") === null
|
||||
) {
|
||||
productionErrors += 1;
|
||||
this.report(
|
||||
"NS1033",
|
||||
"A quit-close `onCloseCommand` requires `commandMsg(name: string): Msg | null`; otherwise the generated launcher cannot map the command to the Msg that removes the window declaration.",
|
||||
closeCommand,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateFunction = (fn: ts.FunctionDeclaration, kind: ProducedKind): void => {
|
||||
const validating = kind === "collection" ? validatingCollections : validatingDescriptors;
|
||||
const validated = kind === "collection" ? validatedCollections : validatedDescriptors;
|
||||
if (validated.has(fn) || validating.has(fn)) return;
|
||||
validating.add(fn);
|
||||
const returns = returnExpressions(fn);
|
||||
if (returns.length === 0) reportProduction(fn.name ?? fn, kind);
|
||||
for (const expression of returns) validateExpression(expression, kind);
|
||||
validating.delete(fn);
|
||||
validated.add(fn);
|
||||
};
|
||||
|
||||
const validateExpression = (raw: ts.Expression, kind: ProducedKind): void => {
|
||||
const expression = unwrap(raw);
|
||||
if (ts.isConditionalExpression(expression)) {
|
||||
validateExpression(expression.whenTrue, kind);
|
||||
validateExpression(expression.whenFalse, kind);
|
||||
return;
|
||||
}
|
||||
if (kind === "collection" && ts.isArrayLiteralExpression(expression)) {
|
||||
for (const element of expression.elements) {
|
||||
if (ts.isSpreadElement(element)) validateExpression(element.expression, "collection");
|
||||
else validateExpression(element, "descriptor");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === "descriptor" && ts.isIdentifier(expression)) {
|
||||
const declaration = this.tast.declarationOf(expression);
|
||||
if (
|
||||
declaration &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
declaration.initializer &&
|
||||
ts.isVariableDeclarationList(declaration.parent) &&
|
||||
(declaration.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
) {
|
||||
validateExpression(declaration.initializer, "descriptor");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(expression)) {
|
||||
if (kind === "descriptor" && this.sdkRootFunctionName(expression.expression) === "windowDescriptor") {
|
||||
validateConstructor(expression);
|
||||
return;
|
||||
}
|
||||
const helper = appFunctionTarget(expression);
|
||||
if (helper) {
|
||||
validateFunction(helper, kind);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reportProduction(expression, kind);
|
||||
};
|
||||
|
||||
validateFunction(windowsDecl, "collection");
|
||||
|
||||
if (!sawConstructor && productionErrors === 0) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
"`windows` must construct each possible entry with `windowDescriptor({ label: asciiBytes(\"<label>\"), ... })` so the generated launcher can prove its `src/windows/<label>.native` view exists.",
|
||||
windowsDecl.name ?? windowsDecl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private checkStatusItemsHelper(): void {
|
||||
let decl: ts.FunctionDeclaration | null = null;
|
||||
for (const stmt of this.entry.statements) {
|
||||
@@ -1108,6 +1360,86 @@ export class SubsetChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/// `windows(model)` is the TypeScript launcher's model-declared secondary
|
||||
/// window set. Keep the descriptor exact: the Zig adapter projects it into
|
||||
/// UiApp.WindowDescriptor, including close-command routing and closePolicy.
|
||||
private checkWindowsHelper(): void {
|
||||
let decl: ts.FunctionDeclaration | null = null;
|
||||
for (const stmt of this.entry.statements) {
|
||||
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === "windows" && hasExportModifier(stmt)) {
|
||||
decl = stmt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (decl === null) {
|
||||
for (const binding of exportListBindings(this.tast, this.entry)) {
|
||||
if (
|
||||
binding.exportedName === "windows" &&
|
||||
binding.target !== null &&
|
||||
binding.target !== undefined &&
|
||||
ts.isFunctionDeclaration(binding.target) &&
|
||||
binding.target.getSourceFile() === this.entry
|
||||
) {
|
||||
decl = binding.target;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (decl === null) return;
|
||||
const helper = this.table.modelHelperDecls().find(
|
||||
(candidate) => candidate.name === "windows" && candidate.decl === decl,
|
||||
);
|
||||
if (helper === undefined || decl.type === undefined) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
"`windows` must be a single-Model-parameter helper with an explicit `readonly WindowDescriptor[]` return type.",
|
||||
decl.name ?? decl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const returns = this.table.resolveTypeNode(decl.type);
|
||||
const descriptorType = returns.k === "slice" && returns.elem.k === "struct" ? returns.elem : null;
|
||||
const descriptor = descriptorType === null ? undefined : this.table.structs.get(descriptorType.name);
|
||||
const names = descriptor?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const field = (name: string) => descriptor?.fields.find((candidate) => candidate.tsName === name);
|
||||
const numeric = (name: string): boolean => {
|
||||
const candidate = field(name);
|
||||
return candidate !== undefined && ["number", "i64", "f64", "numAlias"].includes(candidate.type.k);
|
||||
};
|
||||
const enumMembersAre = (name: string, expected: readonly string[]): boolean => {
|
||||
const candidate = field(name);
|
||||
if (candidate?.type.k !== "enum") return false;
|
||||
const found = this.table.enums.get(candidate.type.name)?.members.slice().sort() ?? [];
|
||||
return found.join(",") === expected.slice().sort().join(",");
|
||||
};
|
||||
const optionalNumeric = (name: string): boolean => {
|
||||
const candidate = field(name);
|
||||
return candidate?.type.k === "optional" && ["number", "i64", "f64", "numAlias"].includes(candidate.type.inner.k);
|
||||
};
|
||||
const valid =
|
||||
names.join(",") === "activateOnShow,allowsFullscreen,alwaysOnTop,canvasLabel,clickThrough,closePolicy,height,label,minHeight,minWidth,onCloseCommand,resizable,title,titlebar,transparent,width,x,y" &&
|
||||
field("label")?.type.k === "bytes" &&
|
||||
field("canvasLabel")?.type.k === "bytes" &&
|
||||
field("title")?.type.k === "bytes" &&
|
||||
numeric("width") && numeric("height") && optionalNumeric("x") && optionalNumeric("y") &&
|
||||
field("resizable")?.type.k === "bool" && numeric("minWidth") && numeric("minHeight") &&
|
||||
enumMembersAre("titlebar", ["standard", "hidden_inset", "hidden_inset_tall", "chromeless"]) &&
|
||||
field("transparent")?.type.k === "bool" && field("alwaysOnTop")?.type.k === "bool" &&
|
||||
field("clickThrough")?.type.k === "bool" && field("activateOnShow")?.type.k === "bool" &&
|
||||
field("allowsFullscreen")?.type.k === "bool" && enumMembersAre("closePolicy", ["quit", "hide"]) &&
|
||||
field("onCloseCommand")?.type.k === "bytes";
|
||||
if (!valid) {
|
||||
const shape = descriptor?.fields.map((candidate) => `${candidate.tsName}:${candidate.type.k}${candidate.type.k === "optional" ? `(${candidate.type.inner.k}${candidate.type.inner.k === "union" ? `:${candidate.type.inner.name}` : ""})` : candidate.type.k === "enum" ? `:${candidate.type.name}` : ""}`).join(", ") ?? `missing descriptor (return=${returns.k}${returns.k === "slice" ? ` elem=${returns.elem.k}` : ""})`;
|
||||
this.report(
|
||||
"NS1033",
|
||||
`\`windows\` must return \`readonly WindowDescriptor[]\`; import \`WindowDescriptor\` from \`@native-sdk/core/events\` and construct entries with \`windowDescriptor(...)\`. Resolved: ${shape}`,
|
||||
decl.type,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.checkWindowViewRegistry(decl);
|
||||
}
|
||||
|
||||
/// NS1032 — `export const viewUnbound = [...] as const`: the dead-state
|
||||
/// lint opt-out. Every entry must be a string literal naming a Model
|
||||
/// field, an exported model helper, or a Msg kind; the emitter routes the
|
||||
@@ -1539,7 +1871,7 @@ export class SubsetChecker {
|
||||
/// entry points, but the exports themselves live in the entry module.
|
||||
private static readonly entryOnlyExports = new Set([
|
||||
"update", "initialModel", "subscriptions", "migrate",
|
||||
"commandMsg", "keyMsg", "frameMsg", "pinchMsg", "dropMsg", "appearanceMsg", "chromeMsg", "envMsgs", "themePack", "statusItem", "statusItems",
|
||||
"commandMsg", "keyMsg", "frameMsg", "pinchMsg", "dropMsg", "appearanceMsg", "chromeMsg", "envMsgs", "themePack", "statusItem", "statusItems", "windows",
|
||||
"viewUnbound", "modelUnbound", "msgUnbound",
|
||||
]);
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ function main(argv: string[]): number {
|
||||
let persistNone: string | null = null;
|
||||
let persistErr: string | null = null;
|
||||
let sdkCorePath: string | null = null;
|
||||
let windowViewsEnabled = false;
|
||||
const windowViews: string[] = [];
|
||||
const capabilities: string[] = [];
|
||||
const permissions: string[] = [];
|
||||
const servicePackages: ServicePackage[] = [];
|
||||
@@ -109,6 +111,16 @@ function main(argv: string[]): number {
|
||||
console.error("--sdk-core requires a generated core.ts path");
|
||||
return 2;
|
||||
}
|
||||
} else if (args[i] === "--window-views") {
|
||||
windowViewsEnabled = true;
|
||||
} else if (args[i] === "--window-view") {
|
||||
windowViewsEnabled = true;
|
||||
const label = args[++i] ?? null;
|
||||
if (label === null || label.length === 0) {
|
||||
console.error("--window-view requires a non-empty label");
|
||||
return 2;
|
||||
}
|
||||
windowViews.push(label);
|
||||
} else if (args[i] === "-o" || args[i] === "--out") {
|
||||
console.error(
|
||||
"-o named the removed TS-to-Zig emitter (v0.7.0 removed it): TypeScript cores compile through the external core compiler now, and this CLI checks the core and emits its contract sidecar (--contract). Drop the flag.",
|
||||
@@ -148,6 +160,7 @@ function main(argv: string[]): number {
|
||||
persistStatePath: persistStatePath ?? undefined,
|
||||
persistRoutes,
|
||||
sdkCorePath: sdkCorePath ?? undefined,
|
||||
windowViews: windowViewsEnabled ? windowViews : undefined,
|
||||
};
|
||||
const result = checkFile(entry, options);
|
||||
for (const e of result.typeErrors) console.error(e);
|
||||
|
||||
@@ -463,6 +463,7 @@ class ContractEmitter {
|
||||
// making every app repeat it in `viewUnbound`.
|
||||
if (helperNames.includes("statusItem") && !model.includes("statusItem")) model.push("statusItem");
|
||||
if (helperNames.includes("statusItems") && !model.includes("statusItems")) model.push("statusItems");
|
||||
if (helperNames.includes("windows") && !model.includes("windows")) model.push("windows");
|
||||
return { model, msg };
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ let appId = "app";
|
||||
const servicePackages: { name: string; version: string; content_hash: string }[] = [];
|
||||
const capabilities = new Set<string>();
|
||||
const permissions = new Set<string>();
|
||||
let windowViewsEnabled = false;
|
||||
const windowViews: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--script") script = args[++i] ?? null;
|
||||
else if (args[i] === "--capability") {
|
||||
@@ -103,6 +105,13 @@ for (let i = 0; i < args.length; i++) {
|
||||
if (permission === null) usage();
|
||||
permissions.add(permission);
|
||||
}
|
||||
else if (args[i] === "--window-views") windowViewsEnabled = true;
|
||||
else if (args[i] === "--window-view") {
|
||||
windowViewsEnabled = true;
|
||||
const label = args[++i] ?? null;
|
||||
if (label === null || label.length === 0) usage();
|
||||
windowViews.push(label);
|
||||
}
|
||||
else if (args[i] === "--persist-ok") persistOk = args[++i] ?? null;
|
||||
else if (args[i] === "--persist-none") persistNone = args[++i] ?? null;
|
||||
else if (args[i] === "--persist-err") persistErr = args[++i] ?? null;
|
||||
@@ -143,7 +152,7 @@ if (serviceCwd !== null) {
|
||||
const persistRouteCount = [persistOk, persistNone, persistErr].filter((route) => route !== null).length;
|
||||
if (persistRouteCount !== 0 && persistRouteCount !== 3) usage();
|
||||
let checked: ReturnType<typeof checkFile> | null = null;
|
||||
if (capabilities.size > 0 || permissions.size > 0 || (persistOk !== null && persistNone !== null && persistErr !== null) || fs.existsSync(path.join(path.dirname(path.resolve(entry)), "services"))) {
|
||||
if (capabilities.size > 0 || permissions.size > 0 || windowViewsEnabled || (persistOk !== null && persistNone !== null && persistErr !== null) || fs.existsSync(path.join(path.dirname(path.resolve(entry)), "services"))) {
|
||||
// Type information is erased by the time this module imports the app core.
|
||||
// Run the frontend inside the watched process so every node --watch restart
|
||||
// revalidates manifest-owned routes against the newly edited Msg union.
|
||||
@@ -154,6 +163,7 @@ if (capabilities.size > 0 || permissions.size > 0 || (persistOk !== null && pers
|
||||
servicesContract: true,
|
||||
servicePackages,
|
||||
sdkCorePath: sdkCore ?? undefined,
|
||||
windowViews: windowViewsEnabled ? windowViews : undefined,
|
||||
});
|
||||
for (const error of checked.typeErrors) console.error(error);
|
||||
for (const diagnostic of checked.diagnostics) console.error(formatDiagnostic(diagnostic));
|
||||
|
||||
@@ -257,8 +257,8 @@ export const rules = {
|
||||
NS1033: {
|
||||
id: "NS1033",
|
||||
title: "wiring exports match their runtime shapes",
|
||||
fix: "Declare the channel exactly: `commandMsg(name: string)` / `keyMsg(key: KeyEvent)` / `frameMsg(model: Model, frame: FrameEvent)` / `pinchMsg(pinch: PinchEvent)` / `dropMsg(drop: FileDropEvent)` returning `Msg | null`; `themePack(model: Model): ThemePack`; singular `statusItem(model: Model): StatusItemState` or collection `statusItems(model: Model): readonly StatusItemDescriptor[]`; `appearanceMsg` / `chromeMsg` naming an arm with that channel's record shape; `envMsgs` entries targeting one-`Uint8Array`-field arms; and persistence ok/none routes naming void arms while err names a one-`Uint8Array`-field arm. Import canonical records from `@native-sdk/core/events`.",
|
||||
why: "The generated wiring builds host events, persistence restore results, model-derived theme selection, and the live menu-bar status item structurally from your declarations at build time; a wrong shape would otherwise surface as a Zig compile error inside generated code instead of a teaching diagnostic here.",
|
||||
fix: "Declare the channel exactly: `commandMsg(name: string)` / `keyMsg(key: KeyEvent)` / `frameMsg(model: Model, frame: FrameEvent)` / `pinchMsg(pinch: PinchEvent)` / `dropMsg(drop: FileDropEvent)` returning `Msg | null`; `themePack(model: Model): ThemePack`; singular `statusItem(model: Model): StatusItemState` or collection `statusItems(model: Model): readonly StatusItemDescriptor[]`; `windows(model: Model): readonly WindowDescriptor[]`, with each entry constructed by `windowDescriptor` and a literal `label: asciiBytes(\"name\")` matching `src/windows/name.native`; `appearanceMsg` / `chromeMsg` naming an arm with that channel's record shape; `envMsgs` entries targeting one-`Uint8Array`-field arms; and persistence ok/none routes naming void arms while err names a one-`Uint8Array`-field arm. Import canonical records from the SDK modules.",
|
||||
why: "The generated wiring builds host events, persistence restore results, model-derived theme/status/window declarations, and their typed callbacks structurally at build time; a wrong shape would otherwise surface as a Zig compile error inside generated code instead of a teaching diagnostic here.",
|
||||
class: "guarantee",
|
||||
},
|
||||
NS1034: {
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface FrontendOptions {
|
||||
/// Generated @native-sdk/core surface carrying declared SQLite query
|
||||
/// constructors. Omitted for non-relational apps and direct checker tests.
|
||||
readonly sdkCorePath?: string;
|
||||
/// Statically compiled secondary-window view labels discovered from direct
|
||||
/// `src/windows/<label>.native` children. Undefined keeps core-only checker
|
||||
/// use independent of an app tree; an empty set means app validation is
|
||||
/// active and the app supplies no secondary-window roots.
|
||||
readonly windowViews?: readonly string[];
|
||||
}
|
||||
|
||||
export interface FrontendResult {
|
||||
@@ -204,6 +209,7 @@ export function checkFile(entry: string, options: FrontendOptions = {}): Fronten
|
||||
options.permissions ?? [],
|
||||
options.persistRoutes,
|
||||
options.sdkCorePath,
|
||||
options.windowViews,
|
||||
);
|
||||
const checkResult = checker.check();
|
||||
if (checkResult.diagnostics.length > 0) {
|
||||
|
||||
@@ -549,6 +549,30 @@ export class IntInference {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `windows(model)` returns host-consumed geometry. Those number slots
|
||||
// are canvas points, so whole-valued descriptors must not accidentally
|
||||
// specialize the canonical WindowDescriptor fields to i64 — another
|
||||
// model may return fractional positions or dimensions through the same
|
||||
// SDK record. Mark the descriptor's immediate number fields as boundary
|
||||
// values, exactly like a host-constructed event record in reverse.
|
||||
if (stmt.name?.text === "windows" && stmt.type) {
|
||||
const returned = this.table.resolveTypeNode(stmt.type);
|
||||
if (returned.k === "slice" && returned.elem.k === "struct") {
|
||||
const descriptor = this.table.structs.get(returned.elem.name);
|
||||
if (descriptor) {
|
||||
for (const f of descriptor.fields) {
|
||||
const numeric = f.type.k === "number" || (f.type.k === "optional" && f.type.inner.k === "number");
|
||||
if (!numeric) continue;
|
||||
const slot = this.slots.get(f.decl);
|
||||
if (slot) {
|
||||
slot.external = true;
|
||||
slot.hostBoundary = true;
|
||||
slot.proven = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { asciiBytes, utf8Bytes } from "../sdk/core.ts";
|
||||
import { asciiBytes, utf8Bytes, windowDescriptor } from "../sdk/core.ts";
|
||||
|
||||
test("asciiBytes returns exact ASCII and rejects Unicode", () => {
|
||||
assert.deepEqual([...asciiBytes("app.refresh")], [...new TextEncoder().encode("app.refresh")]);
|
||||
@@ -22,3 +22,19 @@ test("utf8Bytes matches TextEncoder for BMP, astral, and lone-surrogate text", (
|
||||
assert.deepEqual([...utf8Bytes(value)], [...new TextEncoder().encode(value)], JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("windowDescriptor fills canonical window defaults", () => {
|
||||
const descriptor = windowDescriptor({
|
||||
label: asciiBytes("settings"),
|
||||
canvasLabel: asciiBytes("settings-canvas"),
|
||||
titlebar: "chromeless",
|
||||
closePolicy: "hide",
|
||||
});
|
||||
assert.deepEqual([...descriptor.label], [...asciiBytes("settings")]);
|
||||
assert.equal(descriptor.width, 480);
|
||||
assert.equal(descriptor.height, 360);
|
||||
assert.equal(descriptor.resizable, true);
|
||||
assert.equal(descriptor.titlebar, "chromeless");
|
||||
assert.equal(descriptor.closePolicy, "hide");
|
||||
assert.equal(descriptor.onCloseCommand.length, 0);
|
||||
});
|
||||
|
||||
@@ -1267,6 +1267,240 @@ export function statusItems(model: Model): readonly StatusItemDescriptor[] { ret
|
||||
assert.ok(ruleIds(mutuallyExclusive).includes("NS1033"), `got ${ruleIds(mutuallyExclusive)}`);
|
||||
});
|
||||
|
||||
test("NS1033 windows is the canonical model-declared secondary-window collection", () => {
|
||||
const cleanSource = `
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas"), titlebar: "chromeless", transparent: true, closePolicy: "hide", onCloseCommand: asciiBytes("settings.closed") })];
|
||||
}
|
||||
`;
|
||||
const clean = check(cleanSource, { windowViews: ["settings"] });
|
||||
assert.equal(clean.ok, true, clean.diagnostics.map((d) => d.message).join("\n"));
|
||||
assert.ok(!ruleIds(clean).includes("NS1033"), `got ${ruleIds(clean)}`);
|
||||
|
||||
const missingView = check(cleanSource, { windowViews: [] });
|
||||
assert.equal(missingView.ok, false);
|
||||
assert.match(missingView.diagnostics.map((d) => d.message).join("\n"), /src\/windows\/settings\.native/);
|
||||
|
||||
const mismatchedView = check(cleanSource, { windowViews: ["preferences"] });
|
||||
assert.equal(mismatchedView.ok, false);
|
||||
assert.match(mismatchedView.diagnostics.map((d) => d.message).join("\n"), /src\/windows\/settings\.native/);
|
||||
|
||||
const tooLongLabel = "a".repeat(65);
|
||||
const oversized = check(cleanSource.replaceAll("settings", tooLongLabel), { windowViews: [tooLongLabel] });
|
||||
assert.equal(oversized.ok, false);
|
||||
assert.match(oversized.diagnostics.map((d) => d.message).join("\n"), /at most 64 ASCII bytes/);
|
||||
|
||||
const missingCommandMapper = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [windowDescriptor({
|
||||
label: asciiBytes("settings"),
|
||||
canvasLabel: asciiBytes("settings-canvas"),
|
||||
onCloseCommand: asciiBytes("settings.closed"),
|
||||
})];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(missingCommandMapper.ok, false);
|
||||
assert.match(missingCommandMapper.diagnostics.map((d) => d.message).join("\n"), /requires `commandMsg/);
|
||||
|
||||
const hiddenWithoutMapper = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
return model.settingsOpen ? [windowDescriptor({
|
||||
label: asciiBytes("settings"),
|
||||
canvasLabel: asciiBytes("settings-canvas"),
|
||||
closePolicy: "hide",
|
||||
onCloseCommand: asciiBytes("unused.while.hidden"),
|
||||
})] : [];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(hiddenWithoutMapper.ok, true, hiddenWithoutMapper.diagnostics.map((d) => d.message).join("\n"));
|
||||
|
||||
const rootTypeReexport = check(`
|
||||
import { asciiBytes, windowDescriptor, type WindowDescriptor } from "@native-sdk/core";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
return [windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") })];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(rootTypeReexport.ok, false);
|
||||
assert.match(rootTypeReexport.typeErrors.join("\n"), /WindowDescriptor/);
|
||||
|
||||
const dynamicLabel = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; readonly label: Uint8Array; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false, label: asciiBytes("settings") }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [windowDescriptor({ label: model.label, canvasLabel: asciiBytes("settings-canvas") })];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(dynamicLabel.ok, false);
|
||||
assert.match(dynamicLabel.diagnostics.map((d) => d.message).join("\n"), /static `asciiBytes/);
|
||||
|
||||
const spreadSpec = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
const common = { canvasLabel: asciiBytes("settings-canvas") };
|
||||
return [windowDescriptor({ label: asciiBytes("settings"), ...common })];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(spreadSpec.ok, false);
|
||||
assert.match(spreadSpec.diagnostics.map((d) => d.message).join("\n"), /cannot spread fields after its literal label/);
|
||||
|
||||
const overwrittenLabel = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; readonly label: Uint8Array; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false, label: asciiBytes("missing") }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
const base = windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") });
|
||||
return [{ ...base, label: model.label }];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(overwrittenLabel.ok, false);
|
||||
assert.match(overwrittenLabel.diagnostics.map((d) => d.message).join("\n"), /flow directly from `windowDescriptor/);
|
||||
|
||||
const helperOverwrite = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; readonly label: Uint8Array; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false, label: asciiBytes("missing") }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
function unsafeWindow(model: Model): WindowDescriptor {
|
||||
const base = windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") });
|
||||
return { ...base, label: model.label };
|
||||
}
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [unsafeWindow(model)];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(helperOverwrite.ok, false);
|
||||
assert.match(helperOverwrite.diagnostics.map((d) => d.message).join("\n"), /flow directly from `windowDescriptor/);
|
||||
|
||||
const unrelatedConstructor = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; readonly label: Uint8Array; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false, label: asciiBytes("missing") }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
function unusedWindow(): WindowDescriptor {
|
||||
return windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") });
|
||||
}
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [{ ...unusedWindow(), label: model.label }];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(unrelatedConstructor.ok, false);
|
||||
assert.match(unrelatedConstructor.diagnostics.map((d) => d.message).join("\n"), /flow directly from `windowDescriptor/);
|
||||
|
||||
const factored = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
function settingsWindow(): WindowDescriptor {
|
||||
return windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") });
|
||||
}
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [settingsWindow()];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(factored.ok, true, factored.diagnostics.map((d) => d.message).join("\n"));
|
||||
|
||||
const constAlias = check(`
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
const common = { canvasLabel: asciiBytes("settings-canvas") };
|
||||
const window = windowDescriptor({
|
||||
...common,
|
||||
label: asciiBytes("settings"),
|
||||
});
|
||||
return [window];
|
||||
}
|
||||
`, { windowViews: ["settings"] });
|
||||
assert.equal(constAlias.ok, true, constAlias.diagnostics.map((d) => d.message).join("\n"));
|
||||
|
||||
const namespaced = checkFiles({
|
||||
"core.ts": `
|
||||
import * as shell from "./windows.ts";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] {
|
||||
if (!model.settingsOpen) return [];
|
||||
return [shell.settingsWindow()];
|
||||
}
|
||||
`,
|
||||
"windows.ts": `
|
||||
import { asciiBytes, windowDescriptor } from "@native-sdk/core";
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export function settingsWindow(): WindowDescriptor {
|
||||
return windowDescriptor({ label: asciiBytes("settings"), canvasLabel: asciiBytes("settings-canvas") });
|
||||
}
|
||||
`,
|
||||
}, { windowViews: ["settings"] });
|
||||
assert.equal(namespaced.ok, true, namespaced.diagnostics.map((d) => d.message).join("\n"));
|
||||
|
||||
const wrong = checkOnly(`
|
||||
export interface BadWindow { readonly label: Uint8Array; readonly closePolicy: boolean; }
|
||||
export interface Model { readonly settingsOpen: boolean; }
|
||||
export type Msg = { readonly kind: "open" } | { readonly kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly BadWindow[] { return []; }
|
||||
`);
|
||||
assert.ok(ruleIds(wrong).includes("NS1033"), `got ${ruleIds(wrong)}`);
|
||||
});
|
||||
|
||||
test("NS1061: value-record aliases refuse the shapes value storage cannot carry", () => {
|
||||
// The model root is reference storage by contract.
|
||||
const root = checkOnly(`
|
||||
|
||||
@@ -221,6 +221,22 @@ export function statusItems(model: Model): readonly StatusItemDescriptor[] { ret
|
||||
assert.ok(structs.includes("StatusItemModifiers"), `structs: ${structs.join(", ")}`);
|
||||
});
|
||||
|
||||
test("windows is projected as a launcher-bound descriptor slice", () => {
|
||||
const doc = contractOf(`
|
||||
import { type WindowDescriptor } from "@native-sdk/core/events";
|
||||
export interface Model { settingsOpen: boolean; }
|
||||
export type Msg = { kind: "open" } | { kind: "closed" };
|
||||
export function initialModel(): Model { return { settingsOpen: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function windows(model: Model): readonly WindowDescriptor[] { return []; }
|
||||
`);
|
||||
const helpers = doc.model_helpers as { name: string; returns: unknown }[];
|
||||
assert.deepEqual(helpers.map((helper) => helper.name), ["windows"]);
|
||||
assert.deepEqual(doc.model_unbound, ["windows"]);
|
||||
const structs = (doc.types as { structs: { name: string }[] }).structs.map((record) => record.name);
|
||||
assert.ok(structs.includes("WindowDescriptor"), `structs: ${structs.join(", ")}`);
|
||||
});
|
||||
|
||||
test("Cmd.fetch accepts a line-stream route with bytes and status arms", () => {
|
||||
const doc = contractOf(`
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
|
||||
@@ -31,7 +31,7 @@ if (process.env.SCRIPTC_TARGET !== "x86_64-windows-gnu") { console.error("wrong
|
||||
if (!(process.env.PATH ?? "").startsWith(${JSON.stringify(zigDir)})) { console.error("zig directory missing from PATH front"); process.exit(9); }
|
||||
const output = process.argv[process.argv.indexOf("-o") + 1];
|
||||
fs.writeFileSync(output + ".lib.a", "target archive bytes");
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "cross-target" }));
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "cross-target", model_unbound: [], msg: { unbound: [] } }));
|
||||
`);
|
||||
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "run_external_core_compiler.mjs");
|
||||
const archive = path.join(root, "libfixture_core.a");
|
||||
@@ -216,7 +216,7 @@ if (process.env.SCRIPTC_CC !== "zigcc") { console.error("mobile compile missing
|
||||
if (process.env.SCRIPTC_TARGET !== "aarch64-apple-ios-simulator") { console.error("mobile compile got SCRIPTC_TARGET=" + process.env.SCRIPTC_TARGET); process.exit(9); }
|
||||
const output = process.argv[process.argv.indexOf("-o") + 1];
|
||||
fs.writeFileSync(output + ".lib.a", "ios simulator archive bytes");
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "ios-simulator" }));
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "ios-simulator", model_unbound: [], msg: { unbound: [] } }));
|
||||
`);
|
||||
const archive = path.join(root, "libfixture_core.a");
|
||||
const env = { ...process.env };
|
||||
@@ -263,7 +263,7 @@ if (process.argv.includes("-v")) { console.log("0.0.29"); process.exit(0); }
|
||||
if (process.env.SCRIPTC_CC !== undefined || process.env.SCRIPTC_TARGET !== undefined) { console.error("native compile received cross environment"); process.exit(9); }
|
||||
const output = process.argv[process.argv.indexOf("-o") + 1];
|
||||
fs.writeFileSync(output + ".lib.a", "native msvc archive bytes");
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "native-msvc" }));
|
||||
fs.writeFileSync("core.contract.json", JSON.stringify({ build_id: "native-msvc", model_unbound: [], msg: { unbound: [] } }));
|
||||
`);
|
||||
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "run_external_core_compiler.mjs");
|
||||
const archive = path.join(root, "libfixture_core.a");
|
||||
|
||||
Reference in New Issue
Block a user