Files
forma-engine/editor/BuildPanel.tsx
T

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(connected ? "linux" : "web"),
[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>
);
}