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,75 @@
# ludic.update
A game installed by [Velopack](https://velopack.io) updating itself, a frame at a time, with
nothing on screen but the game's own panel. Uses [`ludic.base`](../ludic.base/README.md) and
nothing else.
```ludic
import "ludic.update"
```
Two halves:
- **Is there a newer version?** `<feed>/latest.json` (`{"version": "1.2.0", "notes": {"en":
[...], "tr": [...]}}`) asked once and polled a frame at a time, so a slow or absent network
costs the frame nothing - it simply never says there is one.
- **Installing it.** Velopack's own feed (`releases.win.json`, `releases.osx.json`: every
package with its version, type, size and SHA-256), then the plan - the chain of deltas when
the installed full package is on disk, no delta is missing and they weigh less than the full
one; the newest full package otherwise - then each package streamed into the packages folder
with its bytes on the bar, its SHA-256 by a child (`Get-FileHash` on Windows, `shasum`
elsewhere) so the frame keeps drawing, `Update patch` per delta, and `Update apply --waitPid
<this>`. On `UPDATE_APPLYING` the game exits; Velopack waits for it, swaps the install and
starts the new version.
What differs on macOS is where things live: the `.app` is the install, `UpdateMac` sits beside
the binary in `Contents/MacOS`, and the packages must be OUTSIDE the bundle (`mac_packages`) -
a bundle is read-only, and `apply` cannot replace a directory it is reading a package out of.
A package's name always comes from the feed (Velopack puts the channel in a macOS name), and a
translocated Mac app (still where it was downloaded) is "not installed".
The package never words anything: a step, an error and a pace are numbers and codes, and the
game says them in its own language.
## Config and ports
```ludic
property UpdateConfig {
feed, app_id, version # the release bucket, Velopack's pack id, this build's version
latest ("latest.json")
root, packages # outright (a stand-in install, for a test); "" works them out
mac_packages # where packages go on macOS
scratch # where a hash is written
}
port UpdateWorld { platform, exe, pid, now_ms } # unbound: Os and the wall clock
port UpdateNet { get, download, poll, text, received, free } # unbound: Http
port UpdateProc { spawn, poll, free } # unbound: Process
```
Every member has the runtime as its default; a game binds only what it wants to replace (a finer
clock for the pace), and a test binds fakes.
## API
| | |
| --- | --- |
| `update_config(c)` | before anything else |
| `update_check_tick(show_current)`, `update_ask_state()`, `update_shown()`, `update_fresh()`, `update_latest()`, `update_notes(lang)` | the newest version: `UPDATE_ASKING`, `_NONE`, `_NEWER`, `_CURRENT` (the version being played, kept only with `show_current`); its notes in a language, else English |
| `update_start() -> bool`, `update_tick()` | the Update button (false, and `UPDATE_ERR_NOT_INSTALLED`, on a copy Velopack did not install), then once a frame |
| `update_state()`, `update_busy()`, `update_failed()`, `update_working()`, `update_error()` | `UPDATE_IDLE`, `_FEED`, `_FETCH`, `_CHECK`, `_PATCH`, `_APPLY`, `_FAILED`; working is a check or a patch (no fraction: sweep the bar); the error is an `UPDATE_ERR_*` |
| `update_progress()`, `update_received()`, `update_file_received()`, `update_file_size()`, `update_eta_seconds()`, `update_step_seconds()` | the bar (0..1000 over every byte of the plan), the bytes, the time left at an eased pace (-1 until known), the seconds a check or patch has run |
| `update_index()`, `update_file()`, `update_version_at()`, `update_package_count()`, `update_target()`, `update_total_bytes()`, `update_plan_file(i)`, `update_plan_is_delta(i)`, `update_plan_version(i)` | the plan and where in it |
| `update_plan(feed) -> bool` | the plan from a parsed feed, outright |
| `update_facts() -> Queue<UpdateFact>` | `{ what, error, target, packages, bytes }`: `UPDATE_STARTED`, `UPDATE_STOPPED`, `UPDATE_APPLYING` (exit now) |
| `update_newer(a, b)`, `update_is_install_hook()`, `update_installed()`, `update_translocated()`, `update_dir()`, `update_root()`, `update_packages()`, `update_open(url)` | versions compared a number at a time; Velopack's `--veloapp-*` hooks (exit at once); the install's layout; a page in the browser |
| `update_reset()` | back to before anything (tests) |
## Tests
```bash
ludic build packages/ludic.update/tests/update_test.ludic --headless -o /tmp/update_test && /tmp/update_test
```
A fake network, fake children and a stand-in root in the temp directory: the notes, the plan both
ways, the whole package and the delta chain end to end, a wrong hash, a failed download and a
package already on disk.

View file

@ -0,0 +1,49 @@
# chain.ludic - the plan made: the files to fetch, in order, and the base the first delta patches
function update__plan_clear() -> void {
update__files = new []string
update__sha = new []string
update__size = new []int
update__delta = new []bool
update__ver = new []string
update__full = new []string
update__total_bytes = 0
update__done_bytes = 0
update__i = 0
}
# the plan from a parsed feed; false when there is nothing newer than this version
export function update_plan(feed: Val) -> bool {
update__plan_clear()
let as = update__assets(feed)
let n = update__newest_full(as)
if n < 0 or not update_newer(as[n].ver, update__conf().version) { return false }
let newest = as[n]
update__target = newest.ver
let base = update_packages() + update__sep() + update__full_name(as, update__conf().version)
let chain = update__chain(as, newest.ver)
var chain_bytes = 0
for i in 0 .. len(chain) { chain_bytes += chain[i].size }
let whole = len(chain) > 0 and len(chain) == update__fulls_after(as)
if Fs.exists(base) and whole and chain_bytes < newest.size {
for i in 0 .. len(chain) { update__plan_add(chain[i], update__full_name(as, chain[i].ver)) }
update__old = base
} else {
update__plan_add(newest, newest.file)
update__old = ""
}
return true
}
# the plan, for a panel or a test
export function update_package_count() -> int {
if update__files == null { return 0 }
return len(update__files)
}
export function update_plan_file(i: int) -> string { return update__files[i] }
export function update_plan_is_delta(i: int) -> bool { return update__delta[i] }
export function update_plan_version(i: int) -> string { return update__ver[i] }
export function update_target() -> string { return update__target }
export function update_total_bytes() -> int { return update__total_bytes }
function update__path(i: int) -> string { return update_packages() + update__sep() + update__files[i] }

View file

@ -0,0 +1,14 @@
# ludic.update - is there a newer version (latest.json, polled a frame at a time), and installing it
# through Velopack a frame at a time: feed, plan, fetch, check, patch, apply
module ludic_update uses ludic_base
numbers float
import "ludic.base"
import "state.ludic"
import "ports.ludic"
import "install.ludic"
import "latest.ludic"
import "plan.ludic"
import "chain.ludic"
import "steps.ludic"
import "tick.ludic"
import "queries.ludic"

View file

@ -0,0 +1,98 @@
# install.ludic - where Velopack put this copy: its root, its packages, its updater, and whether
# it is an install at all. Windows: <root>\Update.exe, <root>\current\<game>.exe, <root>\packages.
# macOS: the .app IS the install and UpdateMac sits beside the binary in Contents/MacOS.
# "0.10.2" beats "0.9.9": compared a number at a time, not as text
export function update_newer(a: string, b: string) -> bool {
let pa = Text.split(a, ".")
let pb = Text.split(b, ".")
for i in 0 .. 3 {
var x = 0
var y = 0
if i < len(pa) { x = Text.to_int(pa[i]) }
if i < len(pb) { y = Text.to_int(pb[i]) }
if x != y { return x > y }
}
return false
}
# Velopack runs the game with --veloapp-install / -updated / -obsolete / -uninstall and kills it if
# it is still running 15-30 s later: a game that sees one exits at once
export function update_is_install_hook() -> bool {
let a = Os.args()
for i in 1 .. len(a) {
let s = a[i]
if len(s) >= 10 { if s[0..10] == "--veloapp-" { return true } }
}
return false
}
function update__mac() -> bool { return UpdateWorld.platform() == "macos" }
function update__sep() -> string {
if UpdateWorld.platform() == "windows" { return "\\" }
return "/"
}
# the directory above p, with no ".." left in it (UpdateMac is handed it as its root)
function update__up(p: string) -> string {
var cut = -1
for i in 0 .. len(p) { if p[i..i + 1] == "/" or p[i..i + 1] == "\\" { cut = i } }
if cut < 0 { return "." }
return p[0..cut]
}
# the executable's directory
export function update_dir() -> string { return update__up(UpdateWorld.exe()) }
export function update_root() -> string {
let c = update__conf()
if len(c.root) > 0 { return c.root }
if update__mac() { return update__up(update__up(update_dir())) }
return update_dir() + "\\.."
}
# where the packages live: never inside a macOS bundle, which is read-only and which apply replaces
export function update_packages() -> string {
let c = update__conf()
if len(c.packages) > 0 { return c.packages }
if update__mac() and len(c.mac_packages) > 0 { return c.mac_packages }
return update_root() + update__sep() + "packages"
}
function update__feed_file() -> string {
if update__mac() { return "releases.osx.json" }
return "releases.win.json"
}
function update__updater() -> string {
if update__mac() { return update_root() + "/Contents/MacOS/UpdateMac" }
return update_root() + update__sep() + "Update.exe"
}
# A quarantined Mac app still where it was downloaded runs from a read-only /AppTranslocation/
# image that vanishes when it exits: nothing there can be updated in place
export function update_translocated() -> bool {
return update__mac() and Text.contains(UpdateWorld.exe(), "/AppTranslocation/")
}
# can this copy update itself? A copy Velopack did not install downloads the game again instead.
export function update_installed() -> bool {
if len(update__conf().root) > 0 { return true }
if update_translocated() { return false }
if update__mac() { return Fs.exists(update_dir() + "/UpdateMac") }
return Fs.exists(update_dir() + "/../Update.exe")
}
# a page in the player's browser (the site, the changelog)
export function update_open(url: string) -> void {
if update__mac() { System.run(`open "{url}"`) } else { System.run(`cmd /c start "" "{url}"`) }
}
# a path inside a PowerShell single-quoted string: a quote is written twice
function update__psq(p: string) -> string { return "'" + Text.replace(p, "'", "''") + "'" }
function update__powershell() -> string {
var root = "C:\\Windows"
if Os.has_env("SystemRoot") and len(Os.env("SystemRoot")) > 0 { root = Os.env("SystemRoot") }
return root + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
}

View file

@ -0,0 +1,73 @@
# latest.ludic - "a new version is out": latest.json asked once, polled a frame at a time, so a
# slow or absent network costs the frame nothing - it simply never says there is one.
# latest.json is {"version": "0.5.1", "notes": {"en": ["...", ...], "tr": [...]}}
var update__ask: int = 0
var update__ask_h: int = -1
var update__latest: string = ""
var update__notes: Val = null
# once a frame on the screen that shows it; with show_current, the notes of the version being
# played are kept too (a launcher shows them)
export function update_check_tick(show_current: bool) -> void {
if update__ask == UPDATE_ASK_IDLE {
update__ask_h = UpdateNet.get(update__conf().feed + "/" + update__conf().latest)
update__ask = UPDATE_ASKING
if update__ask_h < 0 { update__ask = UPDATE_NONE }
return
}
if update__ask != UPDATE_ASKING { return }
let st = UpdateNet.poll(update__ask_h)
if st < 0 { return }
update__ask = UPDATE_NONE
if st > 0 { update__latest_read(Json.parse(UpdateNet.text(update__ask_h)), show_current) }
UpdateNet.free(update__ask_h)
update__ask_h = -1
}
function update__latest_read(v: Val, show_current: bool) -> void {
if v == null or Value.kind(v) != 6 or Value.has(v, "version") == 0 { return }
let ver = Value.as_str(Value.get(v, "version"))
let mine = update__conf().version
if update_newer(ver, mine) { update__ask = UPDATE_NEWER }
else if ver == mine and show_current { update__ask = UPDATE_CURRENT }
else { return }
update__latest = ver
if Value.has(v, "notes") != 0 { update__notes = Value.get(v, "notes") }
}
export function update_ask_state() -> int { return update__ask }
export function update_latest() -> string { return update__latest }
export function update_shown() -> bool { return update__ask == UPDATE_NEWER or update__ask == UPDATE_CURRENT }
export function update_fresh() -> bool { return update__ask == UPDATE_NEWER }
# the notes in this language, else English, else none
export function update_notes(lang: string) -> []string {
let out = new []string
if update__notes == null or Value.kind(update__notes) != 6 { return out }
var key = lang
if Value.has(update__notes, key) == 0 { key = "en" }
if Value.has(update__notes, key) == 0 { return out }
let l = Value.get(update__notes, key)
for i in 0 .. Value.count(l) { push(out, Value.as_str(Value.at(l, i))) }
return out
}
# back to before anything was asked or installed (tests)
export function update_reset() -> void {
if update__ask_h >= 0 { UpdateNet.free(update__ask_h) }
update__ask_h = -1
update__ask = UPDATE_ASK_IDLE
update__latest = ""
update__notes = null
update__release()
update__state = UPDATE_IDLE
update__err = UPDATE_ERR_NONE
update__target = ""
update__files = null
update__total_bytes = 0
update__done_bytes = 0
update__i = 0
update__rate = 0
update__rate_t = -1
q_clear(update_facts())
}

View file

@ -0,0 +1,6 @@
# ludic.update - a Velopack game updating itself: the newest version and its notes, then the feed,
# the delta chain or the full package, the download with its bar, the hash, the patch and the
# apply. Uses ludic.base and nothing else. See README.md.
package "ludic.update"
version "0.1.0"
kind source

View file

@ -0,0 +1,82 @@
# plan.ludic - the smallest way from this version to the newest, from Velopack's feed: the chain of
# deltas when the installed full package is on disk, no delta is missing and they weigh less than
# the full one; the newest full package otherwise
property UpdateAsset {
ver: string = ""
file: string = ""
sha: string = ""
size: int = 0
delta: bool = false
}
# this app's packages in the feed ({"Assets": [{PackageId, Version, Type, FileName, SHA256, Size}]})
function update__assets(feed: Val) -> []UpdateAsset {
let out = new []UpdateAsset
if feed == null or Value.kind(feed) != 6 or Value.has(feed, "Assets") == 0 { return out }
let all = Value.get(feed, "Assets")
for i in 0 .. Value.count(all) {
let a = Value.at(all, i)
if sv_str(a, "PackageId", "") != update__conf().app_id { continue }
let x = new UpdateAsset
x.ver = sv_str(a, "Version", "")
x.file = sv_str(a, "FileName", "")
x.sha = sv_str(a, "SHA256", "")
x.size = sv_int(a, "Size", 0)
x.delta = sv_str(a, "Type", "") == "Delta"
push(out, x)
}
return out
}
# What the feed calls the FULL package of version v. Never built here: Velopack puts the channel in
# a macOS name (App-0.3.4-osx-full.nupkg) and not a Windows one, and it is both the name a patch
# writes and the name the next update looks for as its base.
function update__full_name(as: []UpdateAsset, v: string) -> string {
for i in 0 .. len(as) { if not as[i].delta and as[i].ver == v { return as[i].file } }
return update__conf().app_id + "-" + v + "-full.nupkg"
}
function update__newest_full(as: []UpdateAsset) -> int {
var best = -1
for i in 0 .. len(as) {
if as[i].delta { continue }
if best < 0 or update_newer(as[i].ver, as[best].ver) { best = i }
}
return best
}
# the deltas above this version up to the newest, oldest first
function update__chain(as: []UpdateAsset, newest: string) -> []UpdateAsset {
let out = new []UpdateAsset
let mine = update__conf().version
for i in 0 .. len(as) {
let a = as[i]
if not a.delta or not update_newer(a.ver, mine) or update_newer(a.ver, newest) { continue }
var at = len(out)
while at > 0 and update_newer(out[at - 1].ver, a.ver) { at -= 1 }
push(out, a)
var k = len(out) - 1
while k > at {
out[k] = out[k - 1]
k -= 1
}
out[at] = a
}
return out
}
function update__fulls_after(as: []UpdateAsset) -> int {
var n = 0
for i in 0 .. len(as) { if not as[i].delta and update_newer(as[i].ver, update__conf().version) { n += 1 } }
return n
}
function update__plan_add(a: UpdateAsset, full: string) -> void {
push(update__files, a.file)
push(update__sha, a.sha)
push(update__size, a.size)
push(update__delta, a.delta)
push(update__ver, a.ver)
push(update__full, full)
update__total_bytes += a.size
}

View file

@ -0,0 +1,60 @@
# ports.ludic - what the update asks, and the runtime's answers when the game leaves it unbound
# the machine: its platform ("windows", "macos"), this executable, this process, a clock
export port UpdateWorld {
platform: fn() -> string = fn update__os_platform
exe: fn() -> string = fn update__os_exe
pid: fn() -> int = fn update__os_pid
now_ms: fn() -> int = fn update__os_ms
}
# requests: get asks for text, download streams to a path; poll is -1 pending, 0 failed, 1 ok
export port UpdateNet {
get: fn(string) -> int = fn update__http_get
download: fn(string, string) -> int = fn update__http_download
poll: fn(int) -> int = fn update__http_poll
text: fn(int) -> string = fn update__http_text
received: fn(int) -> int = fn update__http_received
free: fn(int) -> void = fn update__http_free
}
# children: the hash and Velopack's updater; poll is -1 running, else the exit code
export port UpdateProc {
spawn: fn(string, []string) -> int = fn update__proc_spawn
poll: fn(int) -> int = fn update__proc_poll
free: fn(int) -> void = fn update__proc_free
}
function update__os_platform() -> string { return Os.platform() }
function update__os_exe() -> string {
let a = Os.args()
if len(a) == 0 { return "" }
return a[0]
}
function update__os_pid() -> int { return Os.pid() }
function update__os_ms() -> int { return Time.now() * 1000 }
function update__http_get(url: string) -> int { return Http.get(url) }
function update__http_download(url: string, path: string) -> int {
let h = Http.open("GET", url)
if h < 0 { return h }
Http.save_to(h, path)
Http.send(h)
return h
}
function update__http_poll(h: int) -> int {
let st = Http.poll(h)
if st < 0 { return -1 }
if st > 0 and Http.ok(h) { return 1 }
return 0
}
function update__http_text(h: int) -> string { return Http.text(h) }
function update__http_received(h: int) -> int { return Http.received(h) }
function update__http_free(h: int) -> void { Http.free(h) }
function update__proc_spawn(path: string, args: []string) -> int { return Process.spawn(path, args) }
function update__proc_poll(h: int) -> int { return Process.poll(h) }
function update__proc_free(h: int) -> void { Process.free(h) }

View file

@ -0,0 +1,70 @@
# queries.ludic - what a panel draws: the step, the bar, the pace, and why it stopped
export function update_state() -> int { return update__state }
export function update_error() -> int { return update__err }
export function update_busy() -> bool { return update__state != UPDATE_IDLE and update__state != UPDATE_FAILED }
export function update_failed() -> bool { return update__state == UPDATE_FAILED }
# checking and patching have no fraction to give: a panel's bar sweeps instead of filling
export function update_working() -> bool { return update__state == UPDATE_CHECK or update__state == UPDATE_PATCH }
# the package being fetched, checked or patched (0-based), its name, size and version
export function update_index() -> int { return update__i }
export function update_file() -> string {
if update__files == null or update__i >= len(update__files) { return "" }
return update__files[update__i]
}
export function update_file_size() -> int {
if update__size == null or update__i >= len(update__size) { return 0 }
return update__size[update__i]
}
export function update_version_at() -> string {
if update__ver == null or update__i >= len(update__ver) { return "" }
return update__ver[update__i]
}
# bytes of the package in flight, and of the whole plan
export function update_file_received() -> int {
if update__state != UPDATE_FETCH or update__h < 0 { return 0 }
return UpdateNet.received(update__h)
}
export function update_received() -> int { return update__done_bytes + update_file_received() }
# how far, 0..1000, over every byte of the plan (the file being checked counts as fetched)
export function update_progress() -> int {
if update__total_bytes <= 0 { return 0 }
var got = update__done_bytes
if update__state == UPDATE_FETCH { got = update_received() }
if update__state == UPDATE_CHECK { got = update__done_bytes + update__size[update__i] }
return min(1000, got / max(1, update__total_bytes / 1000))
}
# seconds a check or a patch has been running
export function update_step_seconds() -> int { return (UpdateWorld.now_ms() - update__step_t0) / 1000 }
# seconds left at the eased pace; -1 until the pace is known
export function update_eta_seconds() -> int {
if update__state != UPDATE_FETCH or update__rate <= 0 { return -1 }
return (update__total_bytes - update_received()) / update__rate + 1
}
# every half second: the pace, eased so the estimate does not jump
function update__rate_tick() -> void {
if update__state != UPDATE_FETCH { return }
let now = UpdateWorld.now_ms()
let got = update_received()
if update__rate_t < 0 {
update__rate_t = now
update__rate_bytes = got
return
}
let ms = now - update__rate_t
if ms < 500 { return }
let per_s = (got - update__rate_bytes) / ms * 1000
if update__rate == 0 { update__rate = per_s } else { update__rate = (update__rate * 3 + per_s) / 4 }
update__rate_t = now
update__rate_bytes = got
}

View file

@ -0,0 +1,97 @@
# state.ludic - the config, the states, the errors, the facts, and the plan being carried out
# what the game says about itself and where its releases are
export property UpdateConfig {
feed: string = "" # the release bucket: <feed>/latest.json, <feed>/releases.<os>.json
app_id: string = "" # Velopack's pack id (MaroonLake)
version: string = "" # this build's version
latest: string = "latest.json"
root: string = "" # Velopack's root outright (a stand-in, for a test); "" from the executable
packages: string = "" # where packages go outright; "" is <root>/packages, or mac_packages
mac_packages: string = "" # on macOS: outside the bundle, which apply replaces
scratch: string = "" # where a hash is written (unset: the temp directory)
}
# the newest version, asked of latest.json
export const UPDATE_ASK_IDLE: int = 0
export const UPDATE_ASKING: int = 1
export const UPDATE_NONE: int = 2 # up to date, or the network said nothing useful
export const UPDATE_NEWER: int = 3
export const UPDATE_CURRENT: int = 4 # the newest is this version: its notes are "what's new"
# the install
export const UPDATE_IDLE: int = 0
export const UPDATE_FEED: int = 1
export const UPDATE_FETCH: int = 2
export const UPDATE_CHECK: int = 3
export const UPDATE_PATCH: int = 4
export const UPDATE_APPLY: int = 5
export const UPDATE_FAILED: int = 6
# why it stopped; the game words it (update_error_file / _version say which)
export const UPDATE_ERR_NONE: int = 0
export const UPDATE_ERR_UNREACHABLE: int = 1 # the feed or a package could not be asked for
export const UPDATE_ERR_NOTHING: int = 2 # nothing newer to install
export const UPDATE_ERR_DOWNLOAD: int = 3 # a package did not download
export const UPDATE_ERR_CORRUPT: int = 4 # its hash was wrong; it is deleted
export const UPDATE_ERR_CHECK: int = 5 # the hash could not be started
export const UPDATE_ERR_PATCH_START: int = 6
export const UPDATE_ERR_PATCH: int = 7
export const UPDATE_ERR_APPLY: int = 8
export const UPDATE_ERR_NOT_INSTALLED: int = 9 # not installed by Velopack: nothing to update in place
export const UPDATE_STARTED: int = 0 # a plan was made (target, packages, bytes)
export const UPDATE_STOPPED: int = 1 # it failed (error)
export const UPDATE_APPLYING: int = 2 # apply is running: the game must exit now (target)
export property UpdateFact {
what: int = 0
error: int = 0
target: string = ""
packages: int = 0
bytes: int = 0
}
var update__cfg: UpdateConfig = null
var update__state: int = 0
var update__err: int = 0
var update__h: int = -1 # the request
var update__proc: int = -1 # the child process
var update__target: string = ""
var update__files: []string = null # the plan: file names, hashes, sizes, delta or not, versions
var update__sha: []string = null
var update__size: []int = null
var update__delta: []bool = null
var update__ver: []string = null
var update__full: []string = null # what the feed calls the FULL package of that version
var update__i: int = 0
var update__done_bytes: int = 0
var update__total_bytes: int = 0
var update__old: string = "" # the full package the next delta patches
var update__package: string = "" # what apply installs
var update__step_t0: int = 0 # when a check or a patch began, ms
var update__rate: int = 0 # bytes a second, eased
var update__rate_t: int = -1
var update__rate_bytes: int = 0
var update__facts: Queue<UpdateFact> = null
export function update_config(c: UpdateConfig) -> void { update__cfg = c }
function update__conf() -> UpdateConfig {
if update__cfg == null { update__cfg = new UpdateConfig }
return update__cfg
}
export function update_facts() -> Queue<UpdateFact> {
if update__facts == null { update__facts = queue_new("update.facts") }
return update__facts
}
function update__fact(what: int) -> void {
let f = new UpdateFact
f.what = what
f.error = update__err
f.target = update__target
f.packages = update_package_count()
f.bytes = update__total_bytes
q_push(update_facts(), f)
}

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 ""
}

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)
}
}

View file

@ -0,0 +1,117 @@
# tick.ludic - once a frame while an update runs: whatever the step waits on, and the next step
export function update_tick() -> void {
update__rate_tick()
if update__state == UPDATE_FEED { update__feed_tick() }
else if update__state == UPDATE_FETCH { update__fetch_tick() }
else if update__state == UPDATE_CHECK { update__check_tick() }
else if update__state == UPDATE_PATCH { update__patch_tick() }
}
function update__feed_tick() -> void {
let st = UpdateNet.poll(update__h)
if st < 0 { return }
var feed: Val = null
if st > 0 { feed = Json.parse(UpdateNet.text(update__h)) }
UpdateNet.free(update__h)
update__h = -1
if feed == null {
update__fail(UPDATE_ERR_UNREACHABLE)
return
}
if not update_plan(feed) {
update__fail(UPDATE_ERR_NOTHING)
return
}
update__fact(UPDATE_STARTED)
update__fetch(0)
}
function update__fetch_tick() -> void {
let st = UpdateNet.poll(update__h)
if st < 0 { return }
UpdateNet.free(update__h)
update__h = -1
if st == 0 {
Fs.remove(update__path(update__i))
update__fail(UPDATE_ERR_DOWNLOAD)
return
}
update__check(update__i)
}
# a package rebuilt from deltas is not compared with the full one's hash: patch re-assembles the
# archive, so the same files come out as different bytes
function update__check_tick() -> void {
let code = UpdateProc.poll(update__proc)
if code < 0 { return }
UpdateProc.free(update__proc)
update__proc = -1
if code != 0 or update__hash_read() != Text.upper(update__sha[update__i]) {
Fs.remove(update__path(update__i))
update__fail(UPDATE_ERR_CORRUPT)
return
}
update__done_bytes += update__size[update__i]
if update__delta[update__i] { update__patch(update__i) } else { update__apply(update__path(update__i)) }
}
function update__patch(i: int) -> void {
let out = update_packages() + update__sep() + update__full[i]
if Fs.exists(out) { Fs.remove(out) }
let args = new []string
push(args, "patch")
push(args, "--silent")
push(args, "--old")
push(args, update__old)
push(args, "--delta")
push(args, update__path(i))
push(args, "--output")
push(args, out)
update__proc = UpdateProc.spawn(update__updater(), args)
if update__proc < 0 {
update__fail(UPDATE_ERR_PATCH_START)
return
}
update__old = out
update__state = UPDATE_PATCH
update__step_t0 = UpdateWorld.now_ms()
}
function update__patch_tick() -> void {
let code = UpdateProc.poll(update__proc)
if code < 0 { return }
UpdateProc.free(update__proc)
update__proc = -1
if code != 0 or not Fs.exists(update__old) {
update__fail(UPDATE_ERR_PATCH)
return
}
if update__i + 1 < len(update__files) { update__fetch(update__i + 1) } else { update__apply(update__old) }
}
# Velopack waits for this process to be gone, swaps the install and starts the new version; the
# game hears UPDATE_APPLYING and exits. On macOS neither the root nor the packages are where
# UpdateMac would look, so both are named.
function update__apply(package: string) -> void {
update__package = package
let args = new []string
push(args, "apply")
push(args, "--silent")
push(args, "--waitPid")
push(args, `{UpdateWorld.pid()}`)
push(args, "--package")
push(args, package)
if update__mac() {
push(args, "--rootDir")
push(args, update_root())
push(args, "--packageDir")
push(args, update_packages())
}
update__proc = UpdateProc.spawn(update__updater(), args)
if update__proc < 0 {
update__fail(UPDATE_ERR_APPLY)
return
}
update__state = UPDATE_APPLY
update__fact(UPDATE_APPLYING)
}