Layonara Forge — NWN Development IDE
Electron desktop application for Neverwinter Nights module development. Clone, edit, build, and run a complete Layonara NWNX server with only Docker required. - React 19 + Vite frontend with Monaco editor and NWScript LSP - Node.js + Express backend managing Docker sibling containers - Electron shell with Docker availability check and auto-setup - Builder image auto-builds on first use from bundled Dockerfile - Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage) - Gitea Actions CI for automated release builds
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { exec } from "child_process";
|
||||
|
||||
export interface DockerStatus {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function checkDocker(): Promise<DockerStatus> {
|
||||
return new Promise((resolve) => {
|
||||
exec("docker version --format '{{.Server.Version}}'", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({
|
||||
available: false,
|
||||
error: "Docker is not running or not installed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
available: true,
|
||||
version: stdout.trim().replace(/'/g, ""),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function dockerDownloadUrl(): string {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe";
|
||||
case "darwin":
|
||||
return process.arch === "arm64"
|
||||
? "https://desktop.docker.com/mac/main/arm64/Docker.dmg"
|
||||
: "https://desktop.docker.com/mac/main/amd64/Docker.dmg";
|
||||
default:
|
||||
return "https://docs.docker.com/engine/install/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { app, BrowserWindow, shell, dialog } from "electron";
|
||||
import path from "path";
|
||||
import { checkDocker, dockerDownloadUrl } from "./docker-check";
|
||||
import { defaultWorkspacePath, detectNwnHome } from "./paths";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let serverPort: number;
|
||||
|
||||
function setEnvironment(): void {
|
||||
if (!process.env.WORKSPACE_PATH) {
|
||||
process.env.WORKSPACE_PATH = defaultWorkspacePath();
|
||||
}
|
||||
if (!process.env.NWN_HOME_PATH) {
|
||||
const detected = detectNwnHome();
|
||||
if (detected) process.env.NWN_HOME_PATH = detected;
|
||||
}
|
||||
if (!process.env.GIT_PROVIDER_URL) {
|
||||
process.env.GIT_PROVIDER_URL = "https://gitea.layonara.com";
|
||||
}
|
||||
process.env.ELECTRON = "1";
|
||||
}
|
||||
|
||||
async function startBackend(): Promise<number> {
|
||||
const { startServer } = await import(
|
||||
path.join(__dirname, "../packages/backend/dist/index.js")
|
||||
);
|
||||
const port = await findFreePort();
|
||||
await startServer(port);
|
||||
return port;
|
||||
}
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const net = require("net");
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, () => {
|
||||
const port = srv.address().port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
srv.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow(port: number): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: "Layonara Forge",
|
||||
icon: path.join(__dirname, "../assets/icon.png"),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.loadURL(`http://localhost:${port}`);
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http")) shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
async function showDockerMissing(): Promise<boolean> {
|
||||
const url = dockerDownloadUrl();
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
title: "Docker Required",
|
||||
message: "Layonara Forge requires Docker to build modules and run the NWN server.",
|
||||
detail: "Docker Desktop must be installed and running before using the Forge.\n\nClick \"Download Docker\" to open the download page, or \"Check Again\" after installing.",
|
||||
buttons: ["Download Docker", "Check Again", "Quit"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
|
||||
if (response === 0) {
|
||||
shell.openExternal(url);
|
||||
return showDockerMissing();
|
||||
}
|
||||
if (response === 1) {
|
||||
const status = await checkDocker();
|
||||
if (status.available) return true;
|
||||
return showDockerMissing();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
setEnvironment();
|
||||
|
||||
const docker = await checkDocker();
|
||||
if (!docker.available) {
|
||||
const installed = await showDockerMissing();
|
||||
if (!installed) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
serverPort = await startBackend();
|
||||
} catch (err: any) {
|
||||
dialog.showErrorBox(
|
||||
"Forge Startup Error",
|
||||
`Failed to start the backend server:\n\n${err.message}`,
|
||||
);
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow = createWindow(serverPort);
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (mainWindow === null && serverPort) {
|
||||
mainWindow = createWindow(serverPort);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
|
||||
export function defaultWorkspacePath(): string {
|
||||
return path.join(os.homedir(), "Layonara Forge");
|
||||
}
|
||||
|
||||
export function detectNwnHome(): string | null {
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const docs = path.join(os.homedir(), "Documents", "Neverwinter Nights");
|
||||
candidates.push(docs);
|
||||
const steam = path.join(
|
||||
"C:",
|
||||
"Program Files (x86)",
|
||||
"Steam",
|
||||
"steamapps",
|
||||
"common",
|
||||
"Neverwinter Nights",
|
||||
);
|
||||
candidates.push(steam);
|
||||
} else if (process.platform === "darwin") {
|
||||
candidates.push(path.join(os.homedir(), "Documents", "Neverwinter Nights"));
|
||||
candidates.push(
|
||||
path.join(os.homedir(), "Library", "Application Support", "Neverwinter Nights"),
|
||||
);
|
||||
} else {
|
||||
candidates.push(path.join(os.homedir(), ".local", "share", "Neverwinter Nights"));
|
||||
candidates.push(
|
||||
path.join(os.homedir(), ".steam", "steam", "steamapps", "common", "Neverwinter Nights"),
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.statSync(candidate).isDirectory()) return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { contextBridge } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("forge", {
|
||||
platform: process.platform,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user