feat(ludic.update): a Velopack game updating itself, a frame at a time

latest.json asked once and polled (the newest version and its notes, in
a language or English); then Velopack's releases.<os>.json, the plan (the
delta chain when the installed full package is on disk, no delta is
missing and the chain weighs less than the full one; the newest full
package otherwise, named as the feed names it), each package streamed to
the packages folder with its bytes on a bar and an eased pace, its
SHA-256 by Get-FileHash or shasum in a child, Update patch per delta and
Update apply --waitPid. macOS keeps its packages outside the bundle and
names --rootDir and --packageDir; a translocated app is not installed.

Ports with the runtime as every default: UpdateWorld (platform, exe, pid,
now_ms), UpdateNet (get, download, poll, text, received, free),
UpdateProc (spawn, poll, free). Config: feed, app_id, version, root,
packages, mac_packages, scratch. It words nothing: states, UPDATE_ERR_*
codes and numbers, and UPDATE_STARTED / _STOPPED / _APPLYING facts - the
game exits on the last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 05:44:02 +03:00
parent 63e29f024c
commit 3514d46954
13 changed files with 1103 additions and 0 deletions

View file

@ -0,0 +1,270 @@
# update_test.ludic - the newest version and its notes, the plan (deltas or the whole package), and
# the install step by step, against a fake network, fake children and a stand-in root on disk
import "ludic.update"
import "ludic.base"
program UpdateTest {
numbers float
var net_answer: int = -1
var net_text: string = ""
var net_urls: []string = null
var net_paths: []string = null
var net_got: int = 0
var proc_code: int = -1
var proc_paths: []string = null
var proc_args: []string = null
var exe: string = ""
function fake_platform() -> string { return "macos" }
function fake_exe() -> string { return exe }
function fake_pid() -> int { return 4242 }
function fake_ms() -> int { return 0 }
function fake_get(url: string) -> int {
push(net_urls, url)
push(net_paths, "")
return len(net_urls) - 1
}
function fake_download(url: string, path: string) -> int {
push(net_urls, url)
push(net_paths, path)
return len(net_urls) - 1
}
function fake_poll(h: int) -> int { return net_answer }
function fake_text(h: int) -> string { return net_text }
function fake_received(h: int) -> int { return net_got }
function fake_free(h: int) -> void { }
function fake_spawn(path: string, args: []string) -> int {
push(proc_paths, path)
push(proc_args, Text.join(args, " "))
return len(proc_paths) - 1
}
function fake_proc_poll(h: int) -> int { return proc_code }
function fake_proc_free(h: int) -> void { }
bind UpdateWorld { platform: fn fake_platform, exe: fn fake_exe, pid: fn fake_pid, now_ms: fn fake_ms }
bind UpdateNet { get: fn fake_get, download: fn fake_download, poll: fn fake_poll, text: fn fake_text, received: fn fake_received, free: fn fake_free }
bind UpdateProc { spawn: fn fake_spawn, poll: fn fake_proc_poll, free: fn fake_proc_free }
function dir() -> string { return Os.temp_dir() + "/ludic-update-test" }
function pkgs() -> string { return dir() + "/packages" }
function hashf() -> string { return dir() + "/hash.txt" }
function fresh(root: bool) -> void {
net_answer = -1
net_text = ""
net_urls = new []string
net_paths = new []string
net_got = 0
proc_code = -1
proc_paths = new []string
proc_args = new []string
exe = dir() + "/Game.app/Contents/MacOS/Game"
System.run("rm -rf '" + dir() + "'")
Fs.mkdir(dir())
Fs.mkdir(pkgs())
let c = new UpdateConfig
c.feed = "http://feed"
c.app_id = "Game"
c.version = "1.0.0"
if root { c.root = dir() + "/Game.app" }
c.packages = pkgs()
c.scratch = hashf()
update_config(c)
update_reset()
}
function asset(ty: string, ver: string, size: int) -> string {
let file = "Game-" + ver + "-osx-" + Text.lower(ty) + ".nupkg"
return "{\"PackageId\":\"Game\",\"Type\":\"" + ty + "\",\"Version\":\"" + ver + "\",\"FileName\":\"" + file + "\",\"SHA256\":\"ab" + ver + "\",\"Size\":" + string(size) + "}"
}
# 1.0.0 installed; 1.1.0 and 1.2.0 out, each with a delta
function feed(with_delta_11: bool) -> string {
var s = "{\"Assets\":[" + asset("Full", "1.0.0", 900) + "," + asset("Full", "1.1.0", 1000) + "," + asset("Full", "1.2.0", 1000)
if with_delta_11 { s = s + "," + asset("Delta", "1.1.0", 50) }
return s + "," + asset("Delta", "1.2.0", 60) + "]}"
}
function seed_base() -> void { Fs.write_text(pkgs() + "/Game-1.0.0-osx-full.nupkg", "base") }
function plan_of(text: string) -> bool { return update_plan(Json.parse(text)) }
function facts_of(what: int) -> int {
let fs = q_drain(update_facts())
var n = 0
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
function hash_is(h: string) -> void { Fs.write_text(hashf(), h + "\n") }
function last_args() -> string { return proc_args[len(proc_args) - 1] }
function last_url() -> string { return net_urls[len(net_urls) - 1] }
test "versions compare a number at a time" {
expect(update_newer("0.10.2", "0.9.9"))
expect(not update_newer("0.5.1", "0.5.1"))
expect(update_newer("1.0", "0.99.99"))
expect(not update_newer("0.4.9", "0.5.0"))
}
test "a newer version is shown with its notes, in the language asked for or English" {
fresh(true)
update_check_tick(false)
expect(last_url() == "http://feed/latest.json")
net_text = "{\"version\":\"1.2.0\",\"notes\":{\"en\":[\"Fish.\",\"Boats.\"],\"tr\":[\"Balik.\"]}}"
net_answer = 1
update_check_tick(false)
expect(update_fresh())
expect(update_latest() == "1.2.0")
expect_eq(len(update_notes("tr")), 1)
expect_eq(len(update_notes("de")), 2)
}
test "the version being played is shown only when asked" {
fresh(true)
net_text = "{\"version\":\"1.0.0\",\"notes\":{\"en\":[\"x\"]}}"
net_answer = 1
update_check_tick(false)
update_check_tick(false)
expect(not update_shown())
update_reset()
update_check_tick(true)
update_check_tick(true)
expect_eq(update_ask_state(), UPDATE_CURRENT)
update_reset()
net_answer = 0
update_check_tick(true)
update_check_tick(true)
expect_eq(update_ask_state(), UPDATE_NONE)
}
test "with the base on disk and every delta there, the plan is the chain, oldest first" {
fresh(true)
seed_base()
expect(plan_of(feed(true)))
expect_eq(update_package_count(), 2)
expect(update_plan_is_delta(0))
expect(update_plan_version(0) == "1.1.0")
expect(update_plan_version(1) == "1.2.0")
expect(update_target() == "1.2.0")
}
test "no base, or a missing delta, is the whole newest package" {
fresh(true)
expect(plan_of(feed(true)))
expect_eq(update_package_count(), 1)
expect(not update_plan_is_delta(0))
expect(update_plan_file(0) == "Game-1.2.0-osx-full.nupkg")
seed_base()
expect(plan_of(feed(false)))
expect_eq(update_package_count(), 1)
}
test "nothing newer is no plan" {
fresh(true)
expect(not plan_of("{\"Assets\":[" + asset("Full", "1.0.0", 900) + "]}"))
expect(not plan_of("{}"))
}
test "a copy Velopack did not install does not start" {
fresh(false)
expect(not update_installed())
expect(not update_start())
expect_eq(update_error(), UPDATE_ERR_NOT_INSTALLED)
Fs.mkdir(dir() + "/Game.app")
Fs.mkdir(dir() + "/Game.app/Contents")
Fs.mkdir(dir() + "/Game.app/Contents/MacOS")
Fs.write_text(dir() + "/Game.app/Contents/MacOS/UpdateMac", "x")
expect(update_installed())
exe = "/private/var/folders/x/AppTranslocation/y/Game.app/Contents/MacOS/Game"
expect(not update_installed())
}
test "the whole package: fetched, checked, applied, and the game told to go" {
fresh(true)
expect(update_start())
expect(last_url() == "http://feed/releases.osx.json")
net_text = feed(true)
net_answer = 1
update_tick()
expect_eq(update_state(), UPDATE_FETCH)
expect_eq(facts_of(UPDATE_STARTED), 1)
expect(last_url() == "http://feed/Game-1.2.0-osx-full.nupkg")
net_got = 500
expect(update_progress() >= 490 and update_progress() <= 510)
update_tick()
expect_eq(update_state(), UPDATE_CHECK)
expect(Text.contains(last_args(), "shasum -a 256"))
hash_is("AB1.2.0")
proc_code = 0
update_tick()
expect_eq(update_state(), UPDATE_APPLY)
expect(Text.contains(last_args(), "apply --silent --waitPid 4242 --package"))
expect(Text.contains(last_args(), "--rootDir"))
expect(Text.ends_with(proc_paths[len(proc_paths) - 1], "Game.app/Contents/MacOS/UpdateMac"))
expect_eq(facts_of(UPDATE_APPLYING), 1)
}
test "a wrong hash stops it and deletes the file" {
fresh(true)
update_start()
net_text = feed(true)
net_answer = 1
update_tick()
Fs.write_text(pkgs() + "/Game-1.2.0-osx-full.nupkg", "junk")
update_tick()
hash_is("FFFF")
proc_code = 0
update_tick()
expect(update_failed())
expect_eq(update_error(), UPDATE_ERR_CORRUPT)
expect(not Fs.exists(pkgs() + "/Game-1.2.0-osx-full.nupkg"))
expect_eq(facts_of(UPDATE_STOPPED), 1)
}
test "a failed download stops it" {
fresh(true)
update_start()
net_text = feed(true)
net_answer = 1
update_tick()
net_answer = 0
update_tick()
expect_eq(update_error(), UPDATE_ERR_DOWNLOAD)
}
test "the delta chain: each fetched, checked and patched onto the last, then applied" {
fresh(true)
seed_base()
update_start()
net_text = feed(true)
net_answer = 1
update_tick()
update_tick()
hash_is("AB1.1.0")
proc_code = 0
update_tick()
expect_eq(update_state(), UPDATE_PATCH)
expect(Text.contains(last_args(), "--old " + pkgs() + "/Game-1.0.0-osx-full.nupkg"))
expect(Text.contains(last_args(), "--output " + pkgs() + "/Game-1.1.0-osx-full.nupkg"))
Fs.write_text(pkgs() + "/Game-1.1.0-osx-full.nupkg", "patched")
update_tick()
expect_eq(update_state(), UPDATE_FETCH)
expect_eq(update_index(), 1)
update_tick()
hash_is("AB1.2.0")
update_tick()
Fs.write_text(pkgs() + "/Game-1.2.0-osx-full.nupkg", "patched")
update_tick()
expect_eq(update_state(), UPDATE_APPLY)
expect(Text.contains(last_args(), "--package " + pkgs() + "/Game-1.2.0-osx-full.nupkg"))
}
test "a package already on disk at its size is checked, not fetched again" {
fresh(true)
update_start()
net_text = feed(true)
net_answer = 1
Fs.write_text(pkgs() + "/Game-1.2.0-osx-full.nupkg", Text.repeat("x", 1000))
update_tick()
expect_eq(update_state(), UPDATE_CHECK)
}
}