灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-kernel/src/drivers/lingfu.rs
//! lingfu, the package manager's real network client: catalog sync,
//! search, and install over genuine HTTP through `netstack` -- the wire
//! is real (pcap-verifiable), the server is real (any HTTP server the
//! repo URL points at), the install is the existing `.lpkg` unpack. What
//! remains a hosting decision, per packages/README.md, is who runs the
//! public repo: the default URL is QEMU SLIRP's host alias (10.0.2.2:8000
//! -- `python -m http.server 8000` beside the repo files makes the host
//! the repo), overridable by writing "a.b.c.d:port" into lingfs `/repo`.
//!
//! Catalog format, deliberately trivial (one line per package):
//! `name version filename.lpkg description words...`
//!
//! Real limits, disclosed: a registry install downloads the source `.tgz`
//! into a heap buffer capped at `MAX_PKG_TGZ` (8 MiB) and unpacks each file
//! through lingfs's multi-block writer (`write_in_dir_any`, ~64 MiB/file), so
//! multi-megabyte packages (e.g. ling-lang) install for real -- a package
//! whose download exceeds the cap (e.g. the 2.6 GB soul-symphony blob) is
//! rejected cleanly rather than truncated. No signatures yet (packages/README
//! step 5); the catalog says so.

use crate::drivers::netstack;
use crate::fs::{lingfs, packages};
use alloc::vec::Vec;

const CATALOG_MAX: usize = 16 * 1024;
/// Largest registry `.tgz` the in-kernel installer will download+unpack. The
/// download lands in a heap buffer this size (freed after install); a body that
/// fills it is treated as over-cap and rejected (the TLS reader stops at buffer
/// capacity, so a truncated stream would otherwise reach gunzip as garbage --
/// exactly the old "could not decompress" failure). 8 MiB comfortably covers
/// ling-lang (~2.9 MiB) with headroom while still rejecting pathological blobs.
const MAX_PKG_TGZ: usize = 8 * 1024 * 1024;
static mut CATALOG: [u8; CATALOG_MAX] = [0; CATALOG_MAX];
static mut CATALOG_LEN: usize = 0;
/// Scratch for the raw HTTP response body before it's normalized into the
/// tab-delimited CATALOG (JSON from the official registry, or a plaintext
/// `catalog.txt` from a local /repo override). Separate from CATALOG so the
/// normalizer can read raw input while writing normalized output.
static mut RAW: [u8; CATALOG_MAX] = [0; CATALOG_MAX];

// Normalized catalog line format, one package per line, TAB-delimited so
// descriptions may contain spaces (and the fields stay index-stable even
// when one is empty -- splitters must NOT drop empty fields):
//   name \t meta \t filename \t description
// `meta` is the download count (official) or version (local /repo). `filename`
// is the installable `.lpkg` for a local repo, empty for the official registry
// (which has no public binary-download endpoint -- list/search work, install
// needs a local /repo). See `normalize_json` / `normalize_catalog_txt`.
const FSEP: u8 = b'\t';

fn parse_addr(s: &[u8]) -> Option<([u8; 4], u16)> {
    let mut ip = [0u8; 4];
    let mut part = 0usize;
    let mut acc: u32 = 0;
    let mut any = false;
    let mut port: u32 = 0;
    let mut in_port = false;
    for &b in s {
        match b {
            b'0'..=b'9' => {
                acc = acc * 10 + (b - b'0') as u32;
                if in_port {
                    port = acc;
                }
                any = true;
            },
            b'.' if !in_port && part < 3 => {
                ip[part] = acc.min(255) as u8;
                part += 1;
                acc = 0;
            },
            b':' if part == 3 => {
                ip[3] = acc.min(255) as u8;
                part += 1;
                acc = 0;
                in_port = true;
            },
            b'\n' | b'\r' | b' ' => break,
            _ => return None,
        }
    }
    if part == 4 && in_port && any && port > 0 && port < 65536 {
        Some((ip, port as u16))
    } else {
        None
    }
}

/// Output capture, so the graphics Terminal app can show lingfu's output in
/// its own window instead of the text console. When capturing, `print`
/// appends into `CAP` (bounded); otherwise it goes to the console as before,
/// keeping the text-mode `lsh` path unchanged.
static mut CAP: [u8; 8 * 1024] = [0; 8 * 1024];
static mut CAP_LEN: usize = 0;
static mut CAPTURING: bool = false;

pub fn begin_capture() {
    unsafe {
        CAPTURING = true;
        CAP_LEN = 0;
    }
}

pub fn end_capture() -> &'static [u8] {
    unsafe {
        CAPTURING = false;
        &(&*&raw const CAP)[..CAP_LEN]
    }
}

/// True once a catalog has been synced this session.
pub fn synced() -> bool {
    unsafe { CATALOG_LEN > 0 }
}

/// The raw synced catalog bytes (one package per line), for the GUI package
/// manager to parse and render. Empty until `sync()` succeeds.
pub fn catalog_raw() -> &'static [u8] {
    catalog()
}

fn print(s: &[u8]) {
    unsafe {
        if CAPTURING {
            let cap = &mut *&raw mut CAP;
            let room = cap.len().saturating_sub(CAP_LEN);
            let n = s.len().min(room);
            cap[CAP_LEN..CAP_LEN + n].copy_from_slice(&s[..n]);
            CAP_LEN += n;
            return;
        }
    }
    crate::console_write(s);
}

/// The official package registry, fetched over HTTPS (TLS 1.3).
const OFFICIAL_HOST: &str = "fu.ling-lang.org";

/// Local repo override address from lingfs `/repo` ("a.b.c.d:port"), if set.
fn repo_override() -> Option<([u8; 4], u16)> {
    let mut rb = [0u8; 4096];
    if let Ok(Some(len)) = lingfs::read_file("repo", &mut rb) {
        return parse_addr(&rb[..len]);
    }
    None
}

/// Fetch `path`'s response BODY (HTTP headers stripped) into `out`. Uses the
/// local HTTP override in lingfs `/repo` if present, otherwise the official
/// registry over HTTPS.
fn fetch(path: &str, out: &mut [u8]) -> Option<usize> {
    if let Some((ip, port)) = repo_override() {
        return netstack::http_get(ip, port, path, "lingos-repo", out);
    }
    let mut noop = |_: &[u8]| {};
    match crate::tls::https_get(OFFICIAL_HOST, 443, path, out, &mut noop) {
        Ok(n) if n > 0 => {
            let off = crate::tls::http_body_offset(&out[..n]);
            if off > 0 {
                out.copy_within(off..n, 0);
                Some(n - off)
            } else {
                Some(n)
            }
        },
        _ => None,
    }
}

/// Fetch `path`'s response body from the official registry over HTTPS,
/// ignoring any local `/repo` override -- for assets like package avatars
/// that only the public host serves. Returns the body length in `out`.
pub fn fetch_official(path: &str, out: &mut [u8]) -> Option<usize> {
    let mut noop = |_: &[u8]| {};
    match crate::tls::https_get(OFFICIAL_HOST, 443, path, out, &mut noop) {
        Ok(n) if n > 0 => {
            let off = crate::tls::http_body_offset(&out[..n]);
            if off > 0 {
                out.copy_within(off..n, 0);
                Some(n - off)
            } else {
                Some(n)
            }
        },
        _ => None,
    }
}

/// Open a keep-alive session to the official registry (one TLS handshake for a
/// whole batch of fetches, e.g. package icons). Pair with `close_official`.
pub fn open_official() -> bool {
    let mut noop = |_: &[u8]| {};
    crate::tls::https_open(OFFICIAL_HOST, 443, &mut noop)
}

/// Fetch `path`'s body over the open official session. Returns body length.
pub fn next_official(path: &str, out: &mut [u8]) -> Option<usize> {
    match crate::tls::https_next(OFFICIAL_HOST, path, out) {
        Some(n) if n > 0 => {
            let off = crate::tls::http_body_offset(&out[..n]);
            if off > 0 {
                out.copy_within(off..n, 0);
                Some(n - off)
            } else {
                Some(n)
            }
        },
        _ => None,
    }
}

/// Close the official keep-alive session (releases the net lock).
pub fn close_official() {
    crate::tls::https_close();
}

/// Extract a JSON string field `"key":"value"` from `json`, or None.
fn json_field<'a>(json: &'a [u8], key: &[u8]) -> Option<&'a [u8]> {
    let n = json.len();
    let mut i = 0;
    while i + key.len() + 3 < n {
        if json[i] == b'"' && json[i + 1..].starts_with(key) && json[i + 1 + key.len()] == b'"' {
            let mut j = i + 2 + key.len();
            while j < n && (json[j] == b' ' || json[j] == b':') {
                j += 1;
            }
            if j < n && json[j] == b'"' {
                j += 1;
                let s = j;
                while j < n && json[j] != b'"' {
                    j += 1;
                }
                return Some(&json[s..j]);
            }
        }
        i += 1;
    }
    None
}

/// Decompress a gzip stream (skips the header, raw-inflates the body).
fn gunzip(gz: &[u8]) -> Option<Vec<u8>> {
    if gz.len() < 18 || gz[0] != 0x1f || gz[1] != 0x8b || gz[2] != 8 {
        return None;
    }
    let flg = gz[3];
    let mut off = 10usize;
    if flg & 4 != 0 {
        // FEXTRA
        if off + 2 > gz.len() {
            return None;
        }
        let xlen = gz[off] as usize | ((gz[off + 1] as usize) << 8);
        off += 2 + xlen;
    }
    if flg & 8 != 0 {
        // FNAME (zero-terminated)
        while off < gz.len() && gz[off] != 0 {
            off += 1;
        }
        off += 1;
    }
    if flg & 16 != 0 {
        // FCOMMENT
        while off < gz.len() && gz[off] != 0 {
            off += 1;
        }
        off += 1;
    }
    if flg & 2 != 0 {
        off += 2; // FHCRC
    }
    if off >= gz.len() {
        return None;
    }
    // Raw inflate stops at the end of the deflate stream, ignoring the trailer.
    miniz_oxide::inflate::decompress_to_vec(&gz[off..]).ok()
}

/// Parse an octal field (tar header numbers), stopping at NUL/space.
fn tar_oct(b: &[u8]) -> usize {
    let mut v = 0usize;
    for &c in b {
        if (b'0'..=b'7').contains(&c) {
            v = v * 8 + (c - b'0') as usize;
        } else {
            break;
        }
    }
    v
}

/// Unpack a POSIX tar image, writing each regular file (by basename) into
/// `pkg_dir` in lingfs. Returns the number of files installed.
fn untar_install(tar: &[u8], pkg_dir: &str) -> usize {
    let mut off = 0usize;
    let mut count = 0usize;
    while off + 512 <= tar.len() {
        let hdr = &tar[off..off + 512];
        if hdr.iter().all(|&b| b == 0) {
            break; // end-of-archive marker
        }
        let name_end = hdr[..100].iter().position(|&b| b == 0).unwrap_or(100);
        let fullname = &hdr[..name_end];
        let size = tar_oct(&hdr[124..136]);
        let typ = hdr[156];
        off += 512;
        let data_end = off + size;
        if data_end > tar.len() {
            break;
        }
        // Regular file ('0' or NUL typeflag). Skip dirs/links/metadata.
        if (typ == b'0' || typ == 0) && size > 0 {
            let base = fullname.rsplit(|&b| b == b'/').next().unwrap_or(fullname);
            if !base.is_empty() && base != b"." {
                if let Ok(bn) = core::str::from_utf8(base) {
                    // `_any` (manifest-backed) writer: a package's source files
                    // routinely exceed one 4KiB block, and the plain
                    // `write_in_dir` caps at a single block -- so larger files
                    // were silently dropped (part of why ling-lang "installed"
                    // but was unusable). This lifts each file to lingfs's
                    // ~64MiB multi-block ceiling.
                    if lingfs::write_in_dir_any(pkg_dir, bn, &tar[off..data_end]).is_ok() {
                        count += 1;
                    }
                }
            }
        }
        off = data_end;
        off += (512 - (size % 512)) % 512; // pad to 512
    }
    count
}

/// Install a package from the official registry: resolve its latest version via
/// `/api/package?name=`, download `/dl/<name>-<version>.tgz`, gunzip + untar it,
/// and land the source files in lingfs under `pkg-<name>/`. Returns true on a
/// completed install. (Source packages -- running them still needs the `ling`
/// toolchain; this makes `lingfu install` fetch + unpack real registry packages.)
pub fn install_from_registry(name: &str) -> bool {
    // 1. Resolve the latest version.
    print(b"lingfu: resolving ");
    print(name.as_bytes());
    print(b" ...\n");
    let mut apipath = [0u8; 160];
    let mut p = 0usize;
    for &b in b"/api/package?name=" {
        apipath[p] = b;
        p += 1;
    }
    for &b in name.as_bytes() {
        if p < apipath.len() {
            apipath[p] = b;
            p += 1;
        }
    }
    let raw = unsafe { &mut *&raw mut RAW };
    let Ok(apistr) = core::str::from_utf8(&apipath[..p]) else { return false };
    // One retry: the first HTTPS attempt to the registry occasionally fails to
    // connect (transient TLS/connect), and a second almost always succeeds --
    // this is the "could not reach the registry" the user hit on a first try.
    let alen_opt = match fetch_official(apistr, raw) {
        some @ Some(_) => some,
        None => fetch_official(apistr, raw),
    };
    let Some(alen) = alen_opt else {
        print(b"lingfu: could not reach the registry\n");
        return false;
    };
    let Some(ver) = json_field(&raw[..alen], b"version") else {
        print(b"lingfu: package not found or has no published version\n");
        return false;
    };
    let mut verbuf = [0u8; 48];
    let vlen = ver.len().min(verbuf.len());
    verbuf[..vlen].copy_from_slice(&ver[..vlen]);

    // 2. Download /dl/<name>-<version>.tgz.
    let mut dlpath = [0u8; 200];
    let mut d = 0usize;
    let mut put = |s: &[u8], d: &mut usize| {
        for &b in s {
            if *d < dlpath.len() {
                dlpath[*d] = b;
                *d += 1;
            }
        }
    };
    put(b"/dl/", &mut d);
    put(name.as_bytes(), &mut d);
    put(b"-", &mut d);
    put(&verbuf[..vlen], &mut d);
    put(b".tgz", &mut d);
    let Ok(dlstr) = core::str::from_utf8(&dlpath[..d]) else { return false };
    print(b"lingfu: downloading ");
    print(dlstr.as_bytes());
    print(b" ...\n");
    let mut dl: Vec<u8> = Vec::new();
    dl.resize(MAX_PKG_TGZ, 0);
    let Some(glen) = fetch_official(dlstr, &mut dl) else {
        print(b"lingfu: download failed\n");
        return false;
    };
    if glen >= MAX_PKG_TGZ {
        // Body filled the whole cap -> it's over the limit (or exactly at it),
        // so what we hold is a truncated prefix. Reject cleanly rather than
        // decompress garbage.
        print(b"lingfu: package too large for the in-kernel installer (>8MB) -- skipped\n");
        return false;
    }

    // 3. Gunzip + untar into lingfs under pkg-<name>/.
    let Some(tar) = gunzip(&dl[..glen]) else {
        print(b"lingfu: could not decompress the package (truncated?)\n");
        return false;
    };
    let mut pkgdir = [0u8; 64];
    let mut pd = 0usize;
    for &b in b"pkg-" {
        pkgdir[pd] = b;
        pd += 1;
    }
    for &b in name.as_bytes() {
        if pd < pkgdir.len() {
            pkgdir[pd] = b;
            pd += 1;
        }
    }
    let Ok(pkgdirs) = core::str::from_utf8(&pkgdir[..pd]) else { return false };
    let files = untar_install(&tar, pkgdirs);
    if files == 0 {
        print(b"lingfu: package unpacked to no installable files\n");
        return false;
    }
    // Record the installed version so `lingfu list` / `ls packages` shows it.
    let _ = lingfs::write_in_dir("packages", name, &verbuf[..vlen]);
    print(b"lingfu: installed ");
    print(name.as_bytes());
    print(b" (");
    print(&verbuf[..vlen]);
    print(b") -- source in pkg-");
    print(name.as_bytes());
    print(b"/\n");
    true
}

/// Append one printable-ASCII byte to CATALOG at `w`, returning the new `w`.
/// Non-ASCII (the registry carries Thai/CJK descriptions) is dropped -- the
/// font is 8x8 ASCII, and the layout/list renderers skip it anyway.
fn cat_push(cat: &mut [u8], w: usize, b: u8) -> usize {
    if w < cat.len() && (0x20..=0x7e).contains(&b) {
        cat[w] = b;
        return w + 1;
    }
    w
}

fn cat_push_sep(cat: &mut [u8], w: usize) -> usize {
    if w < cat.len() {
        cat[w] = FSEP;
        return w + 1;
    }
    w
}

/// Find `"key"` used as an object key (followed, after optional space, by ':')
/// in `obj`, returning the index just past the ':'.
fn json_key_pos(obj: &[u8], key: &[u8]) -> Option<usize> {
    let mut i = 0usize;
    'outer: while i + key.len() + 2 < obj.len() {
        if obj[i] == b'"' && obj[i + 1..].starts_with(key) && obj[i + 1 + key.len()] == b'"' {
            let mut j = i + 2 + key.len();
            while j < obj.len() && (obj[j] == b' ' || obj[j] == b'\t') {
                j += 1;
            }
            if j < obj.len() && obj[j] == b':' {
                return Some(j + 1);
            }
            i += 1;
            continue 'outer;
        }
        i += 1;
    }
    None
}

/// Copy the JSON string value of `key` from `obj` into CATALOG at `w`
/// (unescaping the common escapes, ASCII-filtered, capped at `max` bytes).
/// Returns the new write cursor.
fn json_copy_string(obj: &[u8], key: &[u8], cat: &mut [u8], mut w: usize, max: usize) -> usize {
    let Some(mut p) = json_key_pos(obj, key) else { return w };
    while p < obj.len() && obj[p] != b'"' {
        if obj[p] == b',' || obj[p] == b'}' {
            return w; // value wasn't a string
        }
        p += 1;
    }
    if p >= obj.len() {
        return w;
    }
    p += 1; // past opening quote
    let start = w;
    while p < obj.len() && obj[p] != b'"' {
        let b = if obj[p] == b'\\' && p + 1 < obj.len() {
            p += 1;
            match obj[p] {
                b'n' | b't' | b'r' => b' ',
                b'u' => {
                    // \uXXXX -> skip the 4 hex digits, emit nothing.
                    p += 4;
                    p += 1;
                    continue;
                },
                other => other,
            }
        } else {
            obj[p]
        };
        if w - start >= max {
            break;
        }
        w = cat_push(cat, w, b);
        p += 1;
    }
    w
}

/// Copy the JSON number value of `key` from `obj` into CATALOG at `w`.
fn json_copy_number(obj: &[u8], key: &[u8], cat: &mut [u8], mut w: usize) -> usize {
    let Some(mut p) = json_key_pos(obj, key) else { return w };
    while p < obj.len() && (obj[p] == b' ' || obj[p] == b'\t') {
        p += 1;
    }
    while p < obj.len() && obj[p].is_ascii_digit() {
        w = cat_push(cat, w, obj[p]);
        p += 1;
    }
    w
}

/// Parse the official registry JSON into normalized CATALOG lines. The
/// endpoint returns `{"packages":[{name,description,downloads},...]}` (an
/// object wrapping the array), so we position at the array and walk its
/// top-level `{...}` elements -- string-aware, so braces or brackets inside a
/// description don't confuse element boundaries. Returns line count.
fn normalize_json(raw: &[u8]) -> usize {
    let cat = unsafe { &mut *&raw mut CATALOG };
    let n = raw.len();
    let mut w = 0usize;

    // Position `i` at the start of the packages array: just past the first
    // top-level '[' (works for both {"packages":[...]} and a bare [...]).
    let mut i = 0usize;
    {
        let mut in_str = false;
        let mut found = false;
        while i < n {
            let b = raw[i];
            if in_str {
                if b == b'\\' {
                    i += 1;
                } else if b == b'"' {
                    in_str = false;
                }
            } else if b == b'"' {
                in_str = true;
            } else if b == b'[' {
                i += 1;
                found = true;
                break;
            }
            i += 1;
        }
        if !found {
            unsafe { CATALOG_LEN = 0 };
            return 0;
        }
    }

    // Walk array elements. Each package object runs from its '{' to the
    // matching '}', tracking strings so quoted braces don't miscount depth.
    loop {
        // Skip to the next '{' (string-aware); stop at the array's ']'.
        let mut in_str = false;
        while i < n {
            let b = raw[i];
            if in_str {
                if b == b'\\' {
                    i += 1;
                } else if b == b'"' {
                    in_str = false;
                }
            } else if b == b'"' {
                in_str = true;
            } else if b == b'{' {
                break;
            } else if b == b']' {
                i = n; // end of array
                break;
            }
            i += 1;
        }
        if i >= n {
            break;
        }
        let obj_start = i;
        let mut j = i + 1;
        let mut depth = 1i32;
        let mut in_str = false;
        while j < n && depth > 0 {
            let b = raw[j];
            if in_str {
                if b == b'\\' {
                    j += 1;
                } else if b == b'"' {
                    in_str = false;
                }
            } else {
                match b {
                    b'"' => in_str = true,
                    b'{' => depth += 1,
                    b'}' => depth -= 1,
                    _ => {},
                }
            }
            j += 1;
        }
        let obj = &raw[obj_start..j.min(n)];
        i = j;

        // name \t downloads \t (empty filename) \t description
        let before = w;
        w = json_copy_string(obj, b"name", cat, w, 48);
        if w == before {
            continue; // no name -> skip
        }
        w = cat_push_sep(cat, w);
        w = json_copy_number(obj, b"downloads", cat, w);
        w = cat_push_sep(cat, w);
        w = cat_push_sep(cat, w); // filename: empty for the official registry
        w = json_copy_string(obj, b"description", cat, w, 96);
        if w < cat.len() {
            cat[w] = b'\n';
            w += 1;
        }
    }
    unsafe { CATALOG_LEN = w };
    count()
}

/// Parse a local `/repo` plaintext catalog ("name version filename desc...",
/// space-delimited) into normalized tab-delimited CATALOG lines.
fn normalize_catalog_txt(raw: &[u8]) -> usize {
    let cat = unsafe { &mut *&raw mut CATALOG };
    let mut w = 0usize;
    for line in raw.split(|&b| b == b'\n') {
        let line = if line.last() == Some(&b'\r') { &line[..line.len() - 1] } else { line };
        if line.is_empty() {
            continue;
        }
        let mut fields = line.split(|&b| b == b' ').filter(|f| !f.is_empty());
        let (Some(name), ver, file) = (fields.next(), fields.next(), fields.next()) else {
            continue;
        };
        for &b in name {
            w = cat_push(cat, w, b);
        }
        w = cat_push_sep(cat, w);
        for &b in ver.unwrap_or(b"") {
            w = cat_push(cat, w, b);
        }
        w = cat_push_sep(cat, w);
        for &b in file.unwrap_or(b"") {
            w = cat_push(cat, w, b);
        }
        w = cat_push_sep(cat, w);
        // The rest of the line (after the 3 fields) is the description.
        let mut seen = 0;
        let mut k = 0;
        while k < line.len() && seen < 3 {
            while k < line.len() && line[k] == b' ' {
                k += 1;
            }
            while k < line.len() && line[k] != b' ' {
                k += 1;
            }
            seen += 1;
        }
        while k < line.len() && line[k] == b' ' {
            k += 1;
        }
        for &b in &line[k..] {
            w = cat_push(cat, w, b);
        }
        if w < cat.len() {
            cat[w] = b'\n';
            w += 1;
        }
    }
    unsafe { CATALOG_LEN = w };
    count()
}

/// Sync the catalog. A local `/repo` override (if set) serves a plaintext
/// `catalog.txt`; otherwise the official registry's `/api/packages` JSON is
/// fetched over HTTPS. Returns the number of package lines, 0 on any failure.
pub fn sync() -> usize {
    let raw = unsafe { &mut *&raw mut RAW };
    let (path, local) = if repo_override().is_some() {
        print(b"lingfu: syncing catalog from /repo (HTTP)...\n");
        ("/catalog.txt", true)
    } else {
        print(b"lingfu: syncing catalog from fu.ling-lang.org (HTTPS)...\n");
        ("/api/packages", false)
    };
    match fetch(path, raw) {
        Some(len) => {
            let n = if local { normalize_catalog_txt(&raw[..len]) } else { normalize_json(&raw[..len]) };
            if n == 0 {
                print(b"lingfu: synced, but the catalog was empty or unrecognized.\n");
            } else {
                print(b"lingfu: catalog synced\n");
            }
            n
        },
        None => {
            unsafe { CATALOG_LEN = 0 };
            print(b"lingfu: sync failed (TLS/connect error, or no catalog served).\n");
            print(b"lingfu: point elsewhere by writing \"a.b.c.d:port\" into lingfs /repo (HTTP).\n");
            0
        },
    }
}

fn catalog() -> &'static [u8] {
    unsafe { &(&*&raw const CATALOG)[..CATALOG_LEN] }
}

fn count() -> usize {
    catalog().split(|&b| b == b'\n').filter(|l| !l.is_empty()).count()
}

/// Print catalog lines, optionally only those containing `query`.
pub fn list(query: &str) -> usize {
    if unsafe { CATALOG_LEN } == 0 {
        print(b"lingfu: no catalog -- run 'lingfu sync' first (and see 'lingfu list' for installed)\n");
        return 0;
    }
    let mut shown = 0;
    for line in catalog().split(|&b| b == b'\n') {
        if line.is_empty() {
            continue;
        }
        if !query.is_empty() && !contains(line, query.as_bytes()) {
            continue;
        }
        print(b"  ");
        // Render the tab field separators as spaces for the console.
        let mut buf = [0u8; 160];
        let m = line.len().min(buf.len());
        for i in 0..m {
            buf[i] = if line[i] == FSEP { b' ' } else { line[i] };
        }
        print(&buf[..m]);
        print(b"\n");
        shown += 1;
    }
    if shown == 0 {
        print(b"lingfu: no catalog entry matches\n");
    }
    shown
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() {
        return true;
    }
    if hay.len() < needle.len() {
        return false;
    }
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Download and install one catalog package by name: resolve its filename
/// from the synced catalog, HTTP-GET it, land the blob in lingfs, unpack
/// via the existing `.lpkg` path. Returns true on a completed install.
pub fn install(name: &str) -> bool {
    // Official registry (no local /repo override): resolve the version and pull
    // the source tarball from /dl, unpacking it into lingfs. A local /repo
    // still serves the catalog.txt-listed `.lpkg` blobs (the branch below).
    if repo_override().is_none() {
        return install_from_registry(name);
    }
    if unsafe { CATALOG_LEN } == 0 && sync() == 0 {
        return false;
    }
    // Find the "name \t meta \t filename \t desc" line; field 2 is the
    // installable filename (empty for the official registry -- no public
    // binary-download endpoint, only a local /repo serves .lpkg blobs).
    let mut filename = [0u8; 64];
    let mut fn_len = 0usize;
    let mut found = false;
    for line in catalog().split(|&b| b == b'\n') {
        if line.is_empty() {
            continue;
        }
        let mut fields = line.split(|&b| b == FSEP);
        let (Some(n), _meta, file) = (fields.next(), fields.next(), fields.next()) else {
            continue;
        };
        if n == name.as_bytes() {
            found = true;
            let f = file.unwrap_or(b"");
            fn_len = f.len().min(filename.len());
            filename[..fn_len].copy_from_slice(&f[..fn_len]);
            break;
        }
    }
    if !found {
        print(b"lingfu: package not in catalog (try 'lingfu search')\n");
        return false;
    }
    if fn_len == 0 {
        print(b"lingfu: this catalog lists packages but serves no downloadable file.\n");
        print(b"lingfu: the public registry has no binary-download API yet -- point at a\n");
        print(b"lingfu: local .lpkg repo by writing \"a.b.c.d:port\" into lingfs /repo.\n");
        return false;
    }
    let Ok(fname) = core::str::from_utf8(&filename[..fn_len]) else { return false };

    let mut path = [0u8; 72];
    path[0] = b'/';
    path[1..1 + fn_len].copy_from_slice(&filename[..fn_len]);
    let Ok(pathstr) = core::str::from_utf8(&path[..1 + fn_len]) else { return false };

    print(b"lingfu: downloading ");
    print(fname.as_bytes());
    print(b" ...\n");
    // Single-block lingfs cap, disclosed in the module doc: bodies bigger
    // than one block fail the write below rather than silently truncating.
    static mut BLOB: [u8; 64 * 1024] = [0; 64 * 1024];
    let blob = unsafe { &mut *&raw mut BLOB };
    let Some(len) = fetch(pathstr, blob) else {
        print(b"lingfu: download failed\n");
        return false;
    };
    if lingfs::write_in_dir("catalog", fname, &blob[..len]).is_err() {
        print(b"lingfu: could not store the blob in lingfs (package too big for a single-block file? multi-block files are queued work)\n");
        return false;
    }
    print(b"lingfu: downloaded, installing...\n");
    // packages::install expects the blob's lingfs name; the catalog dir
    // convention matches the local-install flow's.
    let mut full = [0u8; 96];
    let prefix = b"catalog/";
    full[..prefix.len()].copy_from_slice(prefix);
    full[prefix.len()..prefix.len() + fn_len].copy_from_slice(&filename[..fn_len]);
    let Ok(fullstr) = core::str::from_utf8(&full[..prefix.len() + fn_len]) else { return false };
    if packages::install(fullstr) {
        print(b"lingfu: installed (see 'lingfu list' / 'ls packages')\n");
        true
    } else {
        print(b"lingfu: blob downloaded but unpack failed (malformed .lpkg?)\n");
        false
    }
}

// ── Package manager views (both desktops' Packages windows) ─────────────────

/// The synced catalog as (name, meta, description) rows; `meta` is the
/// download count on the official registry, the version on a local repo.
pub fn entries() -> Vec<(alloc::string::String, alloc::string::String, alloc::string::String)> {
    use alloc::string::String;
    let mut out = Vec::new();
    for line in catalog().split(|&b| b == b'\n') {
        if line.is_empty() {
            continue;
        }
        let mut f = line.split(|&b| b == FSEP);
        let s = |b: Option<&[u8]>| String::from_utf8_lossy(b.unwrap_or(b"")).into_owned();
        let name = s(f.next());
        let meta = s(f.next());
        let _file = f.next();
        let desc = s(f.next());
        if !name.is_empty() {
            out.push((name, meta, desc));
        }
    }
    out
}

/// Sync the catalog once (the registry over HTTPS) unless it is already here.
pub fn ensure_synced() -> bool {
    synced() || sync() > 0
}

/// Is `name` unpacked under pkg-<name>/ on storage?
pub fn is_installed(name: &str) -> bool {
    crate::vfs::is_dir(&alloc::format!("pkg-{name}"))
}

/// Does the installed package have a Ling entry (what the start menus run)?
pub fn is_app(name: &str) -> bool {
    crate::lingc::locate(&alloc::format!("pkg-{name}")).is_some()
}

/// Installed packages that are runnable, by name (for the start menus).
pub fn installed_apps() -> Vec<alloc::string::String> {
    let mut out = Vec::new();
    for (n, is_dir, _) in crate::vfs::list("") {
        if is_dir {
            if let Some(name) = n.strip_prefix("pkg-") {
                if is_app(name) {
                    out.push(alloc::string::String::from(name));
                }
            }
        }
    }
    out
}

/// What a package's description says about LingOS: (mentions LingOS, fits
/// x86-64, fits the Raspberry Pi). The registry carries no platform field, so
/// this is the convention: "LingOS" in the description marks a LingOS app;
/// "x86-64"/"x86_64"/"amd64" or "rpi5"/"Raspberry Pi"/"aarch64"/"arm64"
/// narrow it to one machine; naming neither means both.
pub fn for_lingos(desc: &str) -> (bool, bool, bool) {
    let d = desc.to_ascii_lowercase();
    let lingos = d.contains("lingos") || d.contains("ling os");
    let x86 = ["x86-64", "x86_64", "x86", "amd64"].iter().any(|w| d.contains(w));
    let pi = ["rpi5", "rpi 5", "rpi", "raspberry", "aarch64", "arm64"].iter().any(|w| d.contains(w));
    (lingos, x86 || !pi, pi || !x86)
}

← Back to index