menloapp 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +120 -0
- package/bin/tohseno.js +59 -0
- package/package.json +35 -0
- package/src/archive.js +88 -0
- package/src/cli.js +54 -0
- package/src/constants.js +12 -0
- package/src/deploy-ui.js +51 -0
- package/src/download.js +66 -0
- package/src/github.js +297 -0
- package/src/installer.js +170 -0
- package/src/manifest.js +105 -0
- package/src/native.js +32 -0
- package/src/presentation.js +57 -0
- package/src/project-presentation.js +49 -0
- package/src/record.js +79 -0
- package/src/semver.js +16 -0
- package/src/start.js +9 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const PRESENTATION_PATH = "menloapp/app.json";
|
|
2
|
+
export const MAX_PRESENTATION_BYTES = 16 * 1024;
|
|
3
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
export const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
function text(value, field, maximum, required = false) {
|
|
7
|
+
if (typeof value !== "string" || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value) || required && !value.trim()) {
|
|
8
|
+
throw new Error(`menloapp/app.json: ${field} must be ${required ? "nonempty " : ""}text of at most ${maximum} characters.`);
|
|
9
|
+
}
|
|
10
|
+
return value.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mediaPath(value, video = false) {
|
|
14
|
+
if (typeof value !== "string" || value.length > 240 || !/^menloapp\/[A-Za-z0-9_./-]+$/.test(value) || value.split("/").some(part => !part || part === "." || part === "..") || !(video ? /\.mp4$/ : /\.(png|jpe?g)$/).test(value)) {
|
|
15
|
+
throw new Error(`Use a path inside menloapp/ ending in ${video ? ".mp4" : ".png or .jpg"}; URLs and parent paths are not supported.`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Public presentation is deliberately small; build and identity are derived elsewhere. */
|
|
21
|
+
export function validatePresentation(value) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1) throw new Error("menloapp/app.json requires version: 1.");
|
|
23
|
+
for (const key of Object.keys(value)) if (!["version", "name", "subtitle", "description", "icon", "screenshots", "preview"].includes(key)) throw new Error(`Unknown menloapp/app.json field: ${key}`);
|
|
24
|
+
const name = text(value.name, "name", 100, true);
|
|
25
|
+
const subtitle = text(value.subtitle ?? "", "subtitle", 160);
|
|
26
|
+
const description = text(value.description ?? "", "description", 4000);
|
|
27
|
+
const icon = value.icon == null ? null : mediaPath(value.icon);
|
|
28
|
+
const screenshots = value.screenshots ?? [];
|
|
29
|
+
if (!Array.isArray(screenshots) || screenshots.length > 3 || new Set(screenshots).size !== screenshots.length) throw new Error("Choose up to three distinct screenshots in menloapp/app.json.");
|
|
30
|
+
let preview = null;
|
|
31
|
+
if (value.preview != null) {
|
|
32
|
+
const item = value.preview;
|
|
33
|
+
if (typeof item !== "object" || Array.isArray(item) || !["simulator", "device", "screen-recording"].includes(item.kind) || Object.keys(item).some(key => !["path", "kind", "source_commit"].includes(key))) throw new Error("Preview needs a path and kind: simulator, device, or screen-recording.");
|
|
34
|
+
if (item.source_commit != null && !/^[a-f0-9]{40}$/.test(item.source_commit)) throw new Error("Preview source_commit must be a full Git commit.");
|
|
35
|
+
preview = { path: mediaPath(item.path, true), kind: item.kind, ...(item.source_commit ? { source_commit: item.source_commit } : {}) };
|
|
36
|
+
}
|
|
37
|
+
return { version: 1, name, subtitle, description, icon, screenshots: screenshots.map(item => mediaPath(item)), preview };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function presentationFiles(value) {
|
|
41
|
+
return [...(value.icon ? [value.icon] : []), ...value.screenshots, ...(value.preview ? [value.preview.path] : [])];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mediaType(file) {
|
|
45
|
+
return file.endsWith(".mp4") ? "video/mp4" : file.endsWith(".png") ? "image/png" : "image/jpeg";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateMediaBytes(file, bytes) {
|
|
49
|
+
const type = mediaType(file);
|
|
50
|
+
const maximum = type === "video/mp4" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
51
|
+
if (!bytes.length || bytes.length > maximum) throw new Error(`${file} exceeds the ${maximum / 1024 / 1024} MiB limit or is empty.`);
|
|
52
|
+
const valid = type === "image/png" ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
53
|
+
: type === "image/jpeg" ? bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255
|
|
54
|
+
: bytes.length >= 12 && bytes.subarray(4, 8).toString("ascii") === "ftyp";
|
|
55
|
+
if (!valid) throw new Error(`${file} is not a supported ${type} file. Commit the actual media bytes, not a Git LFS pointer.`);
|
|
56
|
+
return type;
|
|
57
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { PRESENTATION_PATH, MAX_PRESENTATION_BYTES, MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, validatePresentation, presentationFiles, validateMediaBytes } from "./presentation.js";
|
|
5
|
+
|
|
6
|
+
export function projectRoot(directory) {
|
|
7
|
+
try { return execFileSync("git", ["-C", directory, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
8
|
+
catch { throw new Error("Run menloapp from your app's Git repository."); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function setupPresentation(root, name = path.basename(root)) {
|
|
12
|
+
const directory = path.join(root, "menloapp");
|
|
13
|
+
await mkdir(directory, { recursive: true });
|
|
14
|
+
if (!(await lstat(directory)).isDirectory()) throw new Error("menloapp/ must be a real directory, not a symbolic link.");
|
|
15
|
+
try {
|
|
16
|
+
await writeFile(path.join(root, PRESENTATION_PATH), JSON.stringify(validatePresentation({ version: 1, name, subtitle: "", description: "", icon: null, screenshots: [], preview: null }), null, 2) + "\n", { flag: "wx" });
|
|
17
|
+
} catch (error) { if (error.code === "EEXIST") return false; throw error; }
|
|
18
|
+
const guide = `# Your public app page\n\nEdit app.json, then commit and push this folder with your app.\nMENLO reads it and the selected assets from the same public Git commit.\n\nAdd your real icon and up to three screenshots (PNG/JPEG, at most 10 MiB each).\nSet icon to "menloapp/icon.png" and screenshots to paths such as\n["menloapp/screenshot-1.png", "menloapp/screenshot-2.png", "menloapp/screenshot-3.png"].\nNo sample artwork is published as if it were your app.\n\nRun menloapp deploy --record to build and launch this app in an already booted\nSimulator and record a short walkthrough. Review the recording, commit and push,\nthen deploy again. To use your own MP4 (at most 50 MiB), set preview to\n{"path":"menloapp/preview.mp4","kind":"screen-recording"}.\n\nUse normal Git files, not Git LFS pointers. Never put secrets or private captures\nin this public folder. If you install menloapp as a local npm dependency, ignore /node_modules/ in your\nproject .gitignore and commit package.json plus the lockfile.\nAn npm install runs no setup or upload scripts.\n`;
|
|
19
|
+
try { await writeFile(path.join(directory, "README.md"), guide, { flag: "wx" }); }
|
|
20
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readCommittedPresentation(root, commit) {
|
|
25
|
+
const git = (...args) => execFileSync("git", ["-C", root, ...args], { maxBuffer: MAX_VIDEO_BYTES + 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
26
|
+
const entries = new Map(git("ls-tree", "-r", "-z", commit, "--", "menloapp/").toString("utf8").split("\0").filter(Boolean).map(line => { const [info, file] = line.split("\t"); return [file, info.split(" ")[0]]; }));
|
|
27
|
+
if (!entries.has(PRESENTATION_PATH)) return null;
|
|
28
|
+
const read = (file, maximum) => {
|
|
29
|
+
if (!["100644", "100755"].includes(entries.get(file))) throw new Error(`${file} must be a committed regular file; links are not supported.`);
|
|
30
|
+
const size = Number(git("cat-file", "-s", `${commit}:${file}`).toString());
|
|
31
|
+
if (size > maximum) throw new Error(`${file} exceeds its supported size.`);
|
|
32
|
+
return git("show", `${commit}:${file}`);
|
|
33
|
+
};
|
|
34
|
+
let value;
|
|
35
|
+
try { value = JSON.parse(read(PRESENTATION_PATH, MAX_PRESENTATION_BYTES).toString("utf8")); }
|
|
36
|
+
catch (error) { throw new Error(`Cannot read ${PRESENTATION_PATH}: ${error.message}`); }
|
|
37
|
+
const presentation = validatePresentation(value);
|
|
38
|
+
for (const file of presentationFiles(presentation)) validateMediaBytes(file, read(file, file.endsWith(".mp4") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES));
|
|
39
|
+
return presentation;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function initPresentation(args) {
|
|
43
|
+
if (args.includes("--help")) { console.log("menloapp init [path]\n\nCreate menloapp/app.json and instructions for your public app page."); return 0; }
|
|
44
|
+
if (args.length > 1 || args[0]?.startsWith("-")) throw new Error("Usage: menloapp init [path]");
|
|
45
|
+
const root = projectRoot(args[0] || ".");
|
|
46
|
+
const created = await setupPresentation(root);
|
|
47
|
+
console.log(`${created ? "Created" : "Keeping existing"} ${PRESENTATION_PATH}.\nAdd your app's name, subtitle, description, icon and up to three screenshots.\nReview, commit and push menloapp/, then run menloapp deploy.`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
package/src/record.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile, copyFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { PRESENTATION_PATH, validatePresentation, validateMediaBytes } from "./presentation.js";
|
|
7
|
+
|
|
8
|
+
export function selectSimulator(devices, requested) {
|
|
9
|
+
const booted = Object.entries(devices).filter(([runtime]) => /ios/i.test(runtime)).flatMap(([, items]) => items).filter(device => device.state === "Booted" && device.isAvailable !== false && (device.deviceTypeIdentifier?.includes(".iPhone-") || device.name?.startsWith("iPhone")));
|
|
10
|
+
const selected = requested ? booted.filter(device => device.udid === requested) : booted;
|
|
11
|
+
if (selected.length !== 1) throw new Error("Open your intended iPhone Simulator first. If several are running, choose --simulator UDID (xcrun simctl list devices booted).");
|
|
12
|
+
return selected[0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function build(args, directory, log) {
|
|
16
|
+
log("Building your app for Simulator. This can take several minutes; recording starts after the app launches.");
|
|
17
|
+
await new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn("xcodebuild", args, { cwd: directory, stdio: ["ignore", "inherit", "inherit"] });
|
|
19
|
+
const timer = setTimeout(() => { child.kill("SIGTERM"); }, 5 * 60_000);
|
|
20
|
+
child.once("error", error => { clearTimeout(timer); reject(error); });
|
|
21
|
+
child.once("exit", code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error("Simulator build did not finish successfully. Fix the Xcode error, then run menloapp deploy --record again.")); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function recordExperience(root, project, options, ui) {
|
|
26
|
+
if (process.platform !== "darwin") throw new Error("Recording requires macOS, Xcode, and a running iPhone Simulator.");
|
|
27
|
+
const run = (args) => execFileSync("xcrun", ["simctl", ...args], { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
28
|
+
const device = selectSimulator(JSON.parse(run(["list", "devices", "booted", "--json"])).devices, options.simulator);
|
|
29
|
+
const destination = path.join(root, "menloapp", `preview-${project.commit.slice(0, 12)}-${Date.now()}.mp4`);
|
|
30
|
+
const manifestPath = path.join(root, PRESENTATION_PATH);
|
|
31
|
+
if (!(await lstat(path.dirname(manifestPath))).isDirectory() || !(await lstat(manifestPath)).isFile()) throw new Error("The public presentation must use regular files, not links.");
|
|
32
|
+
const before = await readFile(manifestPath, "utf8");
|
|
33
|
+
const presentation = validatePresentation(JSON.parse(before));
|
|
34
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), "menloapp-record-"));
|
|
35
|
+
try {
|
|
36
|
+
const source = path.join(temporary, "source");
|
|
37
|
+
await mkdir(source);
|
|
38
|
+
const archive = path.join(temporary, "source.tar");
|
|
39
|
+
execFileSync("git", ["-C", root, "archive", "--format=tar", "--output", archive, project.commit], { timeout: 30_000 });
|
|
40
|
+
execFileSync("tar", ["-xf", archive, "-C", source], { timeout: 30_000 });
|
|
41
|
+
const args = [project.project.endsWith(".xcworkspace") ? "-workspace" : "-project", path.join(source, project.project), "-scheme", project.scheme, "-configuration", "Debug", "-sdk", "iphonesimulator", "-destination", `id=${device.udid}`, "-derivedDataPath", path.join(temporary, "build"), "CODE_SIGNING_ALLOWED=NO"];
|
|
42
|
+
await build([...args, "build"], source, ui.log);
|
|
43
|
+
const settings = JSON.parse(execFileSync("xcodebuild", [...args, "-showBuildSettings", "-json"], { cwd: source, encoding: "utf8", timeout: 60_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }));
|
|
44
|
+
const apps = settings.map(item => item.buildSettings).filter(item => item.WRAPPER_EXTENSION === "app" && item.PRODUCT_BUNDLE_IDENTIFIER);
|
|
45
|
+
if (apps.length !== 1) throw new Error("The scheme builds several apps. Select a scheme that builds just the app you want to record.");
|
|
46
|
+
const app = apps[0];
|
|
47
|
+
run(["install", device.udid, path.join(app.TARGET_BUILD_DIR, app.FULL_PRODUCT_NAME)]);
|
|
48
|
+
run(["launch", "--terminate-running-process", device.udid, app.PRODUCT_BUNDLE_IDENTIFIER]);
|
|
49
|
+
spawn("open", ["-a", "Simulator"], { stdio: "ignore" }).unref();
|
|
50
|
+
ui.log(`Recording ${project.name} in ${device.name} for ${options.seconds} seconds. Use the app now; only this Simulator's screen is recorded.`);
|
|
51
|
+
const movie = path.join(temporary, "preview.mp4");
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
const child = spawn("xcrun", ["simctl", "io", device.udid, "recordVideo", "--codec=h264", movie], { stdio: ["ignore", "ignore", "pipe"] });
|
|
54
|
+
let timer;
|
|
55
|
+
let started = false;
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
let diagnostic = "";
|
|
58
|
+
const cancel = () => { cancelled = true; child.kill("SIGINT"); };
|
|
59
|
+
process.once("SIGINT", cancel);
|
|
60
|
+
const deadline = setTimeout(() => child.kill("SIGKILL"), (options.seconds + 30) * 1000);
|
|
61
|
+
child.stderr.on("data", chunk => {
|
|
62
|
+
diagnostic = (diagnostic + chunk.toString()).slice(-1024);
|
|
63
|
+
if (!started && diagnostic.includes("Recording started")) { started = true; timer = setTimeout(() => child.kill("SIGINT"), options.seconds * 1000); }
|
|
64
|
+
});
|
|
65
|
+
const cleanup = () => { clearTimeout(timer); clearTimeout(deadline); process.removeListener("SIGINT", cancel); };
|
|
66
|
+
child.once("error", error => { cleanup(); reject(error); });
|
|
67
|
+
child.once("exit", code => { cleanup(); !cancelled && started && code === 0 ? resolve() : reject(new Error("Recording did not finish. Your public presentation was not changed.")); });
|
|
68
|
+
});
|
|
69
|
+
const bytes = await readFile(movie);
|
|
70
|
+
validateMediaBytes(destination, bytes);
|
|
71
|
+
if (await readFile(manifestPath, "utf8") !== before) throw new Error("app.json changed while recording. Run recording again after saving your edits.");
|
|
72
|
+
await copyFile(movie, destination, constants.COPYFILE_EXCL);
|
|
73
|
+
// Recording describes the source that was actually built, even when a later
|
|
74
|
+
// commit adds the resulting video to the public presentation.
|
|
75
|
+
presentation.preview = { path: path.relative(root, destination), kind: "simulator", source_commit: project.commit };
|
|
76
|
+
await writeFile(manifestPath, JSON.stringify(presentation, null, 2) + "\n");
|
|
77
|
+
ui.print(`Saved ${presentation.preview.path}\nReview the video, commit and push menloapp/, then run menloapp deploy to share it.\nThis is a Simulator recording; it is not evidence of an iPhone installation.`);
|
|
78
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
79
|
+
}
|
package/src/semver.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const EXACT = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
2
|
+
|
|
3
|
+
export function parseVersion(value) {
|
|
4
|
+
const match = EXACT.exec(value);
|
|
5
|
+
if (!match) throw new Error("version must be an exact stable semantic version");
|
|
6
|
+
return match.slice(1).map(Number);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function compareVersions(left, right) {
|
|
10
|
+
const a = parseVersion(left);
|
|
11
|
+
const b = parseVersion(right);
|
|
12
|
+
for (let index = 0; index < 3; index += 1) {
|
|
13
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
package/src/start.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { delegate } from "./native.js";
|
|
2
|
+
|
|
3
|
+
export function startProduct(run = delegate, write = console.log) {
|
|
4
|
+
write("Starting TOHSENO…");
|
|
5
|
+
const service = run(["service", "install"]);
|
|
6
|
+
if (service !== 0) return service;
|
|
7
|
+
write("Opening TOHSENO…");
|
|
8
|
+
return run(["studio"]);
|
|
9
|
+
}
|