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/CONTRIBUTING.md b/CONTRIBUTING.md
index 7710d132461..5cebaa38d5e 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,34 @@ 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.
+
+### 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`
+ 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`.
+
+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/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/docs/crossPlatform.md b/docs/crossPlatform.md
new file mode 100644
index 00000000000..bd5f1797a2a
--- /dev/null
+++ b/docs/crossPlatform.md
@@ -0,0 +1,66 @@
+# 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. 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`, 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 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).
diff --git a/manifest.cfg b/manifest.cfg
index 4bbd597311d..6bbd8cd09f8 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 6dccb496a45..389d5e940ca 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -150,7 +150,7 @@
-
+
@@ -185,7 +185,7 @@
-
+
@@ -371,7 +371,7 @@
-
+
@@ -383,7 +383,7 @@
-
+
@@ -399,171 +399,171 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
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/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/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/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/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua
index 1380881c023..91604130ff5 100644
--- a/src/Classes/ItemsTab.lua
+++ b/src/Classes/ItemsTab.lua
@@ -1021,7 +1021,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 1906629b706..cf604faa953 100644
--- a/src/Classes/Tooltip.lua
+++ b/src/Classes/Tooltip.lua
@@ -400,7 +400,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",
Memory = "Assets/memoryicon.png",
Vestigial = "Assets/vestigialicon.png",
}
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 "")
diff --git a/src/UpdateApply.lua b/src/UpdateApply.lua
index 6abaf71ea74..4a851446578 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,30 @@ 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")
+ 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
- if dstFile then
- dstFile:write(srcFile:read("*a"))
- dstFile:close()
+ if not dstFile then
+ srcFile:close()
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)
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
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"])))