diff --git a/packages/ludic.update/README.md b/packages/ludic.update/README.md new file mode 100644 index 00000000..573ec171 --- /dev/null +++ b/packages/ludic.update/README.md @@ -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?** `/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 + `. 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` | `{ 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. diff --git a/packages/ludic.update/chain.ludic b/packages/ludic.update/chain.ludic new file mode 100644 index 00000000..fb3c863f --- /dev/null +++ b/packages/ludic.update/chain.ludic @@ -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] } diff --git a/packages/ludic.update/index.ludic b/packages/ludic.update/index.ludic new file mode 100644 index 00000000..ec0ae6c2 --- /dev/null +++ b/packages/ludic.update/index.ludic @@ -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" diff --git a/packages/ludic.update/install.ludic b/packages/ludic.update/install.ludic new file mode 100644 index 00000000..9c52ba07 --- /dev/null +++ b/packages/ludic.update/install.ludic @@ -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: \Update.exe, \current\.exe, \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" +} diff --git a/packages/ludic.update/latest.ludic b/packages/ludic.update/latest.ludic new file mode 100644 index 00000000..999369bc --- /dev/null +++ b/packages/ludic.update/latest.ludic @@ -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()) +} diff --git a/packages/ludic.update/package.ludic b/packages/ludic.update/package.ludic new file mode 100644 index 00000000..6ebfcd1b --- /dev/null +++ b/packages/ludic.update/package.ludic @@ -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 diff --git a/packages/ludic.update/plan.ludic b/packages/ludic.update/plan.ludic new file mode 100644 index 00000000..a81f45ab --- /dev/null +++ b/packages/ludic.update/plan.ludic @@ -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 +} diff --git a/packages/ludic.update/ports.ludic b/packages/ludic.update/ports.ludic new file mode 100644 index 00000000..10f4a8a7 --- /dev/null +++ b/packages/ludic.update/ports.ludic @@ -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) } diff --git a/packages/ludic.update/queries.ludic b/packages/ludic.update/queries.ludic new file mode 100644 index 00000000..c1f2895c --- /dev/null +++ b/packages/ludic.update/queries.ludic @@ -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 +} diff --git a/packages/ludic.update/state.ludic b/packages/ludic.update/state.ludic new file mode 100644 index 00000000..844f098e --- /dev/null +++ b/packages/ludic.update/state.ludic @@ -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: /latest.json, /releases..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 /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 = 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 { + 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) +} diff --git a/packages/ludic.update/steps.ludic b/packages/ludic.update/steps.ludic new file mode 100644 index 00000000..f2d7a060 --- /dev/null +++ b/packages/ludic.update/steps.ludic @@ -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 "" +} diff --git a/packages/ludic.update/tests/update_test.ludic b/packages/ludic.update/tests/update_test.ludic new file mode 100644 index 00000000..101ab20e --- /dev/null +++ b/packages/ludic.update/tests/update_test.ludic @@ -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) + } +} diff --git a/packages/ludic.update/tick.ludic b/packages/ludic.update/tick.ludic new file mode 100644 index 00000000..a1c9d788 --- /dev/null +++ b/packages/ludic.update/tick.ludic @@ -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) +}