Compare commits

...
Author SHA1 Message Date
Danielv123 3d97b69b07 Add Windows headless installer support 2026-03-11 23:25:02 +01:00
Danielv123 5b4621a7de Add Windows headless update downloads 2026-03-11 23:25:02 +01:00
6 changed files with 261 additions and 70 deletions

No files matched your search

+1 -1
View File
@@ -146,7 +146,7 @@ Your instances (save files etc) will be stored there.
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation, download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
3. If you chose to use the local `factorio` directory for the Factorio installation, you can let the installer download the latest headless release automatically, or manually download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
+110 -16
View File
@@ -43,6 +43,9 @@ const availablePlugins = [
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const windowsHeadlessDownloadUrl = "https://factorio-repackager.fly.dev/get-download/latest/headless/win64";
const headlessDownloadPlatforms = new Set(["linux", "win32"]);
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
@@ -53,6 +56,10 @@ const factorioLocations = {
],
};
function supportsHeadlessDownload(platform = process.platform) {
return headlessDownloadPlatforms.has(platform);
}
class LineSplitter extends stream.Transform {
constructor(options) {
@@ -647,14 +654,14 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (supportsHeadlessDownload()) {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
message: "Automatically download latest headless Factorio release?",
default: true,
},
], answers);
@@ -671,7 +678,7 @@ async function inquirerMissingArgs(args) {
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Use local factorio directory", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
@@ -801,6 +808,87 @@ async function downloadLinuxServer() {
}
}
async function getFactorioVersion(factorioDir) {
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
const changelog = await fs.readFile(changelogPath, "utf8");
for (const line of changelog.split(/\r?\n/)) {
const index = line.indexOf(":");
if (index === -1) {
continue;
}
if (line.slice(0, index).trim().toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
throw new Error(`Unable to detect Factorio version from ${changelogPath}`);
}
async function findFactorioRoot(rootDir) {
const entries = await fs.readdir(rootDir);
const factorioRoot = entries.find(entry => entry === "factorio" || entry === "Factorio")
?? (entries.length === 1 ? entries[0] : null);
if (!factorioRoot) {
throw new Error(`Unable to locate extracted Factorio directory in ${rootDir}`);
}
return path.join(rootDir, factorioRoot);
}
async function downloadWindowsServer() {
let res = await fetch(windowsHeadlessDownloadUrl);
if (res.status === 202) {
throw new Error("Latest Windows headless package is not ready yet, try again in a few minutes.");
}
if (!res.ok) {
throw new Error(`Failed to fetch ${windowsHeadlessDownloadUrl}: HTTP ${res.status} ${res.statusText}`);
}
if (!res.body) {
throw new Error("Windows headless download did not include an archive payload");
}
const tmpDir = path.join("temp", "create-temp");
const archivePath = path.join(tmpDir, "factorio-headless-win64.zip");
const tmpArchivePath = `${archivePath}.tmp`;
const tmpExtractDir = path.join(tmpDir, "win64-extract");
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Windows headless release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
const expandArchiveCommand =
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' ` +
`-DestinationPath '${tmpExtractDir.replace(/'/g, "''")}' -Force`;
await execFile("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
expandArchiveCommand,
]);
} catch (err) {
logger.error("error executing command - do you have PowerShell available?");
throw err;
}
const tmpFactorioDir = await findFactorioRoot(tmpExtractDir);
const version = await getFactorioVersion(tmpFactorioDir);
const factorioDir = path.join("factorio", version);
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
async function main() {
let args = yargs
.option("log-level", {
@@ -824,7 +912,7 @@ async function main() {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
describe: "Download the latest headless release [standalone/host]",
type: "boolean", nargs: 0,
})
.option("host-name", {
@@ -861,17 +949,17 @@ async function main() {
;
if (process.platform === "linux") {
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
args = args.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
});
}
if (supportsHeadlessDownload()) {
args = args.option("download-headless", {
nargs: 0,
describe: "Automatically download and unpack the latest headless Factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
});
}
args = args.argv;
@@ -901,7 +989,13 @@ async function main() {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
if (process.platform === "linux") {
await downloadLinuxServer();
} else if (process.platform === "win32") {
await downloadWindowsServer();
} else {
throw new InstallError("--download-headless is only supported on Linux and Windows");
}
}
if (!dev) {
+102 -36
View File
@@ -19,7 +19,7 @@ const execFileAsync = util.promisify(child_process.execFile);
* @param factorioDir - Path factorio dir containing data/changelog.txt.
*/
async function getFactorioVersion(factorioDir: string) {
let changelog;
let changelog: string;
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
try {
changelog = await fs.readFile(changelogPath, "utf-8");
@@ -106,34 +106,40 @@ async function listFactorioVersions(factorioDir: string) {
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractZip(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
const res = await fetchDownload(url);
// Load the ZIP with JSZip
// Load the ZIP with JSZip
const buffer = Buffer.from(await res.arrayBuffer());
const zip = await JSZip.loadAsync(buffer);
const tmpTargetDir = `${targetDir}.tmp-extract`;
const stripRootDir = hasSingleArchiveRoot(Object.values(zip.files));
// Extract each file/folder
await fs.ensureDir(targetDir);
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedPath = parts.slice(1).join("/");
if (!strippedPath) {
return; // This copies the behaviour of tar --strip-components 1
}
await fs.remove(tmpTargetDir);
await fs.ensureDir(tmpTargetDir);
try {
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedParts = stripRootDir ? parts.slice(1) : parts;
if (!strippedParts.length) {
return;
}
const entryPath = path.join(targetDir, strippedPath);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
const entryPath = validatedExtractPath(tmpTargetDir, strippedParts);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
await fs.remove(targetDir);
await fs.rename(tmpTargetDir, targetDir);
} catch (err) {
await fs.remove(tmpTargetDir);
throw err;
}
}
/**
@@ -144,10 +150,7 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractTar(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
const res = await fetchDownload(url);
// Write the buffer to a temp file
const tmpFilePath = `${targetDir}.tmp`;
@@ -157,7 +160,9 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
// Execute tar CLI to extract
try {
await execFileAsync("tar", ["-xf", tmpFilePath, "-C", targetDir, "--strip-components", "1"]);
await execFileAsync("tar", [
"-xf", tarPath(tmpFilePath), "-C", tarPath(targetDir), "--strip-components", "1",
]);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error("error executing tar command, do you have 'xz-utils' installed?");
@@ -169,6 +174,57 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
}
}
async function fetchDownload(url: string) {
const res = await fetch(url);
if (res.status === 202) {
let detail = "";
try {
detail = await res.text();
} catch {
}
throw new Error(`Download is not ready yet for ${url}${detail ? `: ${detail}` : ""}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
return res;
}
function tarPath(filePath: string) {
if (process.platform === "win32") {
return filePath.replace(/\\/g, "/");
}
return filePath;
}
function hasSingleArchiveRoot(entries: JSZip.JSZipObject[]) {
const rootNames = new Set(
entries
.map(entry => entry.name.split("/").filter(Boolean)[0])
.filter((value): value is string => Boolean(value))
);
return rootNames.size === 1;
}
function validatedExtractPath(rootDir: string, parts: string[]) {
const entryPath = path.resolve(rootDir, ...parts);
const relative = path.relative(rootDir, entryPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to extract unsafe ZIP entry ${parts.join("/")}`);
}
return entryPath;
}
function getHeadlessDownload(version: lib.ExternalFactorioVersion) {
if (process.platform === "linux") {
return { url: version.headlessUrl, extract: downloadAndExtractTar };
}
if (process.platform === "win32" && version.windowsHeadlessUrl) {
return { url: version.windowsHeadlessUrl, extract: downloadAndExtractZip };
}
return null;
}
/**
* Find factorio data directory of the given version
*
@@ -749,7 +805,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
;
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
let content: unknown;
if (type === "j") {
try {
content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8"));
@@ -905,7 +961,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
return;
}
let newWhitelistJson;
let newWhitelistJson: unknown;
try {
newWhitelistJson = await fs.readJSON(filePath);
} catch (err: any) {
@@ -985,8 +1041,8 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct || process.platform !== "linux") {
const download = getHeadlessDownload(latestVersion);
if (installedVersions.direct || !download) {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
return;
}
@@ -994,8 +1050,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// Download the newer version
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
const dst = path.join(this._factorioDir, latestVersion.version);
await downloadAndExtractTar(latestVersion.headlessUrl, dst);
this._logger.info(`Downloaded factorio version ${v}`);
try {
await download.extract(download.url, dst);
this._logger.info(`Downloaded factorio version ${v}`);
} catch (err: unknown) {
if (installedVersions.versions.size > 0) {
this._logger.warn(
`Failed to download factorio version ${v}, continuing with installed versions: ${err}`
);
return;
}
throw err;
}
}
}
@@ -1051,7 +1117,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
} else if (this._unexpected.length === 0) {
let msg;
let msg: string;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
+9 -5
View File
@@ -2,6 +2,7 @@ import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive/";
const WINDOWS_HEADLESS_BASE_URL = "https://factorio-repackager.fly.dev/get-download";
/**
* Represents the parsed result of a factorio version
@@ -10,9 +11,10 @@ export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
windowsHeadlessUrl: Type.Optional(Type.String()),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
/**
* Fetch all factorio versions that support headless
@@ -21,7 +23,7 @@ export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchem
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res;
let res: Response;
try {
res = await fetch(ARCHIVE_URL);
@@ -48,11 +50,11 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+\.\d+)"[^>]*>/g;
let match: RegExpExecArray | null;
let match = anchorRegex.exec(html);
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while ((match = anchorRegex.exec(html)) !== null) {
while (match !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
@@ -61,8 +63,10 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
version: version,
stable: kind === "stable",
headlessUrl: `https://www.factorio.com/get-download/${version}/headless/linux64`,
windowsHeadlessUrl: `${WINDOWS_HEADLESS_BASE_URL}/${version}/headless/win64`,
});
}
match = anchorRegex.exec(html);
}
return [...versions.values()];
+36 -12
View File
@@ -364,10 +364,12 @@ describe("host/server", function() {
stable: true,
version: "0.2.1",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: false,
version: "0.2.0",
headlessUrl: "test2",
windowsHeadlessUrl: "win-test2",
}]);
assert.equal(fetchCalledWith, null);
@@ -380,10 +382,12 @@ describe("host/server", function() {
stable: true,
version: "0.1.1",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test2",
windowsHeadlessUrl: "win-test2",
}]);
assert.equal(fetchCalledWith, null);
@@ -395,21 +399,28 @@ describe("host/server", function() {
stable: true,
version: "0.1.5",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test2",
windowsHeadlessUrl: "win-test2",
}]);
assert.equal(fetchCalledWith, null);
});
it("should do nothing when on windows", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
it("should do attempt to download on windows", async function() {
let infoLine = null;
let warnLine = null;
server._logger = {
info: line => { infoLine = line; },
warn: line => { warnLine = line; },
};
Object.defineProperty(process, "platform", { value: "win32" });
server._factorioDir = path.join("test", "file", "factorio");
@@ -418,44 +429,56 @@ describe("host/server", function() {
stable: true,
version: "0.1.5",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test2",
windowsHeadlessUrl: "win-test2",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
windowsHeadlessUrl: "win-test3",
}]);
assert.equal(fetchCalledWith, null);
assert.ok(logLine !== null);
assert.ok(logLine.endsWith("but must be manually downloaded"));
assert.equal(fetchCalledWith, "win-test1");
assert.ok(infoLine !== null);
assert.ok(infoLine.endsWith("starting download..."));
assert.ok(warnLine?.includes("continuing with installed versions"));
});
it("should do attempt to download on linux", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
let infoLine = null;
let warnLine = null;
server._logger = {
info: line => { infoLine = line; },
warn: line => { warnLine = line; },
};
Object.defineProperty(process, "platform", { value: "linux" });
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await assert.rejects(server.checkForUpdates([{
await server.checkForUpdates([{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
windowsHeadlessUrl: "win-test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test2",
windowsHeadlessUrl: "win-test2",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
}]), new Error("Failed to fetch test1: -1 Fetch called"));
windowsHeadlessUrl: "win-test3",
}]);
assert.equal(fetchCalledWith, "test1");
assert.ok(logLine !== null);
assert.ok(logLine.endsWith("starting download..."));
assert.ok(infoLine !== null);
assert.ok(infoLine.endsWith("starting download..."));
assert.ok(warnLine?.includes("continuing with installed versions"));
});
});
/* eslint-enable no-loop-func */
@@ -474,6 +497,7 @@ describe("host/server", function() {
stable: true,
version: "2.0.73",
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
windowsHeadlessUrl: "https://factorio-repackager.fly.dev/get-download/2.0.73/headless/win64",
}]);
});
});
+3
View File
@@ -55,11 +55,13 @@ describe("FactorioVersions", function() {
stable: false,
version: "2.0.73",
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
windowsHeadlessUrl: "https://factorio-repackager.fly.dev/get-download/2.0.73/headless/win64",
},
{
stable: true,
version: "2.0.72",
headlessUrl: "https://www.factorio.com/get-download/2.0.72/headless/linux64",
windowsHeadlessUrl: "https://factorio-repackager.fly.dev/get-download/2.0.72/headless/win64",
},
]);
});
@@ -91,6 +93,7 @@ describe("FactorioVersions", function() {
stable: true,
version: "2.0.73",
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
windowsHeadlessUrl: "https://factorio-repackager.fly.dev/get-download/2.0.73/headless/win64",
},
]);
});