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:
parent
63e29f024c
commit
3514d46954
13 changed files with 1103 additions and 0 deletions
75
packages/ludic.update/README.md
Normal file
75
packages/ludic.update/README.md
Normal 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.
|
||||
49
packages/ludic.update/chain.ludic
Normal file
49
packages/ludic.update/chain.ludic
Normal 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] }
|
||||
14
packages/ludic.update/index.ludic
Normal file
14
packages/ludic.update/index.ludic
Normal 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"
|
||||
98
packages/ludic.update/install.ludic
Normal file
98
packages/ludic.update/install.ludic
Normal 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"
|
||||
}
|
||||
73
packages/ludic.update/latest.ludic
Normal file
73
packages/ludic.update/latest.ludic
Normal 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())
|
||||
}
|
||||
6
packages/ludic.update/package.ludic
Normal file
6
packages/ludic.update/package.ludic
Normal 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
|
||||
82
packages/ludic.update/plan.ludic
Normal file
82
packages/ludic.update/plan.ludic
Normal 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
|
||||
}
|
||||
60
packages/ludic.update/ports.ludic
Normal file
60
packages/ludic.update/ports.ludic
Normal 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) }
|
||||
70
packages/ludic.update/queries.ludic
Normal file
70
packages/ludic.update/queries.ludic
Normal 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
|
||||
}
|
||||
97
packages/ludic.update/state.ludic
Normal file
97
packages/ludic.update/state.ludic
Normal 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)
|
||||
}
|
||||
92
packages/ludic.update/steps.ludic
Normal file
92
packages/ludic.update/steps.ludic
Normal 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 ""
|
||||
}
|
||||
270
packages/ludic.update/tests/update_test.ludic
Normal file
270
packages/ludic.update/tests/update_test.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
117
packages/ludic.update/tick.ludic
Normal file
117
packages/ludic.update/tick.ludic
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue