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,92 @@
# steps.ludic - each step started: the feed asked, a package streamed into the packages folder, its
# SHA-256 by a child (Get-FileHash, shasum) so the frame keeps drawing, a patch, the apply
# the Update button: false (and nothing started) when Velopack did not install this copy
export function update_start() -> bool {
if not update_installed() {
update__err = UPDATE_ERR_NOT_INSTALLED
return false
}
update__release()
Fs.mkdir(update_packages())
update__err = UPDATE_ERR_NONE
update__h = UpdateNet.get(update__conf().feed + "/" + update__feed_file())
if update__h < 0 {
update__fail(UPDATE_ERR_UNREACHABLE)
return true
}
update__state = UPDATE_FEED
return true
}
function update__release() -> void {
if update__h >= 0 { UpdateNet.free(update__h) }
update__h = -1
if update__proc >= 0 { UpdateProc.free(update__proc) }
update__proc = -1
}
function update__fail(err: int) -> void {
update__release()
update__err = err
update__state = UPDATE_FAILED
update__fact(UPDATE_STOPPED)
}
# fetch file i, unless a copy of the right size is there already - then straight to its check
function update__fetch(i: int) -> void {
update__i = i
let p = update__path(i)
if Fs.exists(p) and Fs.size(p) == update__size[i] {
update__check(i)
return
}
if Fs.exists(p) { Fs.remove(p) }
update__h = UpdateNet.download(update__conf().feed + "/" + update__files[i], p)
if update__h < 0 {
update__fail(UPDATE_ERR_UNREACHABLE)
return
}
update__rate_t = -1
update__state = UPDATE_FETCH
}
function update__hash_file() -> string {
if len(update__conf().scratch) > 0 { return update__conf().scratch }
return Os.temp_dir() + "/ludic-update-hash.txt"
}
# no cmd.exe: it parses its own command line and would not survive Process.spawn's quoting
function update__check(i: int) -> void {
let out = update__hash_file()
if Fs.exists(out) { Fs.remove(out) }
let args = new []string
if UpdateWorld.platform() == "windows" {
push(args, "-NoProfile")
push(args, "-NonInteractive")
push(args, "-Command")
push(args, "(Get-FileHash -Algorithm SHA256 -LiteralPath " + update__psq(update__path(i)) + ").Hash | Out-File -Encoding ascii -LiteralPath " + update__psq(out))
update__proc = UpdateProc.spawn(update__powershell(), args)
} else {
push(args, "-c")
push(args, "shasum -a 256 '" + update__path(i) + "' | cut -d' ' -f1 > '" + out + "'")
update__proc = UpdateProc.spawn("/bin/sh", args)
}
if update__proc < 0 {
update__fail(UPDATE_ERR_CHECK)
return
}
update__state = UPDATE_CHECK
update__step_t0 = UpdateWorld.now_ms()
}
# the hash the child wrote: the first line with anything on it
function update__hash_read() -> string {
let s = Fs.read_text(update__hash_file())
if s == null { return "" }
let lines = Text.split(s, "\n")
for i in 0 .. len(lines) {
let t = Text.trim(lines[i])
if len(t) > 0 { return Text.upper(t) }
}
return ""
}