413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|