Refresh host UI and show app version
- Add version display to the host shell - Improve log rendering and refresh timing - Tidy layout, styling, and window config
This commit is contained in:
parent
9610e3512d
commit
c3531a5839
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@ docus/.nuxt/
|
|||||||
docus/.output/
|
docus/.output/
|
||||||
docus/.data/
|
docus/.data/
|
||||||
docus/.nitro/
|
docus/.nitro/
|
||||||
|
bin/launcher/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "FORGE Host",
|
"productName": "FORGE Host",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.idsolutions.forge.host",
|
"identifier": "com.idsolutions.forge.host",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "FORGE Host",
|
"title": "FORGE Host",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"minWidth": 980,
|
"minWidth": 980,
|
||||||
"minHeight": 640
|
"minHeight": 640,
|
||||||
}
|
"maximizable": false
|
||||||
],
|
}
|
||||||
"security": {
|
],
|
||||||
"csp": null
|
"security": {
|
||||||
}
|
"csp": null
|
||||||
},
|
}
|
||||||
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
|||||||
@ -6,12 +6,14 @@ const serviceNames = {
|
|||||||
|
|
||||||
const subtitles = {
|
const subtitles = {
|
||||||
overview: "Start, stop, and check local Forge hosting services.",
|
overview: "Start, stop, and check local Forge hosting services.",
|
||||||
settings: "Edit process command, arguments, working directory, and health port.",
|
settings:
|
||||||
|
"Edit process command, arguments, working directory, and health port.",
|
||||||
logs: "Recent stdout, stderr, and host supervisor events.",
|
logs: "Recent stdout, stderr, and host supervisor events.",
|
||||||
};
|
};
|
||||||
|
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
let lastRenderedLogText = "";
|
||||||
let activeSettingsService = "surrealdb";
|
let activeSettingsService = "surrealdb";
|
||||||
let configDirty = false;
|
let configDirty = false;
|
||||||
let activeEditorPath = "";
|
let activeEditorPath = "";
|
||||||
@ -28,7 +30,10 @@ document.querySelectorAll(".nav-button").forEach((button) => {
|
|||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const view = button.dataset.view;
|
const view = button.dataset.view;
|
||||||
if (button.dataset.service) {
|
if (button.dataset.service) {
|
||||||
if (configDirty && button.dataset.service !== activeSettingsService) {
|
if (
|
||||||
|
configDirty &&
|
||||||
|
button.dataset.service !== activeSettingsService
|
||||||
|
) {
|
||||||
persistActiveSettingsForm();
|
persistActiveSettingsForm();
|
||||||
}
|
}
|
||||||
activeSettingsService = button.dataset.service;
|
activeSettingsService = button.dataset.service;
|
||||||
@ -38,9 +43,13 @@ document.querySelectorAll(".nav-button").forEach((button) => {
|
|||||||
}
|
}
|
||||||
activeSettingsService = "surrealdb";
|
activeSettingsService = "surrealdb";
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".nav-button").forEach((item) => item.classList.remove("active"));
|
document
|
||||||
|
.querySelectorAll(".nav-button")
|
||||||
|
.forEach((item) => item.classList.remove("active"));
|
||||||
button.classList.add("active");
|
button.classList.add("active");
|
||||||
Object.entries(views).forEach(([key, element]) => element.classList.toggle("active", key === view));
|
Object.entries(views).forEach(([key, element]) =>
|
||||||
|
element.classList.toggle("active", key === view),
|
||||||
|
);
|
||||||
setHeader(view);
|
setHeader(view);
|
||||||
renderSettings(true);
|
renderSettings(true);
|
||||||
syncBulkActionsVisibility();
|
syncBulkActionsVisibility();
|
||||||
@ -52,9 +61,29 @@ document.getElementById("refreshButton").addEventListener("click", refresh);
|
|||||||
document.getElementById("saveButton").addEventListener("click", saveConfig);
|
document.getElementById("saveButton").addEventListener("click", saveConfig);
|
||||||
document.getElementById("startAllButton").addEventListener("click", startAll);
|
document.getElementById("startAllButton").addEventListener("click", startAll);
|
||||||
document.getElementById("stopAllButton").addEventListener("click", stopAll);
|
document.getElementById("stopAllButton").addEventListener("click", stopAll);
|
||||||
document.getElementById("editorCloseButton").addEventListener("click", closeConfigEditor);
|
document
|
||||||
document.getElementById("editorReloadButton").addEventListener("click", () => runAction(() => openConfigEditor(activeEditorPath)));
|
.getElementById("editorCloseButton")
|
||||||
document.getElementById("editorSaveButton").addEventListener("click", () => runAction(saveConfigEditor));
|
.addEventListener("click", closeConfigEditor);
|
||||||
|
document
|
||||||
|
.getElementById("editorReloadButton")
|
||||||
|
.addEventListener("click", () =>
|
||||||
|
runAction(() => openConfigEditor(activeEditorPath)),
|
||||||
|
);
|
||||||
|
document
|
||||||
|
.getElementById("editorSaveButton")
|
||||||
|
.addEventListener("click", () => runAction(saveConfigEditor));
|
||||||
|
|
||||||
|
async function loadAppVersion() {
|
||||||
|
const getVersion = window.__TAURI__?.app?.getVersion;
|
||||||
|
if (!getVersion) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.getElementById("appVersion").textContent =
|
||||||
|
`v${await getVersion()}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Unable to load app version:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (isSettingsViewActive() && configDirty) {
|
if (isSettingsViewActive() && configDirty) {
|
||||||
@ -89,7 +118,9 @@ async function startAll() {
|
|||||||
|
|
||||||
const serviceNames = Object.keys(snapshot.config).filter(
|
const serviceNames = Object.keys(snapshot.config).filter(
|
||||||
(name) =>
|
(name) =>
|
||||||
snapshot.config[name].enabled && (name === "surrealdb" || snapshot.config[name].command.trim() !== "")
|
snapshot.config[name].enabled &&
|
||||||
|
(name === "surrealdb" ||
|
||||||
|
snapshot.config[name].command.trim() !== ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const name of serviceNames) {
|
for (const name of serviceNames) {
|
||||||
@ -106,7 +137,9 @@ async function startAll() {
|
|||||||
|
|
||||||
async function stopAll() {
|
async function stopAll() {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
const serviceNames = Object.keys(snapshot.config).filter((name) => snapshot.config[name].enabled);
|
const serviceNames = Object.keys(snapshot.config).filter(
|
||||||
|
(name) => snapshot.config[name].enabled,
|
||||||
|
);
|
||||||
for (const name of serviceNames) {
|
for (const name of serviceNames) {
|
||||||
const status = snapshot.statuses.find((s) => s.name === name);
|
const status = snapshot.statuses.find((s) => s.name === name);
|
||||||
if (status && status.running) {
|
if (status && status.running) {
|
||||||
@ -141,7 +174,9 @@ async function saveConfig() {
|
|||||||
function tauriInvoke(command, payload) {
|
function tauriInvoke(command, payload) {
|
||||||
const invoke = window.__TAURI__?.core?.invoke;
|
const invoke = window.__TAURI__?.core?.invoke;
|
||||||
if (!invoke) {
|
if (!invoke) {
|
||||||
return Promise.reject("Tauri invoke API is not available in this window.");
|
return Promise.reject(
|
||||||
|
"Tauri invoke API is not available in this window.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return invoke(command, payload);
|
return invoke(command, payload);
|
||||||
}
|
}
|
||||||
@ -157,32 +192,43 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setHeader(view) {
|
function setHeader(view) {
|
||||||
const title = view === "settings" ? serviceNames[activeSettingsService] : activeNavLabel();
|
const title =
|
||||||
|
view === "settings"
|
||||||
|
? serviceNames[activeSettingsService]
|
||||||
|
: activeNavLabel();
|
||||||
document.getElementById("viewTitle").textContent = title;
|
document.getElementById("viewTitle").textContent = title;
|
||||||
document.getElementById("viewSubtitle").textContent = subtitles[view];
|
document.getElementById("viewSubtitle").textContent = subtitles[view];
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeNavLabel() {
|
function activeNavLabel() {
|
||||||
return document.querySelector(".nav-button.active span:last-child")?.textContent.trim() || "Overview";
|
return (
|
||||||
|
document
|
||||||
|
.querySelector(".nav-button.active span:last-child")
|
||||||
|
?.textContent.trim() || "Overview"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSidebarStatus() {
|
function syncSidebarStatus() {
|
||||||
document.querySelectorAll(".nav-button[data-service], .nav-button[data-view='settings']:not([data-service])").forEach((button) => {
|
document
|
||||||
const service = button.dataset.service || "surrealdb";
|
.querySelectorAll(
|
||||||
const config = snapshot.config[service];
|
".nav-button[data-service], .nav-button[data-view='settings']:not([data-service])",
|
||||||
const status = snapshot.statuses.find((s) => s.name === service);
|
)
|
||||||
const enabled = config?.enabled;
|
.forEach((button) => {
|
||||||
const running = status?.running;
|
const service = button.dataset.service || "surrealdb";
|
||||||
const healthy = status?.healthy;
|
const config = snapshot.config[service];
|
||||||
|
const status = snapshot.statuses.find((s) => s.name === service);
|
||||||
|
const enabled = config?.enabled;
|
||||||
|
const running = status?.running;
|
||||||
|
const healthy = status?.healthy;
|
||||||
|
|
||||||
const isDisabled = !enabled;
|
const isDisabled = !enabled;
|
||||||
const isOnline = enabled && running && healthy;
|
const isOnline = enabled && running && healthy;
|
||||||
const isStopped = enabled && !isOnline;
|
const isStopped = enabled && !isOnline;
|
||||||
|
|
||||||
button.classList.toggle("service-enabled", Boolean(isOnline));
|
button.classList.toggle("service-enabled", Boolean(isOnline));
|
||||||
button.classList.toggle("service-disabled", Boolean(isDisabled));
|
button.classList.toggle("service-disabled", Boolean(isDisabled));
|
||||||
button.classList.toggle("service-stopped", Boolean(isStopped));
|
button.classList.toggle("service-stopped", Boolean(isStopped));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderServices() {
|
function renderServices() {
|
||||||
@ -192,31 +238,44 @@ function renderServices() {
|
|||||||
|
|
||||||
snapshot.statuses.forEach((status) => {
|
snapshot.statuses.forEach((status) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
node.querySelector("h2").textContent = serviceNames[status.name] || status.name;
|
node.querySelector("h2").textContent =
|
||||||
|
serviceNames[status.name] || status.name;
|
||||||
const pingPill = node.querySelector(".ping-pill");
|
const pingPill = node.querySelector(".ping-pill");
|
||||||
const pingDisplay = resolvePingDisplay(status);
|
const pingDisplay = resolvePingDisplay(status);
|
||||||
pingPill.textContent = pingDisplay.label;
|
pingPill.textContent = pingDisplay.label;
|
||||||
pingPill.classList.remove("good", "warn", "bad", "unavailable");
|
pingPill.classList.remove("good", "warn", "bad", "unavailable");
|
||||||
pingPill.classList.add(pingDisplay.className);
|
pingPill.classList.add(pingDisplay.className);
|
||||||
node.querySelector(".command-line").textContent = status.command || "No command configured";
|
node.querySelector(".command-line").textContent =
|
||||||
|
status.command || "No command configured";
|
||||||
|
|
||||||
const pill = node.querySelector(".status-pill");
|
const pill = node.querySelector(".status-pill");
|
||||||
pill.textContent = status.running ? "Running" : "Stopped";
|
pill.textContent = status.running ? "Running" : "Stopped";
|
||||||
pill.classList.add(status.running ? "running" : "stopped");
|
pill.classList.add(status.running ? "running" : "stopped");
|
||||||
|
|
||||||
node.querySelector(".pid").textContent = status.pid ? String(status.pid) : "-";
|
node.querySelector(".pid").textContent = status.pid
|
||||||
|
? String(status.pid)
|
||||||
|
: "-";
|
||||||
node.querySelector(".health").textContent = status.health;
|
node.querySelector(".health").textContent = status.health;
|
||||||
node.querySelector(".enabled").textContent = status.enabled ? "Yes" : "No";
|
node.querySelector(".enabled").textContent = status.enabled
|
||||||
|
? "Yes"
|
||||||
|
: "No";
|
||||||
|
|
||||||
const start = node.querySelector(".start");
|
const start = node.querySelector(".start");
|
||||||
const stop = node.querySelector(".stop");
|
const stop = node.querySelector(".stop");
|
||||||
if (!start || !stop) {
|
if (!start || !stop) {
|
||||||
throw new Error("Service card template is missing start or stop controls.");
|
throw new Error(
|
||||||
|
"Service card template is missing start or stop controls.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
start.disabled = status.running || !status.enabled || !status.configured;
|
start.disabled =
|
||||||
|
status.running || !status.enabled || !status.configured;
|
||||||
stop.disabled = !status.running;
|
stop.disabled = !status.running;
|
||||||
start.addEventListener("click", () => runAction(() => startService(status.name)));
|
start.addEventListener("click", () =>
|
||||||
stop.addEventListener("click", () => runAction(() => stopService(status.name)));
|
runAction(() => startService(status.name)),
|
||||||
|
);
|
||||||
|
stop.addEventListener("click", () =>
|
||||||
|
runAction(() => stopService(status.name)),
|
||||||
|
);
|
||||||
|
|
||||||
grid.appendChild(node);
|
grid.appendChild(node);
|
||||||
});
|
});
|
||||||
@ -256,7 +315,9 @@ function renderSettings(force = false) {
|
|||||||
form.className = "settings-form";
|
form.className = "settings-form";
|
||||||
form.innerHTML = renderSettingsPanel(name, config);
|
form.innerHTML = renderSettingsPanel(name, config);
|
||||||
|
|
||||||
form.querySelectorAll("input[data-service], input[data-arg-key], select[data-arg-key]").forEach((input) => {
|
form.querySelectorAll(
|
||||||
|
"input[data-service], input[data-arg-key], select[data-arg-key]",
|
||||||
|
).forEach((input) => {
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
configDirty = true;
|
configDirty = true;
|
||||||
});
|
});
|
||||||
@ -267,20 +328,30 @@ function renderSettings(force = false) {
|
|||||||
|
|
||||||
form.querySelectorAll("[data-picker]").forEach((button) => {
|
form.querySelectorAll("[data-picker]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
runAction(() => pickPath(button.dataset.picker, button.dataset.target, button.dataset.serviceTarget));
|
runAction(() =>
|
||||||
|
pickPath(
|
||||||
|
button.dataset.picker,
|
||||||
|
button.dataset.target,
|
||||||
|
button.dataset.serviceTarget,
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
form.querySelectorAll("[data-create-server-config]").forEach((button) => {
|
form.querySelectorAll("[data-create-server-config]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const template = document.querySelector("[data-config-template]")?.value || "server";
|
const template =
|
||||||
|
document.querySelector("[data-config-template]")?.value ||
|
||||||
|
"server";
|
||||||
runAction(() => createArmaServerConfig(template));
|
runAction(() => createArmaServerConfig(template));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
form.querySelectorAll("[data-edit-server-config]").forEach((button) => {
|
form.querySelectorAll("[data-edit-server-config]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const path = document.querySelector('[data-arg-key="config"]')?.value.trim();
|
const path = document
|
||||||
|
.querySelector('[data-arg-key="config"]')
|
||||||
|
?.value.trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
alert("Select or create a server config first.");
|
alert("Select or create a server config first.");
|
||||||
return;
|
return;
|
||||||
@ -310,7 +381,11 @@ function renderSettings(force = false) {
|
|||||||
|
|
||||||
if (name === "surrealdb" && !surrealInstallInfo) {
|
if (name === "surrealdb" && !surrealInstallInfo) {
|
||||||
loadSurrealInstallInfo();
|
loadSurrealInstallInfo();
|
||||||
} else if (name === "surrealdb" && surrealInstallInfo && !surrealInstallInfo.latest) {
|
} else if (
|
||||||
|
name === "surrealdb" &&
|
||||||
|
surrealInstallInfo &&
|
||||||
|
!surrealInstallInfo.latest
|
||||||
|
) {
|
||||||
loadLatestSurrealVersion();
|
loadLatestSurrealVersion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -319,6 +394,10 @@ function isSettingsViewActive() {
|
|||||||
return views.settings.classList.contains("active");
|
return views.settings.classList.contains("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLogsViewActive() {
|
||||||
|
return views.logs.classList.contains("active");
|
||||||
|
}
|
||||||
|
|
||||||
function syncRefreshTimer() {
|
function syncRefreshTimer() {
|
||||||
if (refreshTimer) {
|
if (refreshTimer) {
|
||||||
clearInterval(refreshTimer);
|
clearInterval(refreshTimer);
|
||||||
@ -326,22 +405,32 @@ function syncRefreshTimer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSettingsViewActive()) {
|
if (!isSettingsViewActive()) {
|
||||||
refreshTimer = setInterval(refresh, 2500);
|
refreshTimer = setInterval(refresh, isLogsViewActive() ? 30000 : 2500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncBulkActionsVisibility() {
|
function syncBulkActionsVisibility() {
|
||||||
const bulkActions = document.getElementById("bulkActions");
|
const bulkActions = document.getElementById("bulkActions");
|
||||||
bulkActions.style.display = views.overview.classList.contains("active") ? "flex" : "none";
|
bulkActions.style.display = views.overview.classList.contains("active")
|
||||||
|
? "flex"
|
||||||
|
: "none";
|
||||||
|
|
||||||
const stopAllButton = document.getElementById("stopAllButton");
|
const stopAllButton = document.getElementById("stopAllButton");
|
||||||
const hasRunningService = Boolean(snapshot?.statuses.some((status) => status.running));
|
const hasRunningService = Boolean(
|
||||||
|
snapshot?.statuses.some((status) => status.running),
|
||||||
|
);
|
||||||
stopAllButton.disabled = !hasRunningService;
|
stopAllButton.disabled = !hasRunningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLogs() {
|
function renderLogs() {
|
||||||
const output = document.getElementById("logOutput");
|
const output = document.getElementById("logOutput");
|
||||||
output.textContent = snapshot.logs.length ? snapshot.logs.join("\n") : "No host logs yet.";
|
const nextLogText = snapshot.logs.length
|
||||||
|
? snapshot.logs.join("\n")
|
||||||
|
: "No host logs yet.";
|
||||||
|
if (nextLogText === lastRenderedLogText) return;
|
||||||
|
|
||||||
|
lastRenderedLogText = nextLogText;
|
||||||
|
output.textContent = nextLogText;
|
||||||
output.scrollTop = output.scrollHeight;
|
output.scrollTop = output.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,6 +439,7 @@ function renderError(error) {
|
|||||||
document.getElementById("configPath").textContent = "Unable to load";
|
document.getElementById("configPath").textContent = "Unable to load";
|
||||||
document.getElementById("serviceGrid").replaceChildren();
|
document.getElementById("serviceGrid").replaceChildren();
|
||||||
document.getElementById("configForm").replaceChildren();
|
document.getElementById("configForm").replaceChildren();
|
||||||
|
lastRenderedLogText = message;
|
||||||
document.getElementById("logOutput").textContent = message;
|
document.getElementById("logOutput").textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,7 +508,9 @@ function renderSurrealInstallControls() {
|
|||||||
const installed = surrealInstallInfo?.installed;
|
const installed = surrealInstallInfo?.installed;
|
||||||
const version = surrealInstallInfo?.version || "Not installed";
|
const version = surrealInstallInfo?.version || "Not installed";
|
||||||
const latest = surrealInstallInfo?.latest || "Checking...";
|
const latest = surrealInstallInfo?.latest || "Checking...";
|
||||||
const path = surrealInstallInfo?.path || "SurrealDB will be installed to the user-local default path.";
|
const path =
|
||||||
|
surrealInstallInfo?.path ||
|
||||||
|
"SurrealDB will be installed to the user-local default path.";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="field full surreal-install">
|
<div class="field full surreal-install">
|
||||||
@ -455,17 +547,23 @@ function renderSurrealInstallControls() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectedSurrealInstallVersion() {
|
function selectedSurrealInstallVersion() {
|
||||||
const mode = document.querySelector("[data-surreal-version-mode]")?.value || "3";
|
const mode =
|
||||||
|
document.querySelector("[data-surreal-version-mode]")?.value || "3";
|
||||||
if (mode !== "custom") {
|
if (mode !== "custom") {
|
||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
return document.querySelector("[data-surreal-custom-version]")?.value.trim() || "3";
|
return (
|
||||||
|
document.querySelector("[data-surreal-custom-version]")?.value.trim() ||
|
||||||
|
"3"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSurrealCustomVersion() {
|
function syncSurrealCustomVersion() {
|
||||||
const custom = document.querySelector(".install-custom");
|
const custom = document.querySelector(".install-custom");
|
||||||
if (!custom) return;
|
if (!custom) return;
|
||||||
const isCustom = document.querySelector("[data-surreal-version-mode]")?.value === "custom";
|
const isCustom =
|
||||||
|
document.querySelector("[data-surreal-version-mode]")?.value ===
|
||||||
|
"custom";
|
||||||
custom.hidden = !isCustom;
|
custom.hidden = !isCustom;
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
custom.querySelector("input")?.focus();
|
custom.querySelector("input")?.focus();
|
||||||
@ -475,7 +573,12 @@ function syncSurrealCustomVersion() {
|
|||||||
async function loadSurrealInstallInfo() {
|
async function loadSurrealInstallInfo() {
|
||||||
try {
|
try {
|
||||||
surrealInstallInfo = await tauriInvoke("get_surrealdb_install_info");
|
surrealInstallInfo = await tauriInvoke("get_surrealdb_install_info");
|
||||||
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
|
if (
|
||||||
|
activeSettingsService === "surrealdb" &&
|
||||||
|
isSettingsViewActive() &&
|
||||||
|
!configDirty &&
|
||||||
|
!isSurrealInstallFocused()
|
||||||
|
) {
|
||||||
renderSettings(true);
|
renderSettings(true);
|
||||||
}
|
}
|
||||||
await loadLatestSurrealVersion();
|
await loadLatestSurrealVersion();
|
||||||
@ -493,12 +596,25 @@ async function loadLatestSurrealVersion() {
|
|||||||
try {
|
try {
|
||||||
const latest = await tauriInvoke("get_latest_surrealdb_version");
|
const latest = await tauriInvoke("get_latest_surrealdb_version");
|
||||||
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest };
|
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest };
|
||||||
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
|
if (
|
||||||
|
activeSettingsService === "surrealdb" &&
|
||||||
|
isSettingsViewActive() &&
|
||||||
|
!configDirty &&
|
||||||
|
!isSurrealInstallFocused()
|
||||||
|
) {
|
||||||
renderSettings(true);
|
renderSettings(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest: "Unavailable" };
|
surrealInstallInfo = {
|
||||||
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
|
...(surrealInstallInfo || {}),
|
||||||
|
latest: "Unavailable",
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
activeSettingsService === "surrealdb" &&
|
||||||
|
isSettingsViewActive() &&
|
||||||
|
!configDirty &&
|
||||||
|
!isSurrealInstallFocused()
|
||||||
|
) {
|
||||||
renderSettings(true);
|
renderSettings(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -685,8 +801,10 @@ function toggleArgument(label, key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readArgumentFields(service, container) {
|
function readArgumentFields(service, container) {
|
||||||
const value = (key) => container.querySelector(`[data-arg-key="${key}"]`)?.value.trim() || "";
|
const value = (key) =>
|
||||||
const checked = (key) => Boolean(container.querySelector(`[data-arg-key="${key}"]`)?.checked);
|
container.querySelector(`[data-arg-key="${key}"]`)?.value.trim() || "";
|
||||||
|
const checked = (key) =>
|
||||||
|
Boolean(container.querySelector(`[data-arg-key="${key}"]`)?.checked);
|
||||||
if (service === "surrealdb") {
|
if (service === "surrealdb") {
|
||||||
const args = [];
|
const args = [];
|
||||||
if (value("subcommand")) args.push(value("subcommand"));
|
if (value("subcommand")) args.push(value("subcommand"));
|
||||||
@ -707,7 +825,8 @@ function readArgumentFields(service, container) {
|
|||||||
if (value("mods")) args.push(`-mod=${value("mods")}`);
|
if (value("mods")) args.push(`-mod=${value("mods")}`);
|
||||||
if (value("serverMods")) args.push(`-serverMod=${value("serverMods")}`);
|
if (value("serverMods")) args.push(`-serverMod=${value("serverMods")}`);
|
||||||
checked("autoInit") && args.push("-autoInit");
|
checked("autoInit") && args.push("-autoInit");
|
||||||
checked("reportNonNetworkObject") && args.push("-reportNonNetworkObject");
|
checked("reportNonNetworkObject") &&
|
||||||
|
args.push("-reportNonNetworkObject");
|
||||||
args.push(checked("battleye") ? "-battleye" : "-noBattlEye");
|
args.push(checked("battleye") ? "-battleye" : "-noBattlEye");
|
||||||
args.push(...splitMiscArgs(value("misc")));
|
args.push(...splitMiscArgs(value("misc")));
|
||||||
return args;
|
return args;
|
||||||
@ -796,7 +915,10 @@ function parseArmaArgs(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitMiscArgs(value) {
|
function splitMiscArgs(value) {
|
||||||
return value.split(/\s+/).map((arg) => arg.trim()).filter(Boolean);
|
return value
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((arg) => arg.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickPath(kind, targetSelector, service) {
|
async function pickPath(kind, targetSelector, service) {
|
||||||
@ -818,7 +940,9 @@ async function pickPath(kind, targetSelector, service) {
|
|||||||
input.value = finalPath;
|
input.value = finalPath;
|
||||||
|
|
||||||
if (kind === "file" && targetSelector.includes('data-field="command"')) {
|
if (kind === "file" && targetSelector.includes('data-field="command"')) {
|
||||||
const workingDir = document.querySelector(`[data-service="${service}"][data-field="working_dir"]`);
|
const workingDir = document.querySelector(
|
||||||
|
`[data-service="${service}"][data-field="working_dir"]`,
|
||||||
|
);
|
||||||
if (workingDir) {
|
if (workingDir) {
|
||||||
workingDir.value = finalPath.replace(/[\\/][^\\/]+$/, "");
|
workingDir.value = finalPath.replace(/[\\/][^\\/]+$/, "");
|
||||||
}
|
}
|
||||||
@ -845,10 +969,14 @@ function pickerFilters(kind) {
|
|||||||
async function createArmaServerConfig(template) {
|
async function createArmaServerConfig(template) {
|
||||||
const save = window.__TAURI__?.dialog?.save;
|
const save = window.__TAURI__?.dialog?.save;
|
||||||
if (!save) {
|
if (!save) {
|
||||||
throw new Error("Tauri save dialog API is not available in this window.");
|
throw new Error(
|
||||||
|
"Tauri save dialog API is not available in this window.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
|
const workingDir = document
|
||||||
|
.querySelector('[data-service="arma"][data-field="working_dir"]')
|
||||||
|
?.value.trim();
|
||||||
const defaultPath = workingDir ? `${workingDir}\\server.cfg` : "server.cfg";
|
const defaultPath = workingDir ? `${workingDir}\\server.cfg` : "server.cfg";
|
||||||
const selected = await save({
|
const selected = await save({
|
||||||
defaultPath,
|
defaultPath,
|
||||||
@ -856,7 +984,10 @@ async function createArmaServerConfig(template) {
|
|||||||
});
|
});
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
await tauriInvoke("create_arma_server_config", { path: selected, template });
|
await tauriInvoke("create_arma_server_config", {
|
||||||
|
path: selected,
|
||||||
|
template,
|
||||||
|
});
|
||||||
const input = document.querySelector('[data-arg-key="config"]');
|
const input = document.querySelector('[data-arg-key="config"]');
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = selected;
|
input.value = selected;
|
||||||
@ -871,7 +1002,9 @@ function resolveArmaConfigPath(path) {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
|
const workingDir = document
|
||||||
|
.querySelector('[data-service="arma"][data-field="working_dir"]')
|
||||||
|
?.value.trim();
|
||||||
return workingDir ? `${workingDir}\\${path}` : path;
|
return workingDir ? `${workingDir}\\${path}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -906,11 +1039,15 @@ function normalizePickedExecutable(service, path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileName === "arma3_x64.exe" || fileName === "arma3.exe") {
|
if (fileName === "arma3_x64.exe" || fileName === "arma3.exe") {
|
||||||
alert("That is the Arma 3 client executable. Forge Host will use arma3server_x64.exe from the same directory instead.");
|
alert(
|
||||||
|
"That is the Arma 3 client executable. Forge Host will use arma3server_x64.exe from the same directory instead.",
|
||||||
|
);
|
||||||
return path.replace(/[\\/][^\\/]+$/, "\\arma3server_x64.exe");
|
return path.replace(/[\\/][^\\/]+$/, "\\arma3server_x64.exe");
|
||||||
}
|
}
|
||||||
|
|
||||||
alert("Select arma3server_x64.exe for dedicated server hosting. The app will block client executables at launch.");
|
alert(
|
||||||
|
"Select arma3server_x64.exe for dedicated server hosting. The app will block client executables at launch.",
|
||||||
|
);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -944,3 +1081,4 @@ function selectedAttr(selected) {
|
|||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
syncRefreshTimer();
|
syncRefreshTimer();
|
||||||
|
loadAppVersion();
|
||||||
|
|||||||
@ -1,145 +1,225 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FORGE Host</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<head>
|
<body>
|
||||||
<meta charset="UTF-8" />
|
<main class="shell">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<aside class="rail">
|
||||||
<title>FORGE Host</title>
|
<nav class="nav">
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<button
|
||||||
</head>
|
class="nav-button active"
|
||||||
|
type="button"
|
||||||
|
data-view="overview"
|
||||||
|
title="Overview"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-dashboard"></span>
|
||||||
|
<span>Overview</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
type="button"
|
||||||
|
data-view="settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-database"></span>
|
||||||
|
<span>SurrealDB</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
type="button"
|
||||||
|
data-view="settings"
|
||||||
|
data-service="icom"
|
||||||
|
title="ICOM"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-icom"></span>
|
||||||
|
<span>ICOM</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
type="button"
|
||||||
|
data-view="settings"
|
||||||
|
data-service="arma"
|
||||||
|
title="Arma 3 Server"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-server"></span>
|
||||||
|
<span>Arma Server</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
type="button"
|
||||||
|
data-view="logs"
|
||||||
|
title="Logs"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-logs"></span>
|
||||||
|
<span>Logs</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<body>
|
<section class="workspace">
|
||||||
<main class="shell">
|
<header class="topbar">
|
||||||
<aside class="rail">
|
<div class="view-heading">
|
||||||
<nav class="nav">
|
<h1 id="viewTitle">Overview</h1>
|
||||||
<button class="nav-button active" type="button" data-view="overview" title="Overview">
|
<p id="viewSubtitle">
|
||||||
<span class="svg-icon icon-dashboard"></span>
|
Local service control for Forge server hosting.
|
||||||
<span>Overview</span>
|
</p>
|
||||||
</button>
|
</div>
|
||||||
<button class="nav-button" type="button" data-view="settings" title="Settings">
|
<div class="top-actions">
|
||||||
<span class="svg-icon icon-database"></span>
|
<div
|
||||||
<span>SurrealDB</span>
|
id="bulkActions"
|
||||||
</button>
|
class="bulk-actions"
|
||||||
<button class="nav-button" type="button" data-view="settings" data-service="icom" title="ICOM">
|
style="display: none"
|
||||||
<span class="svg-icon icon-icom"></span>
|
>
|
||||||
<span>ICOM</span>
|
<button
|
||||||
</button>
|
id="startAllButton"
|
||||||
<button class="nav-button" type="button" data-view="settings" data-service="arma" title="Arma 3 Server">
|
class="start icon-button"
|
||||||
<span class="svg-icon icon-server"></span>
|
type="button"
|
||||||
<span>Arma Server</span>
|
aria-label="Start All"
|
||||||
</button>
|
title="Start all services"
|
||||||
<button class="nav-button" type="button" data-view="logs" title="Logs">
|
>
|
||||||
<span class="svg-icon icon-logs"></span>
|
<span class="svg-icon icon-play"></span>
|
||||||
<span>Logs</span>
|
</button>
|
||||||
</button>
|
<button
|
||||||
</nav>
|
id="stopAllButton"
|
||||||
<div class="config-path">
|
class="stop icon-button"
|
||||||
<span>Config</span>
|
type="button"
|
||||||
<strong id="configPath">Loading</strong>
|
aria-label="Stop All"
|
||||||
</div>
|
title="Stop all services"
|
||||||
</aside>
|
>
|
||||||
|
<span class="svg-icon icon-stop"></span>
|
||||||
<section class="workspace">
|
</button>
|
||||||
<header class="topbar">
|
</div>
|
||||||
<div>
|
<button
|
||||||
<h1 id="viewTitle">Overview</h1>
|
id="refreshButton"
|
||||||
<p id="viewSubtitle">Local service control for Forge server hosting.</p>
|
class="primary icon-button"
|
||||||
</div>
|
type="button"
|
||||||
<div class="top-actions">
|
aria-label="Refresh"
|
||||||
<div id="bulkActions" class="bulk-actions" style="display: none;">
|
title="Refresh"
|
||||||
<button id="startAllButton" class="start icon-button" type="button" aria-label="Start All"
|
>
|
||||||
title="Start all services">
|
<span class="svg-icon icon-refresh"></span>
|
||||||
<span class="svg-icon icon-play"></span>
|
|
||||||
</button>
|
</button>
|
||||||
<button id="stopAllButton" class="stop icon-button" type="button" aria-label="Stop All"
|
<button
|
||||||
title="Stop all services">
|
id="saveButton"
|
||||||
<span class="svg-icon icon-stop"></span>
|
class="icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Save"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-save"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="refreshButton" class="primary icon-button" type="button" aria-label="Refresh" title="Refresh">
|
<div class="config-path">
|
||||||
|
<span>Config</span>
|
||||||
|
<strong id="configPath">Loading</strong>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="overviewView" class="view active">
|
||||||
|
<div id="serviceGrid" class="service-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="settingsView" class="view">
|
||||||
|
<form id="configForm" class="settings-grid"></form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="logsView" class="view">
|
||||||
|
<div id="logOutput" class="log-output"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<div class="version-display" title="FORGE Host version">
|
||||||
|
<span id="appVersion" class="version-text">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="editorOverlay" class="editor-overlay" hidden>
|
||||||
|
<section class="editor-modal">
|
||||||
|
<header class="editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>Server Config</h2>
|
||||||
|
<p id="editorPath"></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="editorCloseButton"
|
||||||
|
class="icon-button"
|
||||||
|
type="button"
|
||||||
|
title="Close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-close"></span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<textarea id="configEditor" spellcheck="false"></textarea>
|
||||||
|
<footer class="editor-actions">
|
||||||
|
<button
|
||||||
|
id="editorReloadButton"
|
||||||
|
class="primary"
|
||||||
|
type="button"
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
<span class="svg-icon icon-refresh"></span>
|
<span class="svg-icon icon-refresh"></span>
|
||||||
</button>
|
</button>
|
||||||
<button id="saveButton" class="icon-button" type="button" aria-label="Save" title="Save">
|
<button
|
||||||
|
id="editorSaveButton"
|
||||||
|
class="icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Save"
|
||||||
|
>
|
||||||
<span class="svg-icon icon-save"></span>
|
<span class="svg-icon icon-save"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="overviewView" class="view active">
|
|
||||||
<div id="serviceGrid" class="service-grid"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="settingsView" class="view">
|
<template id="serviceCardTemplate">
|
||||||
<form id="configForm" class="settings-grid"></form>
|
<article class="service-card">
|
||||||
</section>
|
<header>
|
||||||
|
<div>
|
||||||
<section id="logsView" class="view">
|
<div class="service-title-row">
|
||||||
<div id="logOutput" class="log-output"></div>
|
<h2></h2>
|
||||||
</section>
|
<span class="ping-pill"></span>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
<p class="command-line"></p>
|
||||||
|
|
||||||
<div id="editorOverlay" class="editor-overlay" hidden>
|
|
||||||
<section class="editor-modal">
|
|
||||||
<header class="editor-header">
|
|
||||||
<div>
|
|
||||||
<h2>Server Config</h2>
|
|
||||||
<p id="editorPath"></p>
|
|
||||||
</div>
|
|
||||||
<button id="editorCloseButton" class="icon-button" type="button" title="Close" aria-label="Close">
|
|
||||||
<span class="svg-icon icon-close"></span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<textarea id="configEditor" spellcheck="false"></textarea>
|
|
||||||
<footer class="editor-actions">
|
|
||||||
<button id="editorReloadButton" class="primary" type="button" aria-label="Refresh">
|
|
||||||
<span class="svg-icon icon-refresh"></span>
|
|
||||||
</button>
|
|
||||||
<button id="editorSaveButton" class="icon-button" type="button" aria-label="Save">
|
|
||||||
<span class="svg-icon icon-save"></span>
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template id="serviceCardTemplate">
|
|
||||||
<article class="service-card">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<div class="service-title-row">
|
|
||||||
<h2></h2>
|
|
||||||
<span class="ping-pill"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="command-line"></p>
|
<span class="status-pill"></span>
|
||||||
|
</header>
|
||||||
|
<dl class="metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Process</dt>
|
||||||
|
<dd class="pid"></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Health</dt>
|
||||||
|
<dd class="health"></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Enabled</dt>
|
||||||
|
<dd class="enabled"></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button
|
||||||
|
class="start icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Start"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-play"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="stop icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Stop"
|
||||||
|
>
|
||||||
|
<span class="svg-icon icon-stop"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill"></span>
|
</article>
|
||||||
</header>
|
</template>
|
||||||
<dl class="metrics">
|
|
||||||
<div>
|
|
||||||
<dt>Process</dt>
|
|
||||||
<dd class="pid"></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Health</dt>
|
|
||||||
<dd class="health"></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Enabled</dt>
|
|
||||||
<dd class="enabled"></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="card-actions">
|
|
||||||
<button class="start icon-button" type="button" aria-label="Start">
|
|
||||||
<span class="svg-icon icon-play"></span>
|
|
||||||
</button>
|
|
||||||
<button class="stop icon-button" type="button" aria-label="Stop">
|
|
||||||
<span class="svg-icon icon-stop"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script src="./app.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<script src="./app.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
bin/host/src/public/layer0.png
Normal file
BIN
bin/host/src/public/layer0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user