// Pack logic: turn files into a .appkg archive, and bundle an executable
// together with extra files into a single distributable file.
///|
/// Collect every regular file under `root`, recursively.
pub async fn collect_files(root : String) -> Array[String] {
let result = []
let stack = [norm_sep(root)]
while true {
match stack.pop() {
Some(dir) => {
let names = @fs.readdir(dir)
for name in names {
let full = dir + "/" + name
match @fs.kind(full) {
@fs.FileKind::Regular => result.push(full)
@fs.FileKind::Directory => stack.push(full)
_ => ()
}
}
}
None => break
}
}
result
}
///|
/// Lowercase hex MD5 of the content of the file at `path`.
///
/// The file is read as a whole, so it needs to fit in memory; that is the
/// same limitation the packing code has.
pub async fn file_md5_hex(path : String) -> String {
md5_hex(@fs.read_file(path).binary())
}
///|
/// Print the MD5 fingerprint of the file at `path`.
///
/// The fingerprint of the produced file is what a user compares against the
/// value the platform tooling shows, so it is printed under a clear label.
pub async fn report_output_md5(path : String) -> Unit {
if @fs.exists(path) {
println("output md5 " + file_md5_hex(path) + " " + basename(path))
}
}
///|
/// Write an archive containing `items`.
///
/// Every item is a pair of (source file path, path inside the archive).
/// Returns the manifest that was written.
///
/// The MD5 fingerprint of every source file is computed before it is written
/// and printed as a scan list, so the fingerprints that go into the package
/// can be compared with what the platform tooling reports.
async fn write_archive(
items : Array[(String, String)],
output : String,
opts : PackOptions,
) -> Manifest {
if @fs.exists(output) {
@fs.remove(output)
}
ensure_parent_dir(output)
let pkg = @fs.create(output, allow_existing=true, permission=0o644)
defer pkg.close()
pkg.write(magic_bytes())
let mut cursor : Int64 = 8
let entries = []
let mut scanned = 0
for it in items {
let (src, target) = it
let data = @fs.read_file(src)
let raw = data.binary()
let sum = sha256_hex(raw)
let fingerprint = md5_hex(raw)
// The scan list shows the fingerprint of every file before it goes into
// the package.
println(" md5 " + fingerprint + " " + target)
scanned = scanned + 1
// Preserve the executable bit in a portable way: `can_execute` maps to
// the POSIX x-bit and to executable extensions on Windows.
let exec = @fs.can_execute(src)
let mode : Int32 = if exec { 0o755 } else { 0o644 }
let (stored, codec) = if opts.compression == "none" {
(raw, "raw")
} else {
compress_if_smaller(raw, opts.compression == "gzip")
}
pkg.write(stored)
entries.push({
path: target,
mode,
size: raw.length().to_int64(),
stored_size: stored.length().to_int64(),
codec,
checksum: sum,
md5: fingerprint,
offset: cursor,
})
cursor = cursor + stored.length().to_int64()
}
println("scanned " + scanned.to_string() + " files")
let manifest = {
format_version: FORMAT_VERSION,
name: opts.name,
version: opts.version,
os: opts.os,
arch: opts.arch,
checksum_algo: CHECKSUM_ALGO,
compression: opts.compression,
created_at: iso8601_utc(@env.now()),
main_program: opts.main_program,
pre_install: opts.pre_install,
post_install: opts.post_install,
entries,
}
let mjson = manifest_to_json(manifest)
let mbytes = string_to_utf8_bytes(mjson)
let moff = cursor
pkg.write(mbytes)
let mlen : Int64 = Int64::from_int(mbytes.length())
pkg.write(magic_bytes())
pkg.write(i64_to_le8(moff))
pkg.write(i64_to_le8(mlen))
manifest
}
///|
/// Bundle every file under `input` into a `.appkg` file at `output`.
pub async fn pack(input : String, output : String, opts : PackOptions) -> Unit {
let base = norm_sep(@fs.realpath(input))
let files = collect_files(base)
let items = []
for f in files {
items.push((f, relpath(base, f)))
}
let m = write_archive(items, output, opts)
println("packed " + m.entries.length().to_string() + " files -> " + output)
report_output_md5(output)
}
///|
/// Append the content of `src_path` to `dst` starting at `position`.
///
/// Copying in chunks keeps the memory usage bounded, which matters for large
/// application folders.
async fn append_file(
dst : @fs.File,
src_path : String,
position : Int64,
) -> Unit {
let src = @fs.open(src_path, mode=@fs.Mode::ReadOnly)
defer src.close()
let total = src.size()
let chunk : Int64 = 1 << 20
let mut off : Int64 = 0
while off < total {
let rest = total - off
let n = if rest < chunk { rest } else { chunk }
let buf = src.read_exactly_at(n.to_int(), position=off)
dst.write_at(buf, position=position + off)
off = off + n
}
}
///|
/// Bundle `input_exe` together with the contents of every directory in
/// `file_dirs` into the single file `output_exe`.
///
/// The result is the original executable with a LunaPack archive appended, so
/// it stays a runnable executable while carrying the extra files. Use
/// `unpack` on the result to extract the payload again.
///
/// The contents of a single `--Files` directory land at the root of the
/// archive, next to the main program, so an extracted application is directly
/// runnable. With several directories each one gets a subdirectory named after
/// it, to keep the paths unique.
pub async fn bundle(
input_exe : String,
output_exe : String,
file_dirs : Array[String],
opts : PackOptions,
) -> Unit {
let exe_real = norm_sep(@fs.realpath(input_exe))
let items = []
let main_name = basename(input_exe)
items.push((input_exe, main_name))
let single = file_dirs.length() == 1
for d in file_dirs {
let base = norm_sep(@fs.realpath(d))
let files = collect_files(base)
let prefix = if single { "" } else { basename(base) }
for f in files {
// The main program is stored already; do not keep a second copy.
if norm_sep(@fs.realpath(f)) == exe_real {
continue
}
let rel = relpath(base, f)
items.push((f, if prefix == "" { rel } else { prefix + "/" + rel }))
}
}
let opts2 = with_main_program(opts, main_name)
let mut icon_copied = false
// Build the archive next to the target, then append it to the stub.
ensure_parent_dir(output_exe)
let tmp = output_exe + ".lunapack-archive.tmp"
let m = write_archive(items, tmp, opts2)
// The stub that makes the result runnable is the LunaPack executable
// itself: starting it notices the appended archive, extracts the payload
// and starts the bundled program. That is the same trick a WinRAR or 7-Zip
// self-extracting archive uses.
let stub = match current_exe_path() {
Some(p) => if is_native_target() && @fs.exists(p) { Some(p) } else { None }
None => None
}
if @fs.exists(output_exe) {
@fs.remove(output_exe)
}
match stub {
Some(s) => {
// Write the launcher stub first and close it again: the icon is
// written with the Windows resource API, which rewrites the file and
// therefore needs it to be closed.
{
let out = @fs.create(output_exe, allow_existing=true, permission=0o644)
defer out.close()
append_file(out, s, 0)
}
// Give the bundle the icon of the application it carries. This has to
// happen before the payload is appended, because writing resources
// rewrites the file.
if copy_icon(input_exe, output_exe) {
icon_copied = true
}
// Finally append the archive. The stub may have grown by the icon.
let out = @fs.open(output_exe, mode=@fs.Mode::ReadWrite)
defer out.close()
let stub_size = @fs.file_size(output_exe)
append_file(out, tmp, stub_size)
}
None => {
// Without a native launcher (for example when running under Wasm) the
// application executable itself carries the archive. The result still
// holds the payload, but it cannot extract itself.
println(
"warning: no native launcher available, the bundle will not extract itself",
)
let out = @fs.create(output_exe, allow_existing=true, permission=0o644)
defer out.close()
append_file(out, input_exe, 0)
let exe_size = @fs.file_size(input_exe)
append_file(out, tmp, exe_size)
}
}
@fs.remove(tmp)
println(
"bundled " +
m.entries.length().to_string() +
" files -> " +
output_exe +
(if icon_copied { " (icon copied)" } else { "" }),
)
report_output_md5(output_exe)
}