Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user