Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
import React, { useEffect, useState, useRef } from "react";
import {
Monitor,
Smartphone,
Globe,
Download,
Check,
LoaderCircle,
Square,
} from "lucide-react";
import type { Project } from "../engine/schema.ts";
import { gameArchive, download } from "../engine/archive.ts";
import { buildKit } from "../engine/build-kit.ts";
import { normalizeOptions } from "../native/options.mjs";
const labels: Record<string, string> = {
web: "Веб",
linux: "Linux",
windows: "Windows",
android: "Android",
};
const states: Record<string, string> = {
queued: "В очереди",
building: "Собирается",
succeeded: "Готово",
failed: "Ошибка",
cancelled: "Отменено",
};
async function api(url: string, data?: unknown) {
const r = await fetch(url, {
method: data ? "POST" : "GET",
headers: data ? { "content-type": "application/json" } : undefined,
body: data ? JSON.stringify(data) : undefined,
});
const d: any = await r.json();
if (!r.ok) throw Error(d.error || "Ошибка сборки");
return d;
}
export function BuildPanel({
project,
connected,
}: {
project: Project;
connected: boolean;
}) {
const [target, setTarget] = useState("linux"),
[options, setOptions] = useState(() => {
try {
return normalizeOptions(
JSON.parse(
localStorage.getItem("forma-build-" + project.id) || "null",
) || { name: project.name },
);
} catch {
return { ...normalizeOptions(), name: project.name };
}
});
const [cap, setCap] = useState<any>(null),
[jobs, setJobs] = useState<any[]>([]),
[selected, setSelected] = useState<string | null>(null),
[error, setError] = useState(""),
[working, setWorking] = useState(false);
useEffect(() => {
try {
localStorage.setItem(
"forma-build-" + project.id,
JSON.stringify({
...options,
target: target === "web" ? "linux" : target,
}),
);
} catch {}
}, [project.id, options, target]);
const [notice, setNotice] = useState("");
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const option = (key: string, value: any) =>
setOptions((o) => ({ ...o, [key]: value }));
useEffect(() => {
if (!connected) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try {
const d = await api("/api/builds");
if (!cancelled) setJobs(d.jobs);
} catch (e) {
if (!cancelled) setError(String(e));
}
if (!cancelled) timer = setTimeout(poll, 1600);
};
void api("/api/builds/capabilities")
.then((d) => {
if (!cancelled) setCap(d);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
void poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [connected]);
const current = jobs.find((j) => j.id === selected) || jobs[0];
const ready = target === "web" || Boolean(cap?.targets?.[target]?.ready);
const start = async (kit = false) => {
setError("");
setNotice("");
setWorking(true);
try {
if (target === "web") {
download(
await gameArchive(project),
project.name + "-web.zip",
"application/zip",
);
if (alive.current)
setNotice(
"Веб-сборка скачана. Распакуйте ZIP и разместите на HTTP-хостинге.",
);
} else {
const o = normalizeOptions({ ...options, target });
if (kit) {
download(
await buildKit(project, o),
o.appId + "-" + target + "-build-kit.zip",
"application/zip",
);
if (alive.current)
setNotice(
"Комплект скачан. Команды сборки находятся в README.txt.",
);
} else {
const j = await api("/api/builds", {
options: o,
expectedRevision: project.revision,
});
if (alive.current) {
setJobs((js) => [j, ...js]);
setSelected(j.id);
}
}
}
} catch (e) {
if (alive.current) setError(String(e));
} finally {
if (alive.current) setWorking(false);
}
};
return (
<div className="build-panel">
<nav className="build-platforms" aria-label="Платформа сборки">
{["web", "linux", "windows", "android"].map((t) => (
<button
key={t}
className={target === t ? "selected" : ""}
aria-pressed={target === t}
onClick={() => {
setTarget(t);
setError("");
}}
>
{t === "android" ? (
<Smartphone size={20} />
) : t === "web" ? (
<Globe size={20} />
) : (
<Monitor size={20} />
)}
<strong>{labels[t]}</strong>
<small>
{
{
web: "HTML + ресурсы",
linux: "AppImage · x64",
windows: "Portable EXE · x64",
android: "APK · Android 8+",
}[t]
}
</small>
</button>
))}
</nav>
{target !== "web" && (
<>
<div className="build-fields">
<label>
Название игры
<input
value={options.name}
maxLength={80}
onChange={(e) => option("name", e.target.value)}
/>
</label>
<label>
ID приложения
<input
value={options.appId}
spellCheck={false}
placeholder="games.studio.mygame"
onChange={(e) => option("appId", e.target.value)}
/>
</label>
<label>
Версия
<input
value={options.version}
placeholder="1.0.0"
onChange={(e) => option("version", e.target.value)}
/>
</label>
<label>
Режим
<select
value={options.mode}
onChange={(e) => option("mode", e.target.value)}
>
<option value="debug">Тестовая сборка</option>
<option value="release">Релизная сборка</option>
</select>
</label>
{target === "android" ? (
<>
<label>
Номер версии
<input
type="number"
min="1"
max="2100000000"
value={options.versionCode}
onChange={(e) =>
option("versionCode", Number(e.target.value))
}
/>
</label>
<label>
Ориентация
<select
value={options.orientation}
onChange={(e) => option("orientation", e.target.value)}
>
<option value="landscape">Горизонтальная</option>
<option value="portrait">Вертикальная</option>
<option value="sensor">По повороту устройства</option>
</select>
</label>
</>
) : (
<>
<label>
Ширина окна
<input
type="number"
min="320"
max="7680"
value={options.width}
onChange={(e) => option("width", Number(e.target.value))}
/>
</label>
<label>
Высота окна
<input
type="number"
min="320"
max="7680"
value={options.height}
onChange={(e) => option("height", Number(e.target.value))}
/>
</label>
<label className="build-check">
<input
type="checkbox"
checked={options.fullscreen}
onChange={(e) => option("fullscreen", e.target.checked)}
/>
Полный экран при запуске
</label>
</>
)}
</div>
<div className="build-note">
{!connected
? "Для создания приложения запустите локальную Forma на компьютере. Здесь можно скачать комплект с игрой и командами сборки."
: !cap
? "Проверка инструментов сборки…"
: ready
? "Сборка выполняется на этом компьютере. Первой сборке нужен интернет для загрузки инструментов."
: "Инструменты ещё не установлены: " +
cap.targets[target].missing.join("; ")}
</div>
{target === "android" && (
<p className="build-note">
{options.mode === "debug"
? "Тестовый APK подписывается автоматически и подходит для установки на телефон."
: "Релизный APK требует вашего ключа подписи. Задайте FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS и FORMA_KEY_PASSWORD в окружении локального сервера."}
</p>
)}
{target === "windows" && (
<p className="build-note">
EXE собирается без цифровой подписи издателя.
</p>
)}
</>
)}
<div className="build-actions">
<button
className="primary"
disabled={
working ||
(target !== "web" && (!connected || !ready)) ||
(target === "android" &&
options.mode === "release" &&
!cap?.targets.android.releaseSigningConfigured)
}
onClick={() => void start()}
>
{working ? (
<LoaderCircle size={16} className="spin" />
) : (
<Download size={16} />
)}{" "}
{target === "web"
? "Скачать веб-сборку"
: "Собрать " +
({ linux: "AppImage", windows: "EXE", android: "APK" } as any)[
target
]}
</button>
{target !== "web" && (
<button disabled={working} onClick={() => void start(true)}>
Скачать комплект сборки
</button>
)}
<small>Ревизия проекта {project.revision}</small>
</div>
{error && (
<p className="build-error" role="alert">
{error}
</p>
)}
{notice && (
<p className="build-note" role="status">
{notice}
</p>
)}
{connected && jobs.length > 0 && (
<section className="build-history">
<div className="build-history-title">
<strong>Сборки проекта</strong>
<select
aria-label="Выбрать сборку"
value={current?.id || ""}
onChange={(e) => setSelected(e.target.value)}
>
{jobs.map((j) => (
<option key={j.id} value={j.id}>
{labels[j.options.target]} · {j.options.version} ·{" "}
{states[j.status]} · r{j.revision}
</option>
))}
</select>
</div>
{current && (
<>
<div className="build-status">
<span>
{current.status === "succeeded" ? (
<Check size={16} />
) : ["queued", "building"].includes(current.status) ? (
<LoaderCircle className="spin" size={16} />
) : null}
{states[current.status]} ·{" "}
{new Date(current.createdAt).toLocaleTimeString()} · r
{current.revision}
</span>
{["queued", "building"].includes(current.status) && (
<button
onClick={() =>
void api("/api/builds/cancel", { id: current.id }).catch(
(e) => setError(String(e)),
)
}
>
<Square size={13} /> Отменить
</button>
)}
</div>
<pre className="build-log" aria-label="Журнал сборки">
{current.logs.join("\n")}
</pre>
{current.error && <p className="build-error">{current.error}</p>}
{current.artifacts?.map((f: any) => (
<div className="build-artifact" key={f.name}>
<a href={f.url} download>
{f.name}{" "}
<span>{(f.bytes / 1024 / 1024).toFixed(1)} МБ</span>
</a>
<small>SHA-256: {f.sha256}</small>
</div>
))}
</>
)}
</section>
)}
</div>
);
}