From d0e0e27cfcfd12d4d7aea8e34d027260d6a5690f Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Mon, 3 Aug 2026 18:26:43 -0700 Subject: [PATCH 01/12] feat: platform-aware manifest generation for multi-platform runtimes --- manifest.cfg | 1 + manifest.xml | 539 ++++++++++++++++++---------------- tests/test_update_manifest.py | 79 +++++ update_manifest.py | 28 +- 4 files changed, 380 insertions(+), 267 deletions(-) create mode 100644 tests/test_update_manifest.py diff --git a/manifest.cfg b/manifest.cfg index facb02e37d8..859d785b5b8 100644 --- a/manifest.cfg +++ b/manifest.cfg @@ -5,6 +5,7 @@ include-directories = [runtime] path = runtime +platform = win32 exclude-files = lua-profiler.lua,msvcr100.dll,SimpleGraphic.cfg,Update.exe,imgui.ini,SimpleGraphic.log exclude-directories = diff --git a/manifest.xml b/manifest.xml index 86a8c182c51..c9e335086a1 100644 --- a/manifest.xml +++ b/manifest.xml @@ -117,36 +117,36 @@ - + - + - + - + - - + + - + - - + + - + - + @@ -158,19 +158,19 @@ - + - - + + - + - + @@ -179,19 +179,19 @@ - + - + - - - + + + - + - + - + @@ -204,7 +204,7 @@ - + @@ -213,12 +213,13 @@ - + - + - + + @@ -227,48 +228,48 @@ - - - - + + + + - - - - - - - + + + + + + + - - - - - - + + + + + + - - - - - - - + + + + + + + - + - - + + - - + + - + - + @@ -278,26 +279,52 @@ - + - - - + + + - - - + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -306,12 +333,12 @@ - - + + - + @@ -325,49 +352,51 @@ - + - - + + - + - - + + - - + + - - + + - + - + - - + + + + - + - + @@ -375,162 +404,162 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1153,7 +1182,7 @@ - + @@ -1171,7 +1200,7 @@ - + @@ -1284,11 +1313,13 @@ - - - - - + + + + + + + diff --git a/tests/test_update_manifest.py b/tests/test_update_manifest.py new file mode 100644 index 00000000000..0e79992ad51 --- /dev/null +++ b/tests/test_update_manifest.py @@ -0,0 +1,79 @@ +import pathlib +import sys +import xml.etree.ElementTree as Et + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from update_manifest import create_manifest + + +BASE_MANIFEST = ( + '\n' + "\n" + '\t\n' + "\n" +) + + +def make_repo(tmp_path: pathlib.Path, cfg: str) -> None: + (tmp_path / "manifest.xml").write_text(BASE_MANIFEST) + (tmp_path / "manifest.cfg").write_text(cfg) + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "SimpleGraphic.dll").write_bytes(b"\x00binary") + (runtime / "Update").write_bytes(b"\x00posix-executable") # extensionless + lua = runtime / "lua" + lua.mkdir() + (lua / "xml.lua").write_text("-- lua module\n") + src = tmp_path / "src" + src.mkdir() + (src / "Launch.lua").write_text("-- launch\n") + + +def generate(tmp_path, monkeypatch, cfg): + make_repo(tmp_path, cfg) + monkeypatch.chdir(tmp_path) + create_manifest(version="1.2.3", replace=True) + return Et.parse(tmp_path / "manifest.xml").getroot() + + +def test_platform_section_tags_sources_and_files(tmp_path, monkeypatch): + root = generate( + tmp_path, + monkeypatch, + "[runtime]\npath = runtime\nplatform = win32\n\n[program]\npath = src\n", + ) + sources = { + (s.get("part"), s.get("platform")): s.get("url") for s in root.findall("Source") + } + assert ("runtime", "win32") in sources + assert ("program", None) in sources + runtime_files = { + f.get("name"): f for f in root.findall("File") if f.get("part") == "runtime" + } + # every file in a platformed section is tagged, not just .dll/.exe + assert runtime_files["SimpleGraphic.dll"].get("platform") == "win32" + assert runtime_files["lua/xml.lua"].get("platform") == "win32" + # legacy attribute dropped + assert runtime_files["SimpleGraphic.dll"].get("runtime") is None + # extensionless files are included + assert "Update" in runtime_files + program_files = { + f.get("name"): f for f in root.findall("File") if f.get("part") == "program" + } + assert program_files["Launch.lua"].get("platform") is None + + +def test_part_override_allows_multiple_runtime_sections(tmp_path, monkeypatch): + cfg = ( + "[runtime]\npath = runtime\nplatform = win32\n\n" + "[runtime-linux64]\npath = runtime\npart = runtime\nplatform = linux64\n" + ) + root = generate(tmp_path, monkeypatch, cfg) + sources = {(s.get("part"), s.get("platform")) for s in root.findall("Source")} + assert ("runtime", "win32") in sources + assert ("runtime", "linux64") in sources + parts = {f.get("part") for f in root.findall("File")} + assert parts == {"runtime"} + platforms = {f.get("platform") for f in root.findall("File")} + assert platforms == {"win32", "linux64"} diff --git a/update_manifest.py b/update_manifest.py index 929f8f5ea63..4b0d055ec86 100644 --- a/update_manifest.py +++ b/update_manifest.py @@ -75,14 +75,14 @@ def create_manifest(version: str | None = None, replace: bool = False) -> None: base_url = "https://raw.githubusercontent.com/PathOfBuildingCommunity/PathOfBuilding/{branch}/" parts: list[dict[str, str]] = [] - for part in config.sections(): - url = base_url + config[part]["path"] + for section in config.sections(): + url = base_url + config[section]["path"] url_with_trailing_slash = url if url.endswith("/") else url + "/" - attributes = ( - {"part": part, "platform": "win32", "url": url_with_trailing_slash} - if part == "runtime" - else {"part": part, "url": url_with_trailing_slash} - ) + part = config[section].get("part", section) + platform = config[section].get("platform") + attributes = {"part": part, "url": url_with_trailing_slash} + if platform: + attributes = {"part": part, "platform": platform, "url": url_with_trailing_slash} parts.append(attributes) files: list[dict[str, str]] = [] @@ -91,8 +91,12 @@ def create_manifest(version: str | None = None, replace: bool = False) -> None: include_dirs = _parse_list_option(config, section, "include-directories") exclude_files = _parse_list_option(config, section, "exclude-files") exclude_dirs = _parse_list_option(config, section, "exclude-directories") + part = config[section].get("part", section) + platform = config[section].get("platform") source = pathlib.Path(config[section]["path"]) - for path in source.glob("**/*.*"): + for path in source.glob("**/*"): + if not path.is_file(): + continue if include_files and not _exclude_file(include_files, path): continue if include_dirs and not _exclude_directory(include_dirs, path): @@ -107,11 +111,9 @@ def create_manifest(version: str | None = None, replace: bool = False) -> None: data = re.sub(rb"\r\n?|\n", b"\r\n", data) sha1 = hashlib.sha1(data).hexdigest() name = path.relative_to(config[section]["path"]).as_posix() - attributes = ( - {"name": name, "part": section, "runtime": "win32", "sha1": sha1} - if path.suffix in [".dll", ".exe"] - else {"name": name, "part": section, "sha1": sha1} - ) + attributes = {"name": name, "part": part, "sha1": sha1} + if platform: + attributes = {"name": name, "part": part, "platform": platform, "sha1": sha1} files.append(attributes) files.sort(key=lambda attr: (attr["part"], _alphanumeric(attr["name"]))) From 7c0f7391e36b82228cbbb0d5162289b5a5912e50 Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Mon, 3 Aug 2026 19:40:09 -0700 Subject: [PATCH 02/12] feat: bounded retries, chmod op, and clear errors in update apply --- spec/System/TestUpdateApply_spec.lua | 102 +++++++++++++++++++++++++++ src/UpdateApply.lua | 23 ++++-- 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 spec/System/TestUpdateApply_spec.lua diff --git a/spec/System/TestUpdateApply_spec.lua b/spec/System/TestUpdateApply_spec.lua new file mode 100644 index 00000000000..03e740d0313 --- /dev/null +++ b/spec/System/TestUpdateApply_spec.lua @@ -0,0 +1,102 @@ +local lfs = require("lfs") + +describe("UpdateApply", function() + -- Use a temp dir outside the repo: the Docker test harness mounts the repo read-only + local tmpDir = os.tmpname() + os.remove(tmpDir) + local originalSpawnProcess + local originalExecute + local spawned + local executed + + local function writeFile(path, content) + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() + end + + local function readFile(path) + local file = io.open(path, "rb") + if not file then + return nil + end + local content = file:read("*a") + file:close() + return content + end + + local function rmTree(path) + if lfs.attributes(path, "mode") ~= "directory" then + os.remove(path) + return + end + for entry in lfs.dir(path) do + if entry ~= "." and entry ~= ".." then + rmTree(path.."/"..entry) + end + end + lfs.rmdir(path) + end + + local function runApply(ops) + writeFile(tmpDir.."/opFile.txt", table.concat(ops, "\n")) + return pcall(assert(loadfile("UpdateApply.lua")), tmpDir.."/opFile.txt") + end + + before_each(function() + rmTree(tmpDir) + lfs.mkdir(tmpDir) + spawned = { } + executed = { } + originalSpawnProcess = _G.SpawnProcess + originalExecute = os.execute + _G.SpawnProcess = function(target) + table.insert(spawned, target) + end + os.execute = function(command) + table.insert(executed, command) + return 0 + end + end) + + after_each(function() + _G.SpawnProcess = originalSpawnProcess + os.execute = originalExecute + rmTree(tmpDir) + end) + + it("moves files and desanitises {space} in the destination", function() + writeFile(tmpDir.."/staged", "new content") + local ok, err = runApply({ 'move "'..tmpDir..'/staged" "'..tmpDir..'/Path{space}of{space}Building"' }) + assert.is_true(ok, err) + assert.are.equal("new content", readFile(tmpDir.."/Path of Building")) + assert.is_nil(readFile(tmpDir.."/staged")) + end) + + it("deletes files", function() + writeFile(tmpDir.."/stale.lua", "old") + local ok, err = runApply({ 'delete "'..tmpDir..'/stale.lua"' }) + assert.is_true(ok, err) + assert.is_nil(readFile(tmpDir.."/stale.lua")) + end) + + it("marks chmod targets executable, desanitising {space}", function() + local ok, err = runApply({ 'chmod "'..tmpDir..'/Path{space}of{space}Building"' }) + assert.is_true(ok, err) + assert.are.equal(1, #executed) + assert.are.equal('chmod +x "'..tmpDir..'/Path of Building"', executed[1]) + end) + + it("starts processes", function() + local ok, err = runApply({ 'start "'..tmpDir..'/Path of Building"' }) + assert.is_true(ok, err) + assert.are.same({ tmpDir.."/Path of Building" }, spawned) + end) + + it("raises a clear error instead of looping forever when the destination is unwritable", function() + writeFile(tmpDir.."/staged", "new content") + local ok, err = runApply({ 'move "'..tmpDir..'/staged" "'..tmpDir..'/no-such-dir/target"' }) + assert.is_false(ok) + assert.matches("couldn't write", tostring(err)) + end) +end) diff --git a/src/UpdateApply.lua b/src/UpdateApply.lua index 6abaf71ea74..4c11fcfa5c6 100644 --- a/src/UpdateApply.lua +++ b/src/UpdateApply.lua @@ -6,6 +6,8 @@ -- local opFileName = ... +local maxOpenAttempts = 1000 + print("Applying update...") local opFile = io.open(opFileName, "r") if not opFile then @@ -26,20 +28,27 @@ for _, line in ipairs(lines) do print("Updating '"..dst.."'") local srcFile = io.open(src, "rb") assert(srcFile, "couldn't open "..src) - local dstFile - while not dstFile do - dstFile = io.open(dst, "w+b") - end - if dstFile then - dstFile:write(srcFile:read("*a")) - dstFile:close() + local dstFile, openErr + -- The destination may be transiently locked (e.g. antivirus on Windows); retry, but bounded + for _ = 1, maxOpenAttempts do + dstFile, openErr = io.open(dst, "w+b") + if dstFile then + break + end end + assert(dstFile, "couldn't write "..dst..(openErr and (": "..openErr) or "")) + dstFile:write(srcFile:read("*a")) + dstFile:close() srcFile:close() os.remove(src) elseif op == "delete" then local file = args:match('"(.*)"') print("Deleting '"..file.."'") os.remove(file) + elseif op == "chmod" then + local file = args:match('"(.*)"'):gsub("{space}", " ") + print("Marking '"..file.."' as executable") + os.execute('chmod +x "'..file..'"') elseif op == "start" then local target = args:match('"(.*)"') SpawnProcess(target) From ed1a99ccfc8db3410f3e2dcc3295f2603580452f Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Mon, 3 Aug 2026 19:59:26 -0700 Subject: [PATCH 03/12] docs: document the cross-platform architecture and platform model --- CONTRIBUTING.md | 5 +++- docs/crossPlatform.md | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 docs/crossPlatform.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7710d132461..dbd102522ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ The easiest way to make and test changes is by setting up a development installa cd PathOfBuilding 3. Start Path of Building from the repository by running `./runtime/Path{space}of{space}Building.exe`. - * Note for Linux users: The executable files should automatically have the correct permissions when cloned fresh. If you still encounter permission issues, run once: `chmod +x ./runtime/Path{space}of{space}Building-PoE2.exe` + * Note for Linux users: The executable files should automatically have the correct permissions when cloned fresh. If you still encounter permission issues, run once: `chmod +x ./runtime/Path{space}of{space}Building.exe` You can now use the shortcut to run the program from the repository. Running the program in this manner automatically enables "Dev Mode", which has some handy debugging feature: * `F5` restarts the program in-place (this is what usually happens when an update is applied). * `Ctrl` + `~` toggles the console (Note that this does not work with all keyboard layouts. US layout is a safe bet though). @@ -210,6 +210,9 @@ If you're using linux you can run the ./runtime/Path{space}of{space}Building.exe Z:\home\dev\.vscode\extensions\tangzx.emmylua-0.8.20-linux-x64\debugger\emmy\windows\x64\ ``` +See [docs/crossPlatform.md](docs/crossPlatform.md) for how platform support is +structured and what native Linux/macOS support requires. + ## Testing PoB uses the [Busted](https://lunarmodules.github.io/busted/) framework to run its tests. Tests are stored under `spec/System` and run automatically when a PR is modified. diff --git a/docs/crossPlatform.md b/docs/crossPlatform.md new file mode 100644 index 00000000000..05e5e2aab07 --- /dev/null +++ b/docs/crossPlatform.md @@ -0,0 +1,58 @@ +# Cross-platform architecture + +Path of Building is a pure-Lua application (`src/`) that runs on a native host. +The host API contract is specified, in executable form, by +[`src/HeadlessWrapper.lua`](../src/HeadlessWrapper.lua): any host that provides +those globals (rendering, input, filesystem search, clipboard, subscripts, +`Inflate`/`Deflate`, path helpers) can run the app. The shipping host is +SimpleGraphic (built from +[PathOfBuilding-SimpleGraphic](https://github.com/PathOfBuildingCommunity/PathOfBuilding-SimpleGraphic)), +which renders through GLFW + ANGLE (OpenGL ES) and is delivered into `runtime/` +by the `update-simple-graphic` workflow. + +## Platform identity + +A client learns its platform from the `platform` attribute of the `` +element in its local `manifest.xml` (e.g. `win32`). The updater +(`src/UpdateCheck.lua`) then: + +- includes a remote `` iff it has no `platform` attribute or its + `platform` matches the local platform; +- downloads each part from `` matching the + local platform, falling back to the platform-less source. + +`update_manifest.py` generates these attributes from `manifest.cfg`: a section +with a `platform` option tags its source and every file it contains with that +platform. A section may set `part` to publish under a shared part name, so a +future `[runtime-linux64]` section (with `part = runtime`, +`platform = linux64`) ships an alternative runtime bundle without any client +code changes. + +## Update ops + +`UpdateCheck.lua` stages downloads and writes an ops file that +`UpdateApply.lua` executes (`move`, `delete`, `chmod`, `start`). On non-win32 +platforms, updated runtime files without a file extension (the POSIX +executables) get a `chmod` op so they stay executable after being rewritten. +Runtime files are applied by a second, minimal host (`runtime/Update` / +`Update.exe`) because the main host's own binaries cannot replace themselves +while running. + +## Host expectations + +Hosts are not required to provide every global: `GetCloudProvider` is optional, +and `jit.opt` tuning is skipped when unavailable. Asset paths are +case-sensitive on Linux/macOS; `spec/System/TestAssetCase_spec.lua` enforces +that all `Assets/` references match on-disk casing exactly. + +## Status + +Native Linux/macOS support additionally requires (tracked as follow-on plans): + +1. A POSIX/macOS system layer in PathOfBuilding-SimpleGraphic publishing + `SimpleGraphicDLLs--.tar` release assets (the Windows asset + already follows this naming). +2. Per-platform runtime bundles in this repo (`[runtime-]` manifest + sections), ingestion workflow updates, and packaging (tar.gz, then + AppImage/dmg). Until then, Linux users run the Windows build under Wine or + use community hosts such as pobfrontend. From 9fcc0c217c69f0c4d30bd4fb0bd3c02eddf93a6a Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Tue, 4 Aug 2026 10:22:20 -0700 Subject: [PATCH 04/12] fix: close source file when update apply exhausts destination retries --- src/UpdateApply.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/UpdateApply.lua b/src/UpdateApply.lua index 4c11fcfa5c6..4a851446578 100644 --- a/src/UpdateApply.lua +++ b/src/UpdateApply.lua @@ -36,6 +36,9 @@ for _, line in ipairs(lines) do break end end + if not dstFile then + srcFile:close() + end assert(dstFile, "couldn't write "..dst..(openErr and (": "..openErr) or "")) dstFile:write(srcFile:read("*a")) dstFile:close() From 03d6edbbb0e8031e0e987b9e9565c7671a30f5db Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Tue, 4 Aug 2026 10:25:34 -0700 Subject: [PATCH 05/12] feat: preserve executable bits when updating POSIX runtimes --- spec/System/TestUpdateCheck_spec.lua | 177 +++++++++++++++++++++++++++ src/UpdateCheck.lua | 4 + 2 files changed, 181 insertions(+) create mode 100644 spec/System/TestUpdateCheck_spec.lua diff --git a/spec/System/TestUpdateCheck_spec.lua b/spec/System/TestUpdateCheck_spec.lua new file mode 100644 index 00000000000..cf6325e8ca9 --- /dev/null +++ b/spec/System/TestUpdateCheck_spec.lua @@ -0,0 +1,177 @@ +local lfs = require("lfs") +local sha1 = require("sha1") + +describe("UpdateCheck", function() + -- Use a temp dir outside the repo: the Docker test harness mounts the repo read-only + local tmpDir = os.tmpname() + os.remove(tmpDir) + local originalRequire + local originalMakeDir + local originalGetScriptPath + local originalGetRuntimePath + + local programContent = "-- new launch script\n" + local runtimeContent = "\0new runtime binary" + local soContent = "\0new shared object" + + local function writeFile(path, content) + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() + end + + local function readFile(path) + local file = io.open(path, "rb") + if not file then + return nil + end + local content = file:read("*a") + file:close() + return content + end + + local function rmTree(path) + if lfs.attributes(path, "mode") ~= "directory" then + os.remove(path) + return + end + for entry in lfs.dir(path) do + if entry ~= "." and entry ~= ".." then + rmTree(path.."/"..entry) + end + end + lfs.rmdir(path) + end + + local function newFakeCurl(responses) + local curl = { OPT_ACCEPT_ENCODING = 0, OPT_IPRESOLVE = 1, OPT_PROXY = 2, OPT_SSL_VERIFYPEER = 3, OPT_SSL_VERIFYHOST = 4 } + function curl.easy() + local easy = { url = "" } + function easy:escape(text) + return text + end + function easy:setopt_url(url) + self.url = url + end + function easy:setopt() + end + function easy:setopt_writefunction(sink) + self.sink = sink + end + function easy:perform() + local content = responses[self.url] + if not content then + return nil, { msg = function() return "404: "..self.url end } + end + if type(self.sink) == "function" then + self.sink(content) + else + self.sink:write(content) + end + return true, nil + end + function easy:close() + end + return easy + end + return curl + end + + -- Builds a local manifest on disk and returns the canned remote responses + local function setUpManifests(platform) + writeFile(tmpDir.."/manifest.xml", table.concat({ + '', + '', + '\t', + '\t', + '\t', + '\t', + '\t', + '', + }, "\n")) + local remoteManifest = table.concat({ + '', + '', + '\t', + '\t', + '\t', + '\t', + '\t', + '\t', + '\t', + '', + }, "\n") + return { + ["http://fake/manifest.xml"] = remoteManifest, + ["http://fake/changelog.txt"] = "changelog", + ["http://fake/prog/Launch.lua"] = programContent, + ["http://fake/rt/Path{space}of{space}Building"] = runtimeContent, + ["http://fake/rt/SimpleGraphic.so"] = soContent, + } + end + + local function runUpdateCheck(platform) + local responses = setUpManifests(platform) + local fakeCurl = newFakeCurl(responses) + _G.require = function(name) + if name == "lcurl.safe" then + return fakeCurl + elseif name == "lzip" then + return { } + end + return originalRequire(name) + end + return assert(loadfile("UpdateCheck.lua"))() + end + + before_each(function() + rmTree(tmpDir) + lfs.mkdir(tmpDir) + lfs.mkdir(tmpDir.."/runtime") + originalRequire = _G.require + originalMakeDir = _G.MakeDir + originalGetScriptPath = _G.GetScriptPath + originalGetRuntimePath = _G.GetRuntimePath + _G.GetScriptPath = function() + return tmpDir + end + _G.GetRuntimePath = function() + return tmpDir.."/runtime" + end + _G.MakeDir = function(path) + if path:sub(1, 1) ~= "/" then + path = tmpDir.."/"..path + end + lfs.mkdir(path) + return true + end + end) + + after_each(function() + _G.require = originalRequire + _G.MakeDir = originalMakeDir + _G.GetScriptPath = originalGetScriptPath + _G.GetRuntimePath = originalGetRuntimePath + rmTree(tmpDir) + end) + + it("stages runtime updates with chmod ops for extensionless files on linux64", function() + local mode = runUpdateCheck("linux64") + assert.are.equal("basic", mode) + local opsRuntime = assert(readFile(tmpDir.."/Update/opFileRuntime.txt")) + assert.is_truthy(opsRuntime:find('move "'..tmpDir..'/Update/Path{space}of{space}Building" "'..tmpDir..'/runtime/Path{space}of{space}Building"', 1, true)) + assert.is_truthy(opsRuntime:find('chmod "'..tmpDir..'/runtime/Path{space}of{space}Building"', 1, true)) + -- files with extensions never get chmod + assert.is_falsy(opsRuntime:find('chmod "'..tmpDir..'/runtime/SimpleGraphic.so"', 1, true)) + assert.is_truthy(opsRuntime:find('start "'..tmpDir..'/runtime/Path of Building"', 1, true)) + local ops = assert(readFile(tmpDir.."/Update/opFile.txt")) + assert.is_truthy(ops:find('move "'..tmpDir..'/Update/Launch.lua" "'..tmpDir..'/Launch.lua"', 1, true)) + end) + + it("emits no chmod ops on win32", function() + local mode = runUpdateCheck("win32") + assert.are.equal("basic", mode) + local opsRuntime = assert(readFile(tmpDir.."/Update/opFileRuntime.txt")) + assert.is_falsy(opsRuntime:find("chmod", 1, true)) + end) +end) diff --git a/src/UpdateCheck.lua b/src/UpdateCheck.lua index 969357151d0..b987e498c5e 100644 --- a/src/UpdateCheck.lua +++ b/src/UpdateCheck.lua @@ -315,6 +315,10 @@ for _, data in pairs(updateFiles) do -- These files will be updated on the second pass of the update script, with the first pass being run within the normal environment updateMode = "basic" table.insert(opsRuntime, 'move "'..data.updateFileName..'" "'..data.fullPath..'"') + if localPlatform ~= "win32" and not data.name:match("%.[^/]+$") then + -- POSIX executables ship without an extension and lose their executable bit when rewritten + table.insert(opsRuntime, 'chmod "'..data.fullPath..'"') + end else table.insert(ops, 'move "'..data.updateFileName..'" "'..data.fullPath..'"') end From 96f4bd7ba99e313b9938146fc7481f6b7426e2ac Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Tue, 4 Aug 2026 10:30:37 -0700 Subject: [PATCH 06/12] fix: tolerate hosts without GetCloudProvider or LuaJIT opt API --- spec/System/TestCloudErrorPopup_spec.lua | 14 ++++++++++++++ src/Launch.lua | 4 +++- src/Modules/Main.lua | 5 ++++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 spec/System/TestCloudErrorPopup_spec.lua diff --git a/spec/System/TestCloudErrorPopup_spec.lua b/spec/System/TestCloudErrorPopup_spec.lua new file mode 100644 index 00000000000..49ea5509372 --- /dev/null +++ b/spec/System/TestCloudErrorPopup_spec.lua @@ -0,0 +1,14 @@ +describe("OpenCloudErrorPopup", function() + it("works when the host does not provide GetCloudProvider", function() + local originalGetCloudProvider = _G.GetCloudProvider + _G.GetCloudProvider = nil + local ok, err = pcall(function() + main:OpenCloudErrorPopup("SomeBuild.xml") + end) + _G.GetCloudProvider = originalGetCloudProvider + if ok then + main:ClosePopup() + end + assert.is_true(ok, tostring(err)) + end) +end) diff --git a/src/Launch.lua b/src/Launch.lua index 2453884dafe..f796e8923a2 100644 --- a/src/Launch.lua +++ b/src/Launch.lua @@ -14,7 +14,9 @@ ConExecute("set vid_resizable 3") launch = { } SetMainObject(launch) -jit.opt.start('maxtrace=4000','maxmcode=8192') +if jit and jit.opt then + jit.opt.start('maxtrace=4000','maxmcode=8192') +end collectgarbage("setpause", 400) function launch:OnInit() diff --git a/src/Modules/Main.lua b/src/Modules/Main.lua index 0e7b0d48c1b..9c2c427dbbb 100644 --- a/src/Modules/Main.lua +++ b/src/Modules/Main.lua @@ -1721,7 +1721,10 @@ end -- Show an error popup if a file cannot be read due to cloud provider unavailability. -- Help button opens a URL to PoB's GitHub wiki. function main:OpenCloudErrorPopup(fileName) - local provider, _, status = GetCloudProvider(fileName) + local provider, _, status + if GetCloudProvider then + provider, _, status = GetCloudProvider(fileName) + end ConPrintf('^1Error: file offline "%s" provider: "%s" status: "%s"', fileName or "?", provider, status) fileName = fileName and "\n\n^8'"..fileName.."'" or "" local version = "^8v"..launch.versionNumber..(launch.versionBranch and " "..launch.versionBranch or "")..(launch.devMode and " (dev)" or "") From a41ec8b5a4924b5442f7b135817dd3e86218b62c Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Tue, 4 Aug 2026 10:34:31 -0700 Subject: [PATCH 07/12] test: enforce case-sensitive asset references for POSIX filesystems --- spec/System/TestAssetCase_spec.lua | 61 ++++++++++++++++++++++++++++++ src/Classes/ItemsTab.lua | 2 +- src/Classes/Tooltip.lua | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 spec/System/TestAssetCase_spec.lua diff --git a/spec/System/TestAssetCase_spec.lua b/spec/System/TestAssetCase_spec.lua new file mode 100644 index 00000000000..50740d57c32 --- /dev/null +++ b/spec/System/TestAssetCase_spec.lua @@ -0,0 +1,61 @@ +local lfs = require("lfs") + +describe("Asset references", function() + -- Collect actual files under a directory with their exact on-disk casing + local function collectFiles(dir, prefix, out) + out = out or { } + for entry in lfs.dir(dir) do + if entry ~= "." and entry ~= ".." then + local full = dir.."/"..entry + local rel = prefix..entry + if lfs.attributes(full, "mode") == "directory" then + collectFiles(full, rel.."/", out) + else + out[rel] = true + end + end + end + return out + end + + -- Collect Lua sources, skipping data/export dirs that don't reference assets + local function collectLuaFiles(dir, out) + out = out or { } + for entry in lfs.dir(dir) do + if entry ~= "." and entry ~= ".." then + local full = dir.."/"..entry + local mode = lfs.attributes(full, "mode") + if mode == "directory" then + if entry ~= "Data" and entry ~= "TreeData" and entry ~= "Export" and entry ~= "Builds" then + collectLuaFiles(full, out) + end + elseif entry:match("%.lua$") then + table.insert(out, full) + end + end + end + return out + end + + it("match on-disk filenames exactly (case-sensitive)", function() + local actual = collectFiles("Assets", "Assets/") + local lowerToActual = { } + for name in pairs(actual) do + lowerToActual[name:lower()] = name + end + local mismatches = { } + for _, luaFile in ipairs(collectLuaFiles(".")) do + local file = assert(io.open(luaFile, "rb")) + local content = file:read("*a") + file:close() + for ref in content:gmatch('"(Assets/[%w_%-%./]+)"') do + if not actual[ref] then + local hint = lowerToActual[ref:lower()] + table.insert(mismatches, string.format("%s references %q%s", + luaFile, ref, hint and (" (on disk: %q)"):format(hint) or " (no such file)")) + end + end + end + assert.are.equal(0, #mismatches, "\n"..table.concat(mismatches, "\n")) + end) +end) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 6ca70529cf3..ebbcb5ad8b6 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -959,7 +959,7 @@ holding Shift will put it in the second.]]) end end}) local foulbornIcon = NewImageHandle() - foulbornIcon:Load("Assets/breachicon.png") + foulbornIcon:Load("Assets/BreachIcon.png") self.controls.displayItemRangeLine = new("DropDownControl", {"TOPLEFT",self.controls.displayItemSectionRange,"TOPLEFT"}, {0, 0, 350, 18}, nil, function(index, value) self.controls.displayItemRangeSlider.val = self.displayItem.rangeLineList[index].range end) diff --git a/src/Classes/Tooltip.lua b/src/Classes/Tooltip.lua index a8cb7b22ada..99fce14d328 100644 --- a/src/Classes/Tooltip.lua +++ b/src/Classes/Tooltip.lua @@ -399,7 +399,7 @@ function TooltipClass:Draw(x, y, w, h, viewPort) Exarch = "Assets/exarchicon.png", Synthesis = "Assets/synthesisicon.png", Experimented = "Assets/experimentedicon.png", - Foulborn = "Assets/breachicon.png", + Foulborn = "Assets/BreachIcon.png", } local headerConfigs = { RELIC = {left="Assets/itemsheaderfoilleft.png",middle="Assets/itemsheaderfoilmiddle.png",right="Assets/itemsheaderfoilright.png",height=54,sideWidth=47,middleWidth=52,textYOffset=1,allowInfluenceIcon=true}, From 348c025bdc1e3204c47605e24be051c19bc368a3 Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Tue, 4 Aug 2026 10:48:43 -0700 Subject: [PATCH 08/12] chore: regenerate manifest hashes for branch-modified files --- manifest.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/manifest.xml b/manifest.xml index c9e335086a1..cabd2180ff5 100644 --- a/manifest.xml +++ b/manifest.xml @@ -146,7 +146,7 @@ - + @@ -181,7 +181,7 @@ - + @@ -367,7 +367,7 @@ - + @@ -395,15 +395,15 @@ - + - - + + From d07dec94c502e81bbc311aac9ab531eba0fb154d Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Wed, 5 Aug 2026 20:12:37 -0700 Subject: [PATCH 09/12] feat: add Makefile with macOS app build targets --- .gitignore | 3 ++- Makefile | 40 ++++++++++++++++++++++++++++++++ scripts/build-macos-app.sh | 47 ++++++++++++++++++++++++++++++++++++++ scripts/macos/Info.plist | 33 ++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100755 scripts/build-macos-app.sh create mode 100644 scripts/macos/Info.plist diff --git a/.gitignore b/.gitignore index e3f53a15c52..42a094cc540 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ src/Data/TimelessJewelData/*.bin runtime/imgui.ini runtime/SimpleGraphic/SimpleGraphic.log -src/poe_api_response.json \ No newline at end of file +src/poe_api_response.json +build/ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000000..171e624442a --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# Path of Building — developer entry points. +# macOS app targets require a sibling clone of PathOfBuilding-SimpleGraphic +# (branch feat/macos-build); override SG_DIR if yours lives elsewhere. + +SG_DIR ?= $(HOME)/dev/thirdparty/PathOfBuilding-SimpleGraphic +SG_DIST := $(SG_DIR)/build/dist +BUILD_DIR := build/macos +APP_NAME := Path of Building.app +TEST_IMAGE := ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest + +.PHONY: test test-python manifest macos-runtime macos-app run-macos clean-macos + +test: + docker run --rm --platform linux/amd64 -e HOME=/tmp -v "$(CURDIR)":/workdir:ro -w /workdir $(TEST_IMAGE) busted --lua=luajit + +test-python: + python3 -m pytest tests/ -v + +manifest: + python3 update_manifest.py --in-place + +$(SG_DIR)/CMakeLists.txt: + @echo "error: SimpleGraphic clone not found at $(SG_DIR)"; \ + echo " git clone https://github.com/PathOfBuildingCommunity/PathOfBuilding-SimpleGraphic.git $(SG_DIR)"; \ + echo " (then check out branch feat/macos-build and init submodules)"; \ + exit 1 + +macos-runtime: $(SG_DIR)/CMakeLists.txt + cmake -B "$(SG_DIR)/build" -S "$(SG_DIR)" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DVCPKG_TARGET_TRIPLET=arm64-osx + cmake --build "$(SG_DIR)/build" + cmake --install "$(SG_DIR)/build" --prefix "$(SG_DIST)" + +macos-app: macos-runtime + SG_DIST="$(SG_DIST)" BUILD_DIR="$(BUILD_DIR)" POB_ROOT="$(CURDIR)" bash scripts/build-macos-app.sh + +run-macos: macos-app + open "$(BUILD_DIR)/$(APP_NAME)" + +clean-macos: + rm -rf "$(BUILD_DIR)" diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 00000000000..7c4ae9323ff --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Assembles Path of Building.app from the SimpleGraphic install tree. +# Contents/MacOS mirrors the Windows runtime/ layout: the host resolves fonts, +# lua modules, and native modules relative to the executable's directory. +set -euo pipefail + +: "${SG_DIST:?SG_DIST not set}" +: "${BUILD_DIR:?BUILD_DIR not set}" +: "${POB_ROOT:?POB_ROOT not set}" + +for f in pob libSimpleGraphic.dylib lcurl.so lua-utf8.so socket.so lzip.so libEGL.dylib; do + if [[ ! -e "$SG_DIST/$f" ]]; then + echo "error: missing $SG_DIST/$f — run 'make macos-runtime' (see docs/crossPlatform.md)" >&2 + exit 1 + fi +done + +APP="$BUILD_DIR/Path of Building.app" +MACOS_DIR="$APP/Contents/MacOS" +rm -rf "$APP" +mkdir -p "$MACOS_DIR" + +# Native runtime: launcher (renamed to match CFBundleExecutable), dylibs, Lua modules +cp "$SG_DIST/pob" "$MACOS_DIR/Path of Building" +find "$SG_DIST" -maxdepth 1 \( -name '*.dylib' -o -name '*.so' \) -exec cp -R {} "$MACOS_DIR/" \; +# Pure-Lua modules and fonts from the repo's platform-neutral runtime dir +cp -R "$POB_ROOT/runtime/lua" "$MACOS_DIR/lua" +mkdir -p "$MACOS_DIR/SimpleGraphic" +cp -R "$POB_ROOT/runtime/SimpleGraphic/Fonts" "$MACOS_DIR/SimpleGraphic/Fonts" + +cp "$POB_ROOT/scripts/macos/Info.plist" "$APP/Contents/Info.plist" + +# The app runs the Lua program from this checkout (dev mode: updater disabled, +# user data lives in the checkout). POB_SCRIPT_PATH overrides at launch. +cat > "$MACOS_DIR/launch-env.sh" < shell wrapper baking the script path. +mv "$MACOS_DIR/Path of Building" "$MACOS_DIR/Path of Building.bin" +mv "$MACOS_DIR/launch-env.sh" "$MACOS_DIR/Path of Building" +chmod +x "$MACOS_DIR/Path of Building" "$MACOS_DIR/Path of Building.bin" + +codesign --force --deep -s - "$APP" +echo "built: $APP" diff --git a/scripts/macos/Info.plist b/scripts/macos/Info.plist new file mode 100644 index 00000000000..85d0930250d --- /dev/null +++ b/scripts/macos/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleName + Path of Building + CFBundleDisplayName + Path of Building + CFBundleIdentifier + community.pathofbuilding.dev + CFBundleExecutable + Path of Building + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + CFBundleURLTypes + + + CFBundleURLName + Path of Building import link + CFBundleURLSchemes + + pob + + + + + From c5e6b497fb4587e65bad9f0898615d61636e09bd Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Wed, 5 Aug 2026 20:17:22 -0700 Subject: [PATCH 10/12] docs: document the macOS app build --- CONTRIBUTING.md | 15 +++++++++++++++ docs/crossPlatform.md | 12 +++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd102522ed..784b61a9fa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,6 +213,21 @@ Z:\home\dev\.vscode\extensions\tangzx.emmylua-0.8.20-linux-x64\debugger\emmy\win See [docs/crossPlatform.md](docs/crossPlatform.md) for how platform support is structured and what native Linux/macOS support requires. +### macOS + +A native arm64 app can be built from source: + +1. Install prerequisites: Xcode, plus `brew install cmake ninja`. +2. Clone the host repo as a sibling: + `git clone https://github.com/PathOfBuildingCommunity/PathOfBuilding-SimpleGraphic.git ../PathOfBuilding-SimpleGraphic` + and check out its `feat/macos-build` branch (`git submodule update --init --recursive`). +3. From this repo: `make macos-app` (first run builds all native + dependencies via vcpkg — expect 30-60 minutes), then `make run-macos`. + +The app runs in dev mode from your checkout: update with `git pull`, user +data lives in `src/`. Override the checkout with `POB_SCRIPT_PATH` or the +host clone location with `make SG_DIR=/path/to/clone macos-app`. + ## Testing PoB uses the [Busted](https://lunarmodules.github.io/busted/) framework to run its tests. Tests are stored under `spec/System` and run automatically when a PR is modified. diff --git a/docs/crossPlatform.md b/docs/crossPlatform.md index 05e5e2aab07..2a1aef04bf1 100644 --- a/docs/crossPlatform.md +++ b/docs/crossPlatform.md @@ -53,6 +53,12 @@ Native Linux/macOS support additionally requires (tracked as follow-on plans): `SimpleGraphicDLLs--.tar` release assets (the Windows asset already follows this naming). 2. Per-platform runtime bundles in this repo (`[runtime-]` manifest - sections), ingestion workflow updates, and packaging (tar.gz, then - AppImage/dmg). Until then, Linux users run the Windows build under Wine or - use community hosts such as pobfrontend. + sections), ingestion workflow updates, and packaging. Until then, Linux + users run the Windows build under Wine or use community hosts such as + pobfrontend. + +On macOS, a native app can be built locally today: `make macos-app` builds +the SimpleGraphic host from a sibling clone (branch `feat/macos-build`) and +assembles `build/macos/Path of Building.app`, a dev-mode app running +`src/Launch.lua` from this checkout (auto-updates disabled by design — see +the spec in `docs/superpowers/specs/2026-08-05-macos-app-design.md`). From cecdaf0ba9fbaf1a630928a94563b45be2378e4b Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Wed, 5 Aug 2026 20:21:26 -0700 Subject: [PATCH 11/12] docs: clarify macOS host branch is pending upstream submission Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 5 ++++- docs/crossPlatform.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 784b61a9fa7..84ffd67d4b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -220,7 +220,10 @@ A native arm64 app can be built from source: 1. Install prerequisites: Xcode, plus `brew install cmake ninja`. 2. Clone the host repo as a sibling: `git clone https://github.com/PathOfBuildingCommunity/PathOfBuilding-SimpleGraphic.git ../PathOfBuilding-SimpleGraphic` - and check out its `feat/macos-build` branch (`git submodule update --init --recursive`). + then run `git submodule update --init --recursive`. The macOS build also + needs the `feat/macos-build` changes, which are not yet merged upstream — + see [docs/crossPlatform.md](docs/crossPlatform.md) for their status; until + they land, use a checkout that already contains them. 3. From this repo: `make macos-app` (first run builds all native dependencies via vcpkg — expect 30-60 minutes), then `make run-macos`. diff --git a/docs/crossPlatform.md b/docs/crossPlatform.md index 2a1aef04bf1..3e86cd8b727 100644 --- a/docs/crossPlatform.md +++ b/docs/crossPlatform.md @@ -58,7 +58,7 @@ Native Linux/macOS support additionally requires (tracked as follow-on plans): pobfrontend. On macOS, a native app can be built locally today: `make macos-app` builds -the SimpleGraphic host from a sibling clone (branch `feat/macos-build`) and +the SimpleGraphic host from a sibling clone (branch `feat/macos-build`, currently local-only, pending upstream submission) and assembles `build/macos/Path of Building.app`, a dev-mode app running `src/Launch.lua` from this checkout (auto-updates disabled by design — see the spec in `docs/superpowers/specs/2026-08-05-macos-app-design.md`). From da9ca5d76c6336f19e592eb356e6d3d892397f86 Mon Sep 17 00:00:00 2001 From: "Juan C. Diaz" Date: Wed, 5 Aug 2026 20:33:52 -0700 Subject: [PATCH 12/12] docs: record macOS as-built details, limitations, and upstream checklist Closes final-review findings: documents the pob:// URL delivery limitation (CLI-arg delivery works, Apple-Event delivery to a running instance does not), bundle-internal runtime state discarded on rebuild, the as-built deviations from the design doc, and the pre-upstream-PR checklist for SimpleGraphic feat/macos-build. --- CONTRIBUTING.md | 7 +++++++ docs/crossPlatform.md | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84ffd67d4b9..5cebaa38d5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -231,6 +231,13 @@ The app runs in dev mode from your checkout: update with `git pull`, user data lives in `src/`. Override the checkout with `POB_SCRIPT_PATH` or the host clone location with `make SG_DIR=/path/to/clone macos-app`. +Known limitations of the local app: `pob://` links launch the app but URL +delivery into the running program is best-effort (links to an +already-running instance are not received); and the app writes some runtime +state (`imgui.ini`, logs) inside its own bundle, which is discarded on the +next `make macos-app`. Build/character data is unaffected — it lives in +`src/` in your checkout. + ## Testing PoB uses the [Busted](https://lunarmodules.github.io/busted/) framework to run its tests. Tests are stored under `spec/System` and run automatically when a PR is modified. diff --git a/docs/crossPlatform.md b/docs/crossPlatform.md index 3e86cd8b727..bd5f1797a2a 100644 --- a/docs/crossPlatform.md +++ b/docs/crossPlatform.md @@ -60,5 +60,7 @@ Native Linux/macOS support additionally requires (tracked as follow-on plans): On macOS, a native app can be built locally today: `make macos-app` builds the SimpleGraphic host from a sibling clone (branch `feat/macos-build`, currently local-only, pending upstream submission) and assembles `build/macos/Path of Building.app`, a dev-mode app running -`src/Launch.lua` from this checkout (auto-updates disabled by design — see -the spec in `docs/superpowers/specs/2026-08-05-macos-app-design.md`). +`src/Launch.lua` from this checkout. Auto-updates are disabled by design: +the published manifest has no macOS runtime section yet, so an +update-enabled macOS install would delete its own native runtime (see +"Update ops" above).