-- MojoSetup; a portable, flexible installation application.
--
-- Please see the file LICENSE.txt in the source's root directory.
--
--  This file written by Ryan C. Gordon.


-- This is where most of the magic happens. Everything is initialized, and
--  the user's config script has successfully run. This Lua chunk drives the
--  main application code.

-- !!! FIXME: add a --dryrun option.

local _ = MojoSetup.translate

MojoSetup.metadatakey = ".mojosetup_metadata."
MojoSetup.metadatadesc = _("Metadata")
MojoSetup.metadatadirname = ".mojosetup"

if MojoSetup.info.platform == "windows" then
    MojoSetup.controlappname = "mojosetup.exe"
else
    MojoSetup.controlappname = "mojosetup"
end


local function badcmdline()
    MojoSetup.fatal(_("Invalid command line"))
end


-- This currently counts on (base) ending with a single "/", and not having
--  any strange distortions: "/blah/../blah/.//" would not match "/blah/x",
--  although it _should_ if we parsed it out.
local function make_relative(fname, base)
    local baselen = string.len(base)
    if string.sub(fname, 0, baselen) == base then
        fname = string.sub(fname, baselen+2)  -- make it relative.
    end
    return fname
end


local function manifest_resync(man, fname, _key)
    if fname == nil then return end

    local fullpath = fname
    fname = make_relative(fname, MojoSetup.destination)

    if man[fname] == nil then
        MojoSetup.logwarning("Tried to resync unknown file '" ..fname.. "' in manifest!")
    else
        local perms = "0644" -- !!! FIXME MojoSetup.platform.perms(fullpath)
        local sums = nil
        local lndest = nil
        local ftype = nil

        if MojoSetup.platform.issymlink(fullpath) then
            ftype = "symlink"
            -- !!! FIXME: linkdest?
        elseif MojoSetup.platform.isdir(fullpath) then
            ftype = "dir"
        else  -- !!! FIXME: other types?
            ftype = "file"
            sums = MojoSetup.checksum(fullpath)
        end

        man[fname] =
        {
            key = _key,
            type = ftype,
            mode = perms,
            checksums = sums,
            linkdest = lndest
        }

        MojoSetup.logwarning("Resync'd file '" ..fname.. "' in manifest")
    end
end


-- !!! FIXME: I need to go back from managing everything installed through
-- !!! FIXME:  the manifest to a separate table of "things written to disk
-- !!! FIXME:  on just this run" ... the existing manifest code can stay as-is,
-- !!! FIXME:  rollbacks should be done exclusively from that other table.
-- !!! FIXME: Right now we're relying on dumb stuff like sorting the filenames
-- !!! FIXME:  to get a safe deletion order on revert, but we should just
-- !!! FIXME:  keep a chronological array instead.

local function manifest_add(man, fname, _key, ftype, mode, sums, lndest)
    if (fname ~= nil) and (_key ~= nil) then
        fname = make_relative(fname, MojoSetup.destination)

        if man[fname] ~= nil then
            MojoSetup.logwarning("Overwriting file '" .. fname .. "' in manifest!")
        end

        man[fname] = {
            key = _key,
            type = ftype,
            mode = perms,
            checksums = sums,
            linkdest = lndest
        }

        if MojoSetup.oldfiles[fname] ~= nil then
            MojoSetup.oldfiles[fname].seen = true
        end
    end
end


local function manifest_delete(man, fname)
    if fname ~= nil then
        fname = make_relative(fname, MojoSetup.destination)

        if man[fname] == nil then
            MojoSetup.logwarning("Deleting unknown file '" .. fname .. "' from manifest!")
        else
            man[fname] = nil
        end
    end
end


local function flatten_list(list)
    local retval = list
    if type(list) == "table" then
        retval = {}
        for i,v in ipairs(list) do
            if #retval == 0 then
                retval[1] = v
            else
                retval[#retval+1] = ';'
                retval[#retval+1] = v
            end
        end
        retval = table.concat(retval)
    end
    return retval
end


local function do_delete(fname)
    local retval = false
    if fname == nil then
        retval = true
    else
        -- Try to unlink() first, so we'll catch broken symlinks, then try
        --  exists(): if it really wasn't there, we'll call it success anyhow.
        if MojoSetup.platform.unlink(fname) then
            MojoSetup.loginfo("Deleted '" .. fname .. "'")
            retval = true
        elseif not MojoSetup.platform.exists(fname) then
            retval = true
        else
            MojoSetup.logerror("Deleting '" .. fname .. "'" .. " failed!")
        end
    end
    return retval
end


local function delete_files(rootdir, filelist, callback, error_is_fatal)
    if filelist ~= nil then
        local max = #filelist
        for i = max,1,-1 do
            local fname
            if rootdir == nil then
                fname = filelist[i]
            elseif filelist[i] == "" then
                fname = rootdir
            else
                fname = rootdir .. "/" .. filelist[i]
            end
            if not do_delete(fname) and error_is_fatal then
                MojoSetup.fatal(_("Deletion failed!"))
            end

            if callback ~= nil then
                callback(filelist[i], i, max)
            end
        end
    end
end

local function delete_rollbacks(callback)
    if MojoSetup.rollbacks == nil then
        return
    end
    local fnames = {}
    local max = #MojoSetup.rollbacks
    for id = 1,max,1 do
        fnames[id] = id
    end
    MojoSetup.rollbacks = {}   -- just in case this gets called again...
    delete_files(MojoSetup.rollbackdir, fnames, callback)
end

local function delete_scratchdirs()
    do_delete(MojoSetup.downloaddir)
    do_delete(MojoSetup.rollbackdir)
    do_delete(MojoSetup.scratchdir)  -- must be after dirs it contains!
    do_delete(MojoSetup.metadatadir)  -- must be last! (and is okay to fail.)
end


local function do_rollbacks()
    if MojoSetup.rollbacks == nil then
        return
    end

    local warned = false
    local max = #MojoSetup.rollbacks
    for id = max,1,-1 do
        local src = MojoSetup.rollbackdir .. "/" .. id
        local dest = MojoSetup.rollbacks[id]
        if not MojoSetup.movefile(src, dest) and not warned then
            -- we're already in fatal(), so we can only throw up a msgbox...
            MojoSetup.msgbox(_("Serious problem"),
                             _("Couldn't restore some files. Your existing installation is likely damaged."))
            warned = true
        end
        MojoSetup.loginfo("Restored rollback #" .. id .. ": '" .. dest .. "'")
    end

    MojoSetup.rollbacks = {}   -- just in case this gets called again...
end


-- get a linear array of filenames in the manifest.
local function flatten_manifest(man, postprocess)
    local files = {}
    if postprocess == nil then
        postprocess = function(x) return x end
    end
    if man ~= nil then
        for fname,items in pairs(man) do
            fname = postprocess(fname)
            if fname ~= nil then
                files[#files+1] = fname
            end
        end
    end

    table.sort(files, function(a,b) return MojoSetup.strcmp(a,b) < 0 end)
    return files
end


-- This gets called by fatal()...must be a global function.
function MojoSetup.revertinstall()
    -- (The real revertinstall is set later. This is a stub for startup.)
end


local function calc_percent(current, total)
    if total == 0 then
        return 0
    elseif total < 0 then
        return -1
    end

    local retval = MojoSetup.truncatenum((current / total) * 100)
    if retval > 100 then
        retval = 100
    elseif retval < 0 then
        retval = 0
    end
    return retval
end


local function make_rate_string(rate, bw, total)
    local retval = nil

    if rate <= 0 then
        retval = _("stalled")
    else
        local ratetype = _("B/s")
        if rate > 1024 then
            rate = MojoSetup.truncatenum(rate / 1024)
            ratetype = _("KB/s")
        end

        if total > 0 then  -- can approximate time left if we know the goal.
            local bytesleft = total - bw
            local secsleft = MojoSetup.truncatenum(bytesleft / rate)
            local minsleft = MojoSetup.truncatenum(secsleft / 60)
            local hoursleft = MojoSetup.truncatenum(minsleft / 60)

            secsleft = string.sub("00" .. (secsleft - (minsleft * 60)), -2)
            minsleft = string.sub("00" .. (minsleft - (hoursleft * 60)), -2)

            if hoursleft < 10 then
                hoursleft = "0" .. hoursleft
            else
                hoursleft = tostring(hoursleft)
            end

            retval = MojoSetup.format(_("%0 %1, %2:%3:%4 remaining"),
                                      rate, ratetype,
                                      hoursleft, minsleft, secsleft)
        else
            retval = MojoSetup.format(_("%0 %1"), rate, ratetype)
        end
    end

    return retval
end


local function split_path(path)
    local retval = {}
    for item in string.gmatch(path .. "/", "(.-)/") do
        if item ~= "" then
            retval[#retval+1] = item
        end
    end
    return retval
end

local function rebuild_path(paths, n)
    local retval = paths[n]
    n = n + 1
    while paths[n] ~= nil do
        retval = retval .. "/" .. paths[n]
        n = n + 1
    end
    return retval
end

local function normalize_path(path)
    return rebuild_path(split_path(path), 1)
end


local function close_archive_list(arclist)
    for i = #arclist,1,-1 do
        MojoSetup.archive.close(arclist[i])
        arclist[i] = nil
    end
end


-- This code's a little nasty...
local function drill_for_archive(archive, path, arclist)
    if not MojoSetup.archive.enumerate(archive) then
        MojoSetup.fatal(_("Couldn't enumerate archive"))
    end

    local pathtab = split_path(path)
    local ent = MojoSetup.archive.enumnext(archive)
    while ent ~= nil do
        if ent.type == "file" then
            local i = 1
            local enttab = split_path(ent.filename)
            while (enttab[i] ~= nil) and (enttab[i] == pathtab[i]) do
                i = i + 1
            end

            if enttab[i] == nil then
                -- It's a file that makes up some of the specified path.
                --  open it as an archive and keep drilling...
                local arc = MojoSetup.archive.fromentry(archive)
                if arc == nil then
                    MojoSetup.fatal(_("Couldn't open archive"))
                end
                arclist[#arclist+1] = arc
                if pathtab[i] == nil then
                    return arc  -- this is the end of the path! Done drilling!
                end
                return drill_for_archive(arc, rebuild_path(pathtab, i), arclist)
            end
        end
        ent = MojoSetup.archive.enumnext(archive)
    end

    MojoSetup.fatal(_("Archive not found"))
end


local function install_file(dest, perms, writefn, desc, manifestkey)
    -- Upvalued so we don't look these up each time...
    local fname = string.gsub(dest, "^.*/", "", 1)  -- chop the dirs off...
    local ptype = _("Installing")
    local component = desc
    local keepgoing = true
    local callback = function(ticks, justwrote, bw, total)
        local percent = -1
        local item = fname
        if total >= 0 then
            MojoSetup.written = MojoSetup.written + justwrote
            percent = calc_percent(MojoSetup.written, MojoSetup.totalwrite)
            item = MojoSetup.format(_("%0: %1%%"), fname, calc_percent(bw, total))
        end
        keepgoing = MojoSetup.gui.progress(ptype, component, percent, item, true)
        return keepgoing
    end

    -- !!! FIXME: maybe keep a separate list, so we can rollback installs
    -- !!! FIXME:  that are building on previous installations?
    -- Add to manifest first, so we can delete it during rollback if i/o fails.
    -- !!! FIXME: perms may be nil...we need a MojoSetup.defaultPermsString()...
    manifest_add(MojoSetup.manifest, dest, manifestkey, "file", perms, nil, nil)

    MojoSetup.gui.progressitem()
    local written, sums = writefn(callback)
    if not written then
        if not keepgoing then
            MojoSetup.logerror("User cancelled install during file write.")
            MojoSetup.fatal()
        else
            MojoSetup.logerror("Failed to create file '" .. dest .. "'")
            MojoSetup.fatal(_("File creation failed!"))
        end
    end

    -- Readd it to the manifest, now with a checksum!
    if manifestkey ~= nil then
        manifest_delete(MojoSetup.manifest, dest)
        manifest_add(MojoSetup.manifest, dest, manifestkey, "file", perms, sums, nil)
    end

    MojoSetup.loginfo("Created file '" .. dest .. "'")
    MojoSetup.incrementgarbagecount()
end


local function install_file_from_archive(dest, archive, perms, desc, manifestkey)
    local fn = function(callback)
        return MojoSetup.writefile(archive, dest, perms, nil, callback)
    end
    return install_file(dest, perms, fn, desc, manifestkey)
end


local function install_file_from_stringtable(dest, t, perms, desc, manifestkey)
    local fn = function(callback)
        return MojoSetup.stringtabletofile(t, dest, perms, nil, callback)
    end
    return install_file(dest, perms, fn, desc, manifestkey)
end


local function install_file_from_string(dest, str, perms, desc, manifestkey)
    local fn = function(callback)
        return MojoSetup.stringtofile(str, dest, perms, nil, callback)
    end
    return install_file(dest, perms, fn, desc, manifestkey)
end


local function install_file_from_filesystem(dest, src, perms, desc, manifestkey, maxbytes)
    local fn = function(callback)
        return MojoSetup.copyfile(src, dest, perms, maxbytes, callback)
    end
    return install_file(dest, perms, fn, desc, manifestkey)
end


local function install_symlink(dest, lndest, desc, manifestkey)
    MojoSetup.gui.progressitem()
    -- The size of a symbolic link is the length of the path it points to
    MojoSetup.written = MojoSetup.written + string.len(lndest)
    local percent = calc_percent(MojoSetup.written, MojoSetup.totalwrite)
    MojoSetup.gui.progress(_("Installing"), desc, percent, dest, true)

    if not MojoSetup.platform.symlink(dest, lndest) then
        MojoSetup.logerror("Failed to create symlink '" .. dest .. "'")
        MojoSetup.fatal(_("Symlink creation failed!"))
    end

    manifest_add(MojoSetup.manifest, dest, manifestkey, "symlink", nil, nil, lndest)
    MojoSetup.loginfo("Created symlink '" .. dest .. "' -> '" .. lndest .. "'")
end


local function install_directory(dest, perms, desc, manifestkey)
    -- Chop any '/' chars from the end of the string...
    dest = string.gsub(dest, "/+$", "")

    if desc ~= nil then
        MojoSetup.gui.progressitem()
        -- Preserve the current completion percentage
        local percent = calc_percent(MojoSetup.written, MojoSetup.totalwrite)
        MojoSetup.gui.progress(_("Installing"), desc, percent, dest, true)
    end

    -- The directory may already exist in case of an upgrade.
    -- !!! FIXME: We should set the permissions (e.g. 0555) after filling up
    -- !!! FIXME:  the directory, including if it already exists (upgrade).
    if not MojoSetup.platform.mkdir(dest, perms) and not MojoSetup.platform.isdir(dest) then
        MojoSetup.logerror("Failed to create dir '" .. dest .. "'")
        MojoSetup.fatal(_("Directory creation failed"))
    end

    manifest_add(MojoSetup.manifest, dest, manifestkey, "directory", perms, nil, nil)
    MojoSetup.loginfo("Created directory '" .. dest .. "'")
end


local function install_parent_dirs(path, manifestkey)
    -- Chop any '/' chars from the end of the string...
    path = string.gsub(path, "/+$", "")

    -- Build each piece of final path. The gmatch() skips the last element.
    local fullpath = ""
    for item in string.gmatch(path, "(.-)/") do
        if item ~= "" then
            fullpath = fullpath .. "/" .. item
            if not MojoSetup.platform.exists(fullpath) then
                install_directory(fullpath, nil, nil, manifestkey)
            end
        end
    end
end


local function backup_file(path)
    if MojoSetup.platform.isdir(path) and
        not MojoSetup.platform.issymlink(path) then
        -- We must first create rollbacks for all the files and directories
        -- contained in this directory
        local dir = MojoSetup.platform.opendir(path)
        local dentry = MojoSetup.platform.readdir(dir)
        while dentry ~= nil do
            backup_file(path .. "/" .. dentry)
            dentry = MojoSetup.platform.readdir(dir)
        end
        MojoSetup.platform.closedir(dir)
    end

    local id = #MojoSetup.rollbacks + 1
    local f = MojoSetup.rollbackdir .. "/" .. id
    install_parent_dirs(f, MojoSetup.metadatakey)
    MojoSetup.rollbacks[id] = path
    if not MojoSetup.movefile(path, f) then
        MojoSetup.fatal(MojoSetup.format(_("Couldn't backup '%0' to '%1' for rollback"), path, f))
    end
    local relpath = make_relative(path, MojoSetup.destination)
    if MojoSetup.oldfiles[relpath] ~= nil then
        MojoSetup.oldfiles[relpath].seen = 1
        MojoSetup.loginfo("marked '" .. relpath .. "' as seen")
    else
        MojoSetup.loginfo(relpath .. " is not an old file")
    end
    MojoSetup.loginfo("Created rollback #" .. id .. ": '" .. path .. "'")
end


local function permit_write(dest, filename, type, file)
    local allowoverwrite = true
    if MojoSetup.platform.exists(dest) or MojoSetup.platform.issymlink(dest) then
        if type == "dir" then
            allowoverwrite = true
        else
            if MojoSetup.oldfiles[filename] ~= nil then
                allowoverwrite = true
            elseif MojoSetup.forceoverwrite ~= nil then
                allowoverwrite = MojoSetup.forceoverwrite
            else
                -- !!! FIXME: option/package-wide overwrite?
                allowoverwrite = false
                if file ~= nil then
                    allowoverwrite = file.allowoverwrite
                end
                if not allowoverwrite then
                    MojoSetup.loginfo("File '" .. dest .. "' already exists.")
                    local text = MojoSetup.format(_("File '%0' already exists! Replace?"), dest)
                    local ynan = MojoSetup.promptynan(_("Conflict!"), text, true)
                    if ynan == "always" then
                        MojoSetup.forceoverwrite = true
                        allowoverwrite = true
                    elseif ynan == "never" then
                        MojoSetup.forceoverwrite = false
                        allowoverwrite = false
                    elseif ynan == "yes" then
                        allowoverwrite = true
                    elseif ynan == "no" then
                        allowoverwrite = false
                    end
                end
            end

            -- !!! FIXME: Setup.File.mustoverwrite to override "never"?

            if allowoverwrite then
                backup_file(dest)
                -- Make sure this isn't already in the manifest...
                if MojoSetup.manifest[dest] ~= nil then
                    manifest_delete(MojoSetup.manifest, dest)
                end
            end
        end
    end

    return allowoverwrite
end


local function install_archive_entity(dest, ent, archive, desc, manifestkey, perms)
    install_parent_dirs(dest, manifestkey)
    if ent.type == "file" then
        install_file_from_archive(dest, archive, perms, desc, manifestkey)
    elseif ent.type == "dir" then
        install_directory(dest, perms, desc, manifestkey)
    elseif ent.type == "symlink" then
        install_symlink(dest, ent.linkdest, desc, manifestkey)
    else  -- !!! FIXME: device nodes, etc...
        -- !!! FIXME: should this be fatal?
        MojoSetup.fatal(_("Unknown file type in archive"))
    end
end


local function install_archive_entry(archive, ent, file, option)
    local entdest = ent.filename
    if entdest == nil then return end   -- probably can't happen...

    -- Set destination in native filesystem. May be default or explicit.
    local dest = file.destination
    if dest == nil then
        dest = entdest
    else
        dest = dest .. "/" .. entdest
    end

    local perms = file.permissions   -- may be nil

    if file.filter ~= nil then
        local filterperms
        dest, filterperms = file.filter(dest, ent)
        if filterperms ~= nil then
            perms = filterperms
        end
    end

    if dest ~= nil then  -- Only install if file wasn't filtered out
        dest = MojoSetup.destination .. "/" .. dest
        if permit_write(dest, ent.filename, ent.type, file) then
            local desc = option.description
            install_archive_entity(dest, ent, archive, desc, desc, perms)
        end
    end
end


local function install_archive(archive, file, option, dataprefix)
    if not MojoSetup.archive.enumerate(archive) then
        MojoSetup.fatal(_("Couldn't enumerate archive"))
    end

    local isbase = (archive == MojoSetup.archive.base)
    local single_match = true
    local wildcards = file.wildcards

    -- If there's only one explicit file we're looking for, we don't have to
    --  iterate the whole archive...we can stop as soon as we find it.
    if wildcards == nil then
        single_match = false
    else
        if type(wildcards) == "string" then
            wildcards = { wildcards }
        end
        if #wildcards > 1 then
            single_match = false
        else
            for i,v in ipairs(wildcards) do
                if string.find(v, "[*?]") ~= nil then
                    single_match = false
                    break  -- no reason to keep iterating...
                end
            end
        end
    end

    local ent = MojoSetup.archive.enumnext(archive)
    while ent ~= nil do
        -- If inside GBaseArchive (no URL lead in string), then we
        --  want to clip to data/ directory...
        if isbase and (string.len(dataprefix) > 0) then
            local count
            ent.filename, count = string.gsub(ent.filename, "^" .. dataprefix, "", 1)
            if count == 0 then
                ent.filename = nil
            end
        end
        
        -- See if we should install this file...
        if (ent.filename ~= nil) and (ent.filename ~= "") then
            local should_install = false
            if wildcards == nil then
                should_install = true
            else
                for i,v in ipairs(wildcards) do
                    if MojoSetup.wildcardmatch(ent.filename, v) then
                        should_install = true
                        break  -- no reason to keep iterating...
                    end
                end
            end

            if should_install then
                install_archive_entry(archive, ent, file, option)
                if single_match then
                    break   -- no sense in iterating further if we're done.
                end
            end
        end

        -- and check the next entry in the archive...
        ent = MojoSetup.archive.enumnext(archive)
    end
end


local function install_basepath(basepath, file, option, dataprefix)
    -- Obviously, we don't want to enumerate the entire physical filesystem,
    --  so we'll dig through each path element with MojoPlatform_exists()
    --  until we find one that doesn't, then we'll back up and try to open
    --  that as a directory, and then a file archive, and start drilling from
    --  there. Fun.

    local function create_basepath_archive(path)
        local archive = MojoSetup.archive.fromdir(path)
        if archive == nil then
            archive = MojoSetup.archive.fromfile(path)
            if archive == nil then
                MojoSetup.fatal(_("Couldn't open archive"))
            end
        end
        return archive
    end

    -- fast path: See if the whole path exists. This is probably the normal
    --  case, but it won't work for archives-in-archives.
    if MojoSetup.platform.exists(basepath) then
        local archive = create_basepath_archive(basepath)
        install_archive(archive, file, option, dataprefix)
        MojoSetup.archive.close(archive)
    else
        -- Check for archives-in-archives...
        local path = ""
        local paths = split_path(basepath)
        for i,v in ipairs(paths) do
            local knowngood = path
            path = path .. "/" .. v
            if not MojoSetup.platform.exists(path) then
                if knowngood == "" then
                    MojoSetup.fatal(_("Archive not found"))
                end
                local archive = create_basepath_archive(knowngood)
                local arclist = { archive }
                path = rebuild_path(paths, i)
                local arc = drill_for_archive(archive, path, arclist)
                install_archive(arc, file, option, dataprefix)
                close_archive_list(arclist)
                return  -- we're done here
            end
        end

        -- wait, the whole thing exists now? Did this just move in?
        install_basepath(basepath, file, option, dataprefix)  -- try again, I guess...
    end
end


local function set_destination(dest)
    -- Chop any '/' chars from the end of the string...
    dest = string.gsub(dest, "/+$", "")

    MojoSetup.loginfo("Install dest: '" .. dest .. "'")
    MojoSetup.destination = dest
    MojoSetup.metadatadir = MojoSetup.destination .. "/" .. MojoSetup.metadatadirname
    MojoSetup.controldir = MojoSetup.metadatadir  -- .. "/control"
    MojoSetup.manifestdir = MojoSetup.metadatadir .. "/manifest"
    MojoSetup.scratchdir = MojoSetup.metadatadir .. "/tmp"
    MojoSetup.rollbackdir = MojoSetup.scratchdir .. "/rollbacks"
    MojoSetup.downloaddir = MojoSetup.scratchdir .. "/downloads"
end


local function run_config_defined_hook(func, pkg)
    if func ~= nil then
        local errstr = func(pkg)
        if errstr == "" then
            MojoSetup.fatal(nil)
        elseif errstr ~= nil then
            MojoSetup.fatal(errstr)
        end
    end
end


-- The XML manifest is compatible with the loki_setup manifest schema, since
--  it has a reasonable set of data, and allows you to use loki_update or
--  loki_patch with a MojoSetup installation. Please note that we never ever
--  look at this data! You are responsible for updating the other files if
--  you think it'll be important. The Unix MojoSetup uninstaller uses the
--  lua manifest, for example (but loki_uninstall can use the xml one,
--  so if you want, you can just drop in MojoSetup to replace loki_setup and
--  use the Loki tools for everything else.
local function build_xml_manifest(package)
    local retval = {};

    local function addstr(str)
        retval[#retval+1] = str
    end

    addstr('<?xml version="1.0" encoding="UTF-8"?>\n')
    addstr('<product name="')
    addstr(package.id)
    addstr('" desc="')
    addstr(package.description)
    addstr('" xmlversion="1.6" root="')
    addstr(package.root)
    addstr('" ')
    if package.update_url ~= nil then
        addstr('update_url="')
        addstr(package.update_url)
        addstr('"')
    end
    addstr('>\n')
    addstr('\t<component name="Default" version="')
    addstr(package.version)
    addstr('" default="yes">\n')

    -- Need to group these by options.
    local grouped = {}
    for fname,entity in pairs(package.manifest) do
        local key = entity.key
        if grouped[key] == nil then
            grouped[key] = {}
        end
        entity.path = fname
        local list = grouped[key]
        list[#list+1] = entity
    end

    for desc,items in pairs(grouped) do
        addstr('\t\t<option name="')
        addstr(desc)
        addstr('">\n')
        for i,item in ipairs(items) do
            local type = item.type
            if type == "dir" then
                type = "directory"  -- loki_setup expects this string.
            end

            -- !!! FIXME: files from archives aren't filling item.mode in
            -- !!! FIXME:  because it figures out the perms from the archive's
            -- !!! FIXME:  C struct in native code. Need to get that into Lua.
            -- !!! FIXME: (and get the perms uint16/string conversion cleaned up)
            local mode = item.mode
            if mode == nil then
                mode = "0644"   -- !!! FIXME
            end

            local path = item.path
            item.path = nil  -- we added this when grouping. Remove it now.

            addstr('\t\t\t<')
            addstr(type)

            if type == "file" then
                if item.checksums ~= nil then
                    for k,v in pairs(item.checksums) do
                        addstr(' ')
                        addstr(k)
                        addstr('="')
                        addstr(v)
                        addstr('"')
                    end
                end
                addstr(' mode="')
                addstr(mode)
                addstr('"')
            elseif type == "directory" then
                addstr(' mode="')
                addstr(mode)
                addstr('"')
            elseif type == "symlink" then
                addstr(' dest="')
                addstr(item.linkdest)
                addstr('" mode="0777"')
            end

            addstr('>')
            addstr(path)
            addstr('</')
            addstr(type)
            addstr('>\n')
        end
        addstr('\t\t</option>\n');
    end

    addstr('\t</component>\n</product>\n\n')

    return retval
end


local function serialize(obj, prefix, postfix)
    local retval = {}
    local function addstr(str)
        retval[#retval+1] = str
    end

    if prefix ~= nil then
        addstr(prefix)
    end

    local function _serialize(obj, indent)
        local objtype = type(obj)
        if objtype == "nil" then
            addstr("nil")
        elseif (objtype == "number") or (objtype == "boolean") then
            addstr(tostring(obj))
        elseif objtype == "string" then
            addstr(string.format("%q", obj))
        elseif objtype == "function" then
            addstr("load(")
            addstr(string.format("%q", string.dump(obj)))
            addstr(")")
        elseif objtype == "table" then
            addstr("{\n")
            local tab = string.rep("\t", indent)
            for k,v in pairs(obj) do
                local key = k
                addstr(tab)
                if type(key) == "number" then
                    addstr('[')
                    addstr(key)
                    addstr(']')
                elseif not string.match(key, "^[_a-zA-Z][_a-zA-Z0-9]*$") then
                    addstr('[')
                    addstr(string.format("%q", key))
                    addstr(']')
                else
                    addstr(key)
                end
                addstr(" = ")
                _serialize(v, indent+1)
                addstr(",\n")
            end
            addstr(string.rep("\t", indent-1))
            addstr("}")
        else
            MojoSetup.logerror("unexpected object to serialize (" ..
                                 objtype .. "): '" .. tostring(obj) .. "'")
            MojoSetup.fatal(_("BUG: Unhandled data type"))
        end
    end

    _serialize(obj, 1)

    if postfix ~= nil then
        addstr(postfix)
    end

    return retval
end


local function build_lua_manifest(package)
    return serialize(package, 'MojoSetup.package = ', '\n\n')
end


local function build_txt_manifest(package)
    local flat = flatten_manifest(package.manifest)
    local retval = {}
    for i,v in pairs(flat) do
        retval[#retval+1] = v
        retval[#retval+1] = "\n"
    end
    return retval
end


local function install_control_app(desc, key)
    local dst, src

    -- We copy the installer binary itself, and any auxiliary files it needs,
    --  like this Lua script, to a metadata directory in the installation.
    -- Unfortunately, the binary might be a self-extracting installer that
    --  has gigabytes of now-unnecessary data appended to it, so we need to
    --  decide if that's the case and, if so, extract just the program itself
    --  from the start of the file.
    local maxbytes = -1  -- copy whole thing by default.
    local base = MojoSetup.archive.base

    dst = MojoSetup.controldir .. "/" .. MojoSetup.controlappname
    src = MojoSetup.info.binarypath
    if src == MojoSetup.info.basearchivepath then
        maxbytes = MojoSetup.archive.offsetofstart(base)
        if maxbytes <= 0 then
            MojoSetup.fatal(_("BUG: Unexpected value"))
        end
    end

    -- Always update the MojoSetup binary because the uninstall script may
    -- need features that only it provides (e.g. load(string, ...)).
    if permit_write(dst, MojoSetup.metadatadirname .. "/" .. MojoSetup.controlappname, "file", nil) then
        local perms = "0755"  -- !!! FIXME
        install_parent_dirs(dst, key)
        install_file_from_filesystem(dst, src, perms, desc, key, maxbytes)
    end

    -- Okay, now we need all the support files.
    if not MojoSetup.archive.enumerate(base) then
        MojoSetup.fatal(_("Couldn't enumerate archive"))
    end

    local needdirs = { "scripts", "guis", "meta" }

    local ent = MojoSetup.archive.enumnext(base)
    while ent ~= nil do
        -- Make sure this is in a directory we want to write out...
        local should_write = false

        if (ent.filename ~= nil) and (ent.filename ~= "") then
            for i,dir in ipairs(needdirs) do
                local clipdir = "^" .. dir .. "/"
                if string.find(ent.filename, clipdir) ~= nil then
                    should_write = true
                    break
                end
            end
        end

        if should_write then
            dst = MojoSetup.controldir .. "/" .. ent.filename
            if permit_write(dst, MojoSetup.metadatadirname .. "/" .. ent.filename, ent.type, nil) then
                install_archive_entity(dst, ent, base, desc, key, perms)
            end
        end

        -- and check the next entry in the archive...
        ent = MojoSetup.archive.enumnext(base)
    end

    -- okay, we're written out.
end

local function shquote_string(str)
    -- Quote the string so it can safely be passed to the shell
    str = string.gsub(str, "\\", "\\\\")
    str = string.gsub(str, "[$]", "\\$")
    str = string.gsub(str, "\"", "\\\"")
    str = string.gsub(str, "`", "\\`")
    return "\"" ..str.. "\""
end

local function install_unix_uninstaller(desc, key)
    -- Write a script out that calls the uninstaller.
    local fname = MojoSetup.destination .. "/" ..
                  "uninstall-" .. MojoSetup.install.id .. ".sh"

    -- Man, I hate escaping shell strings...
    local bin = "\"`dirname \\\"$0\\\"`\"/" ..
                shquote_string(MojoSetup.metadatadirname .. "/" .. MojoSetup.controlappname)

    local script =
        "#!/bin/sh\n" ..
        "exec " .. bin .. " uninstall " .. shquote_string(MojoSetup.install.id) .. " \"$@\"\n"

    install_parent_dirs(fname, key)
    install_file_from_string(fname, script, "0755", desc, key)
end


local function install_product_keys(productkeys)
    for desc,prodkey in pairs(productkeys) do
        local dest = MojoSetup.destination .. "/" .. prodkey.destination
        local productkey = prodkey.productkey
        local component = prodkey.component
        -- !!! FIXME: Windows registry support.
        -- !!! FIXME: file permissions for product keys?
        install_parent_dirs(dest, component)
        install_file_from_string(dest, productkey, "0644", desc, component)
    end
end


local function install_manifests(desc, key)
    -- We write out a Lua script as a data definition language, a
    --  loki_setup-compatible XML manifest, and a straight text file of
    --  all the filenames. Take your pick.

    local perms = "0644"  -- !!! FIXME
    local basefname = MojoSetup.manifestdir .. "/" .. MojoSetup.install.id
    local lua_fname = basefname .. ".lua"
    local xml_fname = basefname .. ".xml"
    local txt_fname = basefname .. ".txt"

    -- We have to cheat and just plug these into the manifest directly, since
    --  they won't show up until after we write them out, otherwise.
    -- Obviously, we don't have checksums for them, either, as we haven't
    --  assembled the data yet!

    manifest_add(MojoSetup.manifest, lua_fname, key, "file", perms, nil, nil)
    manifest_add(MojoSetup.manifest, xml_fname, key, "file", perms, nil, nil)
    manifest_add(MojoSetup.manifest, txt_fname, key, "file", perms, nil, nil)

    -- build the "package" table that we serialize, etc.
    local package =
    {
        id = MojoSetup.install.id,
        vendor = MojoSetup.install.vendor,
        description = MojoSetup.install.description,
        root = MojoSetup.destination,
        update_url = MojoSetup.install.updateurl,
        version = MojoSetup.install.version,
        manifest = MojoSetup.manifest,
        splash = MojoSetup.install.splash,
        splashpos = MojoSetup.install.splashpos,
        desktopmenuitems = MojoSetup.install.desktopmenuitems,
        preuninstall = MojoSetup.install.preuninstall,
        postuninstall = MojoSetup.install.postuninstall
    }

    -- now build these things...
    install_parent_dirs(lua_fname, key)
    install_file_from_stringtable(lua_fname, build_lua_manifest(package), perms, desc, nil)
    install_file_from_stringtable(xml_fname, build_xml_manifest(package), perms, desc, nil)
    install_file_from_stringtable(txt_fname, build_txt_manifest(package), perms, desc, nil)
end


local function freedesktop_menuitem_filename(pkg, idx)  -- only for Unix.
    local vendor = string.gsub(pkg.vendor, "%.", "_")
    local fname = vendor .. "-" .. pkg.id .. "_" .. idx .. ".desktop"
    return MojoSetup.metadatadir .. "/" .. fname
end


local function uninstall_desktop_menu_items(pkg)
    -- !!! FIXME: GUI progress?
    if pkg.desktopmenuitems ~= nil then
        for i,v in ipairs(pkg.desktopmenuitems) do
            if MojoSetup.info.platform == "windows" then
                MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
            elseif MojoSetup.info.platform == "macosx" then
                MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
            elseif MojoSetup.info.platform == "beos" then
                MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
            else  -- freedesktop, we hope.
                local fname = freedesktop_menuitem_filename(pkg, i)
                if not MojoSetup.platform.uninstalldesktopmenuitem(fname) then
                    MojoSetup.fatal(_("Failed to uninstall desktop menu item"))
                end
            end
        end
    end
end


local function install_freedesktop_menuitem(pkg, idx, item)  -- only for Unix.
    local icon
    local dest = MojoSetup.destination
    if item.builtin_icon then
        icon = item.icon
    else
        icon = dest .. "/" .. item.icon
    end

    local cmdline = MojoSetup.format(item.commandline, dest)

    -- Try to escape some characters...
    cmdline = '"' .. string.gsub(string.gsub(cmdline, "\"","\\\""), "%%", "%%%%") .. '"'

    local t = { "[Desktop Entry]\n" }
    local function addpair(key, val)
        t[#t+1] = key
        t[#t+1] = '='
        t[#t+1] = val
        t[#t+1] = '\n'
    end

    addpair("Encoding", "UTF-8")
    addpair("Value", "1.0")
    addpair("Type", "Application")
    addpair("Name", item.name)
    addpair("GenericName", item.genericname)
    addpair("Comment", item.tooltip)
    addpair("Icon", icon)
    addpair("Exec", cmdline)
    addpair("Categories", flatten_list(item.category))

    if item.mimetype ~= nil then
        addpair("MimeType", flatten_list(item.mimetype))
    end

    if item.workingdir ~= nil then
        addpair("Path", MojoSetup.format(item.workingdir, dest))
    end

    t[#t+1] = '\n'

    local fname = freedesktop_menuitem_filename(pkg, idx)
    local perms = "0644"  -- !!! FIXME
    local key = MojoSetup.metadatakey
    local desc = MojoSetup.metadatadesc

    --MojoSetup.logdebug("Install FreeDesktop file")
    --MojoSetup.logdebug(fname)
    --MojoSetup.logdebug(str)
    install_parent_dirs(fname, key)
    install_file_from_stringtable(fname, t, perms, desc, key)
    if not MojoSetup.platform.installdesktopmenuitem(fname) then
        MojoSetup.fatal(_("Failed to install desktop menu item"))
    end
end


local function install_desktop_menu_items(pkg)
    -- !!! FIXME: GUI progress?
    if pkg.desktopmenuitems ~= nil then
        for i,item in ipairs(pkg.desktopmenuitems) do
            if not item.disabled then
                if MojoSetup.info.platform == "windows" then
                    MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
                elseif MojoSetup.info.platform == "macosx" then
                    MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
                elseif MojoSetup.info.platform == "beos" then
                    MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
                else  -- freedesktop, we hope.
                    install_freedesktop_menuitem(pkg, i, item)
                end
            end
        end
    end
end


local function get_productkey(thisstage, maxstage, desc, fmt, verify, dest, manifestkey)
    local key = nil
    local userkey = nil
    local retval = nil

    -- Retrieve the previous entry, in case we're stepping back over a stage.
    --  This lets the user edit it or just move forward without typing the
    --  whole thing again.
    if MojoSetup.productkeys[desc] ~= nil then
        userkey = MojoSetup.productkeys[desc].user_productkey
    end

    while key == nil do
        retval, userkey = MojoSetup.gui.productkey(desc, fmt, userkey, thisstage, maxstage)
        if retval ~= 1 then
            return retval  -- user hit back or cancel.
        end

        key = userkey
        if verify ~= nil then
            local ok, newkey = verify(userkey)
            if not ok then
                MojoSetup.msgbox(
                    _("Invalid product key"),
                    _("That key appears to be invalid. Please try again."))
                key = nil
            elseif newkey ~= nil then
                key = newkey
            end
        end
    end

    for desckey,prodkey in pairs(MojoSetup.productkeys) do
        if (prodkey.destination == dest) and (desckey ~= desc) then
            MojoSetup.logwarning("More than one product key with same destination!")
            break
        end
    end

    MojoSetup.productkeys[desc] = {
        destination = dest,
        productkey = key,
        user_productkey = userkey,
        component = manifestkey
    }

    return 1
end


local function start_gui(desc, splashfname, splashpos)
    if splashfname ~= nil then
        splashfname = 'meta/' .. splashfname
    end

    if not MojoSetup.gui.start(desc, splashfname, splashpos) then
        MojoSetup.fatal(_("GUI failed to start"))
    end

    MojoSetup.gui_started = true
end


local function stop_gui()
    MojoSetup.gui.stop()
    MojoSetup.gui_started = nil
end


local function load_oldpackage()
    local filename = MojoSetup.metadatadir .. "/manifest/" .. MojoSetup.install.id .. ".lua"
    local fh, err = io.open(filename, "r")
    if not fh then
        MojoSetup.logerror("could not open '" .. filename .. "' for reading: " .. err)
        return {}
    end
    local data = fh:read("*all")
    fh:close()

    -- This execution environment does not provide any function for the
    -- loaded code to call. So define a stub load() function, and also a
    -- loadstring() one for pre-Lua 5.2 manifest files.
    local env = {MojoSetup = {}}
    local lua_func, err = load("function load() end\nfunction loadstring() end\n" .. data, nil, "t", env)
    if not lua_func then
        MojoSetup.logerror("could not parse '" .. filename .. "': " .. err)
        return {}
    end

    local ran, err = pcall(lua_func)
    if not ran then
        MojoSetup.logerror("could not run '" .. filename .. "' " .. err)
        return {}
    end
    return env.MojoSetup.package
end


local function do_install(install)
    MojoSetup.forceoverwrite = nil
    MojoSetup.written = 0
    MojoSetup.totalwrite = 0
    MojoSetup.downloaded = 0
    MojoSetup.totaldownload = 0
    MojoSetup.install = install
    MojoSetup.installed_menu_items = false

    -- !!! FIXME: need a cmdline to automate cdkey entry?
    local skipeulas = MojoSetup.cmdline("i-agree-to-all-licenses")
    local skipreadmes = MojoSetup.cmdline("noreadme")
    local skipoptions = MojoSetup.cmdline("nooptions")
    local skipprompt = MojoSetup.cmdline("noprompt")

    -- !!! FIXME: try to sanity check everything we can here
    -- !!! FIXME:  (unsupported URLs, bogus media IDs, etc.)
    -- !!! FIXME:  I would like everything possible to fail here instead of
    -- !!! FIXME:  when a user happens to pick an option no one tested...

    if (install.options == nil) and (install.optiongroups == nil) then
        MojoSetup.fatal(_("BUG: no options"))
    end

    -- The uninstaller support needs a manifest to know what to do. Force it on.
    if (install.support_uninstall) and (not install.write_manifest) then
        MojoSetup.fatal(_("BUG: support_uninstall requires write_manifest"))
    end

    -- Desktop icons should probably require uninstall so we don't clutter
    --  the system with no option for reversal later.
    -- !!! FIXME: will miss menu items that are Setup.Option children...
    if (install.desktopmenuitems ~= nil) and (not install.support_uninstall) then
        MojoSetup.fatal(_("BUG: Setup.DesktopMenuItem requires support_uninstall"))
    end

    -- Manifest support requires the Lua parser.
    if (install.write_manifest) and (not MojoSetup.info.luaparser) then
        MojoSetup.fatal(_("BUG: write_manifest requires Lua parser support"))
    end

    -- This is to save us the trouble of a loop every time we have to
    --  find media by id...
    MojoSetup.media = {}
    if install.medias ~= nil then
        for k,v in pairs(install.medias) do
            if MojoSetup.media[v.id] ~= nil then
                MojoSetup.fatal(_("BUG: duplicate media id"))
            end
            MojoSetup.media[v.id] = v
        end
    end

    -- Build a bunch of functions into a linear array...this lets us move
    --  back and forth between stages of the install with customized functions
    --  for each bit that have their own unique params embedded as upvalues.
    -- So if there are three EULAs to accept, we'll call three variations of
    --  the EULA function with three different tables that appear as local
    --  variables, and the driver that calls this function will be able to
    --  skip back and forth based on user input. This is a cool Lua thing.
    local stages = {}

    -- First stage: Make sure installer can run. Always fails or steps forward.
    -- !!! FIXME: you can step back onto this...need a way to run some stages
    -- !!! FIXME:  only once...
    if install.precheck ~= nil then
        stages[#stages+1] = function ()
            run_config_defined_hook(install.precheck, install)
            return 1
        end
    end

    -- Next stage: accept all global EULAs. Rejection of any EULA is considered
    --  fatal. These are global EULAs for the install, per-option EULAs come
    --  later.
    if (install.eulas ~= nil) and (not skipeulas) then
        for k,eula in pairs(install.eulas) do
            local desc = eula.description
            local fname = install.dataprefix .. eula.source
            local accept_needed = not eula.accept_not_needed

            -- (desc) and (fname) become upvalues in this function.
            stages[#stages+1] = function (thisstage, maxstage)
                local retval = MojoSetup.gui.readme(desc,fname,thisstage,maxstage)
                if retval == 1 then
                    if accept_needed then
                        if not MojoSetup.promptyn(desc, _("Accept this license?"), false) then
                            MojoSetup.fatal(_("You must accept the license before you may install"))
                        end
                    end
                end
                return retval
            end
        end
    end

    -- Next stage: enter all global products keys. These are global keys
    --  for the install, per-option keys come later.
    MojoSetup.productkeys = {}
    if install.productkeys ~= nil then
        for k,prodkey in pairs(install.productkeys) do
            -- (prodkey) becomes an upvalue in this function.
            stages[#stages+1] = function(thisstage, maxstage)
                return get_productkey(thisstage, maxstage, prodkey.description,
                                      prodkey.format, prodkey.verify,
                                      prodkey.destination,
                                      MojoSetup.metadatakey)
            end
        end
    end

    -- Next stage: show any READMEs.
    if (install.readmes ~= nil) and (not skipreadmes) then
        for k,readme in pairs(install.readmes) do
            local desc = readme.description
            -- !!! FIXME: pull from archive?
            local fname = install.dataprefix .. readme.source
            -- (desc) and (fname) become upvalues in this function.
            stages[#stages+1] = function(thisstage, maxstage)
                return MojoSetup.gui.readme(desc, fname, thisstage, maxstage)
            end
        end
    end

    -- Next stage: let user choose install destination.
    --  The config file can force a destination if it has a really good reason
    --  (system drivers that have to go in a specific place, for example),
    --  but really really shouldn't in 99% of the cases.
    local recommend = nil
    local destcmdline = MojoSetup.cmdlinestr("destination")
    if install.destination ~= nil then
        set_destination(install.destination)
    elseif destcmdline ~= nil then
        set_destination(destcmdline)
    elseif install.recommended_full_destinations ~= nil then
        if type(install.recommended_full_destinations) == "string" then
            recommend = { install.recommended_full_destinations }
        else
            recommend = install.recommended_full_destinations
        end
    else
        local recommended_cfg = install.recommended_destinations
        if recommended_cfg ~= nil then
            if type(recommended_cfg) == "string" then
                recommended_cfg = { recommended_cfg }
            end

            recommend = {}
            for i,v in ipairs(recommended_cfg) do
                local dir = v
                while true do
                    if MojoSetup.platform.isdir(dir) then
                        if MojoSetup.platform.writable(dir) then
                            recommend[#recommend+1] = v .. "/" .. install.id
                        end
                        break
                    end
                    dir = string.gsub(dir, "/[^/]*$", "", 1)
                    if dir == "" then
                        if MojoSetup.platform.writable("/") then
                            recommend[#recommend+1] = v .. "/" .. install.id
                        end
                        break
                    end
                end
            end
        end
    end
    if recommend ~= nil then
        -- (recommend) becomes an upvalue in this function.
        stages[#stages+1] = function(thisstage, maxstage)
            while true do
                local rc, dst
                rc, dst = MojoSetup.gui.destination(recommend, thisstage, maxstage)
                if rc ~= 1 then
                    return rc
                end
                if install.checkdst == nil then
                    set_destination(dst)
                    return 1
                end
                local errstr = install.checkdst(install, dst)
                if errstr == nil then
                    set_destination(dst)
                    return 1
                elseif errstr ~= "" then
                    MojoSetup.msgbox(_("Invalid destination directory"), errstr)
                end
                if recommend[1] ~= dst then
                    -- Make sure the last directory entered by the user is the
                    -- first in the list
                    for i, dir in ipairs(recommend) do
                        if dir == dst then
                            table.remove(recommend, i)
                            break
                        end
                    end
                    table.insert(recommend, 1, dst)
                end
            end
            return 0
        end
    end

    -- Next stage: let user choose install options.
    --  This may not produce a GUI stage if it decides that all options
    --  are either required or disabled.
    if not skipoptions then  -- (just take the defaults...)
        -- (install) becomes an upvalue in this function.
        stages[#stages+1] = function(thisstage, maxstage)
            -- This does some complex stuff with a hierarchy of tables in C.
            return MojoSetup.gui.options(install, thisstage, maxstage)
        end
    end


    -- Next stage: accept all option-specific EULAs.
    --  Rejection of any EULA will put you back one stage (usually to the
    --  install options), but if there is no previous stage, this becomes
    --  fatal.
    -- This may not produce a GUI stage if there are no selected options with
    --  EULAs to accept.
    if not skipeulas then
        stages[#stages+1] = function(thisstage, maxstage)
            local option_eulas = {}

            local function find_option_eulas(opts)
                local options = opts['options']
                if options ~= nil then
                    for k,v in pairs(options) do
                        if v.value then
                            if v.eulas ~= nil then
                                for ek,ev in pairs(v.eulas) do
                                    option_eulas[#option_eulas+1] = ev
                                end
                            end
                            find_option_eulas(v)
                        end
                    end
                end

                local optiongroups = opts['optiongroups']
                if optiongroups ~= nil then
                    for k,v in pairs(optiongroups) do
                        if not v.disabled then
                            find_option_eulas(v)
                        end
                    end
                end
            end

            find_option_eulas(install)

            for k,eula in pairs(option_eulas) do
                local desc = eula.description
                local fname = install.dataprefix .. eula.source
                local accept_needed = not eula.accept_not_needed
                local retval = MojoSetup.gui.readme(desc,fname,thisstage,maxstage)
                if retval == 1 then
                    if accept_needed then
                        if not MojoSetup.promptyn(desc, _("Accept this license?"), false) then
                            -- can't go back? We have to die here instead.
                            if thisstage == 1 then
                                MojoSetup.fatal(_("You must accept the license before you may install"))
                            else
                                retval = -1  -- just step back a stage.
                            end
                        end
                    end
                end

                if retval ~= 1 then
                    return retval
                end
            end

            return 1   -- all licenses were agreed to. Go to the next stage.
        end
    end

    -- Next stage: enter all option-specific product keys.
    -- This may not produce a GUI stage if there are no selected options with
    --  product keys. Many installers will use a single global key instead.
    stages[#stages+1] = function(thisstage, maxstage)
        local options_with_keys = {}

        local function find_options_with_keys(opts)
            local options = opts['options']
            if options ~= nil then
                for k,v in pairs(options) do
                    if v.value then
                        if v.productkeys ~= nil then
                            options_with_keys[#options_with_keys+1] = v;
                        end
                        find_options_with_keys(v)
                    end
                end
            end

            local optiongroups = opts['optiongroups']
            if optiongroups ~= nil then
                for k,v in pairs(optiongroups) do
                    if not v.disabled then
                        find_options_with_keys(v)
                    end
                end
            end
        end

        find_options_with_keys(install)

        for optk,option in ipairs(options_with_keys) do
            for k,prodkey in ipairs(option.productkeys) do
                local retval = get_productkey(thisstage, maxstage,
                                              prodkey.description,
                                              prodkey.format, prodkey.verify,
                                              prodkey.destination,
                                              option.description)
                if retval ~= 1 then
                    return retval
                end
            end
        end

        return 1   -- all product keys are entered. Go to the next stage.
    end

    -- Next stage: Make sure source list is sane.
    --  This is not a GUI stage, it just needs to run between them.
    --  This gets a little hairy.
    stages[#stages+1] = function(thisstage, maxstage)
        -- Load the previous installation's list of files
        --  (empty if this is a first time install)
        MojoSetup.oldpackage = load_oldpackage()
        MojoSetup.oldfiles = {}
        if MojoSetup.oldpackage ~= nil and MojoSetup.oldpackage.manifest ~= nil then
            for path,file in pairs(MojoSetup.oldpackage.manifest) do
                MojoSetup.oldfiles[path] = file
            end
        end

        -- Make sure we install the destination dir, so it's in the manifest
        -- and thus gets uninstalled.
        install_parent_dirs(MojoSetup.destination, MojoSetup.metadatakey)
        install_directory(MojoSetup.destination, nil, nil, MojoSetup.metadatakey)

        local function process_file(option, file)
            -- !!! FIXME: what happens if a file shows up in multiple options?
            local src = file.source
            local prot,host,path
            if src ~= nil then  -- no source, it's in GBaseArchive
                prot,host,path = MojoSetup.spliturl(src)
            end
            if (src == nil) or (prot == "base://") then  -- included content?
                if MojoSetup.files.included == nil then
                    MojoSetup.files.included = {}
                end
                MojoSetup.files.included[file] = option
            elseif prot == "media://" then
                -- !!! FIXME: make sure media id is valid.
                if MojoSetup.files.media == nil then
                    MojoSetup.files.media = {}
                end
                if MojoSetup.files.media[host] == nil then
                    MojoSetup.files.media[host] = {}
                end
                MojoSetup.files.media[host][file] = option
            else
                -- !!! FIXME: make sure we can handle this URL...
                if MojoSetup.files.downloads == nil then
                    MojoSetup.files.downloads = {}
                end

                if option.bytes > 0 then
                    MojoSetup.totaldownload = MojoSetup.totaldownload + option.bytes
                end
                MojoSetup.files.downloads[file] = option
            end
        end

        -- Sort an option into tables that say what sort of install it is.
        --  This lets us batch all the things from one CD together,
        --  do all the downloads first, etc.
        local function process_option(option)
            if option.bytes > 0 then
                MojoSetup.totalwrite = MojoSetup.totalwrite + option.bytes
            end
            if option.files ~= nil then
                for k,v in pairs(option.files) do
                    process_file(option, v)
                end
            end

            if option.desktopmenuitems ~= nil then
                for i,item in ipairs(option.desktopmenuitems) do
                    if install.desktopmenuitems == nil then
                        install.desktopmenuitems = {}
                    end
                    install.desktopmenuitems[#install.desktopmenuitems+1] = item
                end
            end
        end

        local function build_source_tables(opts)
            local options = opts['options']
            if options ~= nil then
                for k,v in pairs(options) do
                    if v.value then
                        process_option(v)
                        build_source_tables(v)
                    end
                end
            end

            local optiongroups = opts['optiongroups']
            if optiongroups ~= nil then
                for k,v in pairs(optiongroups) do
                    if not v.disabled then
                        build_source_tables(v)
                    end
                end
            end
        end

        run_config_defined_hook(install.preflight, install)

        MojoSetup.files = {}
        build_source_tables(install)

        -- This dumps the files tables using MojoSetup.logdebug,
        --  so it only spits out crap if debug-level logging is enabled.
        MojoSetup.dumptable("MojoSetup.files", MojoSetup.files)

        return 1   -- always go forward from non-GUI stage.
    end

    -- Next stage: Download external packages.
    stages[#stages+1] = function(thisstage, maxstage)
        if MojoSetup.files.downloads ~= nil then
            -- !!! FIXME: id will cause problems for download resume
            -- !!! FIXME: id will chop filename extension
            local id = 0
            for file,option in pairs(MojoSetup.files.downloads) do
                local f = MojoSetup.downloaddir .. "/" .. id
                install_parent_dirs(f, MojoSetup.metadatakey)
                id = id + 1

                -- Upvalued so we don't look these up each time...
                local url = file.source
                local fname = string.gsub(url, "^.*/", "", 1)  -- chop the dirs off...
                local ptype = _("Downloading")
                local component = option.description
                local bps = 0
                local bpsticks = 0
                local ratestr = ''
                local item = fname
                local percent = -1
                local callback = function(ticks, justwrote, bw, total)
                    if total < 0 then
                        -- adjust start point for d/l rate calculation...
                        bpsticks = ticks + 1000
                    else
                        if ticks >= bpsticks then
                            ratestr = make_rate_string(bps, bw, total)
                            bpsticks = ticks + 1000
                            bps = 0
                        end
                        bps = bps + justwrote
                        MojoSetup.downloaded = MojoSetup.downloaded + justwrote
                        percent = calc_percent(MojoSetup.downloaded,
                                               MojoSetup.totaldownload)

                        item = MojoSetup.format(_("%0: %1%% (%2)"),
                                                fname,
                                                calc_percent(bw, total),
                                                ratestr)
                    end
                    return MojoSetup.gui.progress(ptype, component, percent, item, true)
                end

                MojoSetup.loginfo("Download '" .. url .. "' to '" .. f .. "'")
                MojoSetup.gui.progressitem()
                local downloaded, sums = MojoSetup.download(url, f, nil, nil, callback)
                if not downloaded then
                    MojoSetup.fatal(_("File download failed!"))
                end
                MojoSetup.downloads[#MojoSetup.downloads+1] = f
            end
        end
        return 1
    end

    -- Next stage: actual installation.
    stages[#stages+1] = function(thisstage, maxstage)
        run_config_defined_hook(install.preinstall)

        -- Do stuff on media first, so the user finds out he's missing
        --  disc 3 of 57 as soon as possible...

        -- Since we sort all things to install by the media that contains
        --  the source data, you should only have to insert each disc
        --  once, no matter how they landed in the config file.

        if MojoSetup.files.media ~= nil then
            -- Build an array of media ids so we can sort them into a
            --  reasonable order...disc 1 before disc 2, etc.
            local medialist = {}
            for mediaid,mediavalue in pairs(MojoSetup.files.media) do
                medialist[#medialist+1] = mediaid
            end
            table.sort(medialist)

            for mediaidx,mediaid in ipairs(medialist) do
                local media = MojoSetup.media[mediaid]
                local basepath = MojoSetup.findmedia(media.uniquefile)
                while basepath == nil do
                    if not MojoSetup.gui.insertmedia(media.description) then
                        return 0   -- user cancelled.
                    end
                    basepath = MojoSetup.findmedia(media.uniquefile)
                end

                -- Media is ready, install everything from this one...
                MojoSetup.loginfo("Found correct media at '" .. basepath .. "'")
                local files = MojoSetup.files.media[mediaid]
                for file,option in pairs(files) do
                    local prot,host,path = MojoSetup.spliturl(file.source)
                    install_basepath(basepath .. "/" .. path, file, option, install.dataprefix)
                end
            end
            medialist = nil   -- done with this.
        end

        if MojoSetup.files.downloads ~= nil then
            local id = 0
            for file,option in pairs(MojoSetup.files.downloads) do
                local f = MojoSetup.downloaddir .. "/" .. id
                id = id + 1
                install_basepath(f, file, option, install.dataprefix)
            end
        end

        if MojoSetup.files.included ~= nil then
            for file,option in pairs(MojoSetup.files.included) do
                local arc = MojoSetup.archive.base
                if file.source == nil then
                    install_archive(arc, file, option, install.dataprefix)
                else
                    local prot,host,path = MojoSetup.spliturl(file.source)
                    local arclist = {}
                    arc = drill_for_archive(arc, install.dataprefix .. path, arclist)
                    install_archive(arc, file, option, install.dataprefix)
                    close_archive_list(arclist)
                end
            end
        end

        if install.desktopmenuitems ~= nil then
            install_desktop_menu_items(install)
            MojoSetup.installed_menu_items = true
        end

        if install.support_uninstall then
            if MojoSetup.info.platform == "windows" then
                MojoSetup.fatal(_("Unimplemented"))  -- !!! FIXME: write me.
            else  -- Unix, Mac OS X, BeOS...
                install_unix_uninstaller(MojoSetup.metadatadesc, MojoSetup.metadatakey)
            end
        end

        install_product_keys(MojoSetup.productkeys)

        -- Move away obsolete files so they get deleted
        --  Note that this will obsolete the control app and the manifests but
        --  that's ok because we will recreate them if the installation
        --  succeeds, and restore them if it fails.
        for path,file in pairs(MojoSetup.oldfiles) do
            if file.seen == nil and (file.type == 'file' or file.type == 'symlink') then
                local fullpath = MojoSetup.destination .. "/" .. path
                if MojoSetup.platform.exists(fullpath) or MojoSetup.platform.issymlink(fullpath) then
                    MojoSetup.loginfo("Obsoleting " .. file.type .. " '" .. path .. "'")
                    backup_file(fullpath)
                end
            end
        end

        run_config_defined_hook(install.postinstall, install)

        if install.write_manifest then
            install_control_app(MojoSetup.metadatadesc, MojoSetup.metadatakey)
            install_manifests(MojoSetup.metadatadesc, MojoSetup.metadatakey)
        end

        return 1   -- go to next stage.
    end

    -- Next stage: show results gui
    if not skipprompt then
        stages[#stages+1] = function(thisstage, maxstage)
            local ptype = _("Installing")
            local component = _("Cleaning up")
            local callback = function(fname, _current, _total)
                MojoSetup.gui.progressitem()
                MojoSetup.gui.progress(ptype, component, 100, fname, false)
            end
            -- Successful install, so delete conflicts we no longer need to
            -- rollback.
            delete_rollbacks(callback)
            delete_files(nil, MojoSetup.downloads, callback)
            delete_scratchdirs()

            -- Delete obsolete directories
            table.sort(MojoSetup.oldfiles, function(a,b) return MojoSetup.strcmp(a,b) < 0 end)
            for path,file in pairs(MojoSetup.oldfiles) do
                if file.seen == nil and file.type == 'directory' then
                    MojoSetup.loginfo("Obsoleting directory '" .. path .. "'")
                    callback(path, nil, nil)
                    -- It's ok if this fails. Most likely it just means the
                    -- directory is not empty.
                    MojoSetup.platform.unlink(MojoSetup.destination .. "/" .. path)
                end
            end

            -- Don't let future errors delete files from successful installs...
            MojoSetup.downloads = nil
            MojoSetup.rollbacks = nil

            MojoSetup.gui.final(_("Installation was successful."))
            return 1  -- go forward.
        end
    end

    -- Now make all this happen.
    start_gui(install.description, install.splash, install.splashpos)

    -- Make the stages available elsewhere.
    MojoSetup.stages = stages

    MojoSetup.manifest = {}
    MojoSetup.rollbacks = {}
    MojoSetup.downloads = {}

    local i = 1
    while MojoSetup.stages[i] ~= nil do
        local stage = MojoSetup.stages[i]
        local rc = stage(i, #MojoSetup.stages)

        -- Too many times I forgot to return something.   :)
        if type(rc) ~= "number" then
            MojoSetup.fatal(_("BUG: stage returned wrong type"))
        end

        if rc == 1 then
            i = i + 1   -- next stage.
        elseif rc == -1 then
            if i == 1 then
                MojoSetup.fatal(_("BUG: stepped back over start of stages"))
            else
                i = i - 1
            end
        elseif rc == 0 then
            MojoSetup.fatal()  -- user cancelled
        else
            MojoSetup.fatal(_("BUG: stage returned wrong value"))
        end
    end

    stop_gui()

    -- Done with these things. Make them eligible for garbage collection.
    stages = nil
    MojoSetup.manifest = nil
    MojoSetup.oldpackage = nil
    MojoSetup.oldfiles = nil
    MojoSetup.manifestdir = nil
    MojoSetup.metadatadir = nil
    MojoSetup.controldir = nil
    MojoSetup.scratchdir = nil
    MojoSetup.rollbackdir = nil
    MojoSetup.downloaddir = nil
    MojoSetup.install = nil
    MojoSetup.forceoverwrite = nil
    MojoSetup.installed_menu_items = nil
    MojoSetup.stages = nil
    MojoSetup.files = nil
    MojoSetup.productkeys = nil
    MojoSetup.media = nil
    MojoSetup.written = 0
    MojoSetup.totalwrite = 0
    MojoSetup.downloaded = 0
    MojoSetup.totaldownload = 0
end


local function real_revertinstall()
    if MojoSetup.gui_started then
        MojoSetup.gui.final(_("Incomplete installation. We will revert any changes we made."))
    end

    MojoSetup.loginfo("Cleaning up half-finished installation...")

    -- !!! FIXME: callbacks here.
    if MojoSetup.installed_menu_items then
        uninstall_desktop_menu_items(MojoSetup.install)
    end

    delete_files(nil, MojoSetup.downloads)

    local function filter_manifest(fname)
        if MojoSetup.manifest[fname].type == 'directory' and MojoSetup.oldfiles[fname] ~= nil then
            -- Don't delete directories that were created by the version we
            --  were upgrading.
            return nil
        end
        return fname
    end
    delete_files(MojoSetup.destination, flatten_manifest(MojoSetup.manifest, filter_manifest))

    do_rollbacks()
    delete_scratchdirs()
end


local function installer()
    local postexec = nil
    MojoSetup.loginfo("Installer starting")

    MojoSetup.revertinstall = real_revertinstall   -- replace the stub.

    -- This dumps the table built from the user's config script using logdebug,
    --  so it only spits out crap if debug-level logging is enabled.
    MojoSetup.dumptable("MojoSetup.installs", MojoSetup.installs)

    local saw_an_installer = false
    for installkey,install in pairs(MojoSetup.installs) do
        if not install.disabled then
            saw_an_installer = true
            do_install(install)

            if (install.postexec ~= nil) then
                postexec = string.gsub(install.postexec, "$DESTINATION", MojoSetup.destination)
            end

            MojoSetup.collectgarbage()  -- nuke all the tables we threw around...
        end
    end

    if not saw_an_installer then
        MojoSetup.fatal(_("Nothing to do!"))
    end

    if postexec ~= nil then
        MojoSetup.platform.exec(postexec)
    end

    MojoSetup.destination = nil
end


local function load_manifest(pkg)
    local package = nil
    if pkg == nil then
        badcmdline()
    end

    MojoSetup.package = nil   -- just in case.

    -- We need to be in the metadata directory of an install or this fails.
    local base = string.gsub(MojoSetup.info.basearchivepath, "/[^/]*$", "", 1)
    set_destination(base)  -- set up directories
    if MojoSetup.metadatadir == MojoSetup.info.basearchivepath then
        -- load the existing manifest first...
        local ran = MojoSetup.runfilefromdir("manifest", pkg)
        if (not ran) or (MojoSetup.package == nil) then
            MojoSetup.fatal(MojoSetup.format(
                               _("Couldn't load manifest file for '%0'"), pkg))
        end

        -- Move this out of the global...
        package = MojoSetup.package
        MojoSetup.package = nil
    end

    -- note that we discard the base directory specified in the manifest, as
    --  someone could have moved the installation's folder elsewhere. We'll
    --  keep it up to date for loki_update or whatever to use, though.
    package.destination = MojoSetup.destination

    return package
end


local function manifest_management()
    MojoSetup.loginfo("Manifest management starting")
    local package = load_manifest(MojoSetup.info.argv[3])

    local i = 4
    while MojoSetup.info.argv[i] ~= nil do
        local cmd = MojoSetup.info.argv[i]
        i = i + 1

        if cmd == "add" then
            local key = MojoSetup.info.argv[i]
            i = i + 1
            local fname = MojoSetup.info.argv[i]
            i = i + 1
            if (key == nil) or (fname == nil) then
                badcmdline()
            end

            MojoSetup.loginfo("Add '" ..fname.. "', '" ..key.. "' to manifest")
            fname = MojoSetup.destination .. "/" .. fname
            if not MojoSetup.platform.exists(fname) then
                MojoSetup.fatal(MojoSetup.format(_("File %0 not found"), fname))
            end

            manifest_add(package.manifest, fname, key, nil, nil, nil, nil)
            manifest_resync(package.manifest, fname, key)

        elseif cmd == "delete" then
            local fname = MojoSetup.info.argv[i]
            i = i + 1
            if fname == nil then
                badcmdline()
            end
            MojoSetup.loginfo("Delete '" .. fname .. "' from manifest")
            fname = MojoSetup.destination .. "/" .. fname
            manifest_delete(package.manifest, fname)

        elseif cmd == "resync" then
            local fname = MojoSetup.info.argv[i]
            i = i + 1
            if fname == nil then
                badcmdline()
            end
            MojoSetup.loginfo("Resync '" .. fname .. "' in manifest")
            fname = MojoSetup.destination .. "/" .. fname
            if not MojoSetup.platform.exists(fname) then
                MojoSetup.fatal(MojoSetup.format(_("File %0 not found"), fname))
            end
            manifest_resync(package.manifest, fname)

        elseif string.match(cmd, "^-") == nil then   -- skip "-option" strings
            MojoSetup.logerror("Unknown command '" .. cmd .. "'")
            badcmdline()
        end
    end

    -- !!! FIXME: duplication with install_manifests()
    local perms = "0644"  -- !!! FIXME
    local basefname = MojoSetup.manifestdir .. "/" .. package.id
    local lua_fname = basefname .. ".lua"
    local xml_fname = basefname .. ".xml"
    local txt_fname = basefname .. ".txt"

    MojoSetup.loginfo("rebuilding manifests...")

    -- !!! FIXME: rollback!
    delete_files(nil, {lua_fname, xml_fname, txt_fname}, nil, false)
    MojoSetup.stringtabletofile(build_lua_manifest(package), lua_fname, perms, nil, nil)
    MojoSetup.stringtabletofile(build_xml_manifest(package), xml_fname, perms, nil, nil)
    MojoSetup.stringtabletofile(build_txt_manifest(package), txt_fname, perms, nil, nil)

    MojoSetup.loginfo("manifests rebuilt!")
end


local function uninstaller()
    MojoSetup.loginfo("Uninstaller starting")
    local package = load_manifest(MojoSetup.info.argv[3])

    local title = _("Uninstall")

    local uninstall_permitted = false
    if MojoSetup.cmdline("force") then
        uninstall_permitted = true
    else
        local question = _("Are you sure you want to uninstall '%0'?")
        question = MojoSetup.format(question, package.description)
        uninstall_permitted = MojoSetup.promptyn(title, question, false)
    end

    if uninstall_permitted then
        start_gui(package.description, package.splash, package.splashpos)
        run_config_defined_hook(package.preuninstall, package)

        uninstall_desktop_menu_items(package)

        -- Upvalued in callback so we don't look this up each time...
        local ptype = _("Uninstalling")
        local callback = function(fname, current, total)
            fname = make_relative(fname, MojoSetup.destination)
            local percent = 100 - calc_percent(current, total)
            local component = package.manifest[fname].key
            if component == nil then
                component = ""
            elseif component == MojoSetup.metadatakey then
                component = MojoSetup.metadatadesc
            end

            local item = string.gsub(fname, "^.*/", "", 1)  -- chop off dirs...
            MojoSetup.gui.progressitem()
            MojoSetup.gui.progress(ptype, component, percent, item, false)
            return true  -- !!! FIXME: need to disable cancel button in UI...
        end

        local filelist = flatten_manifest(package.manifest)
        delete_files(MojoSetup.destination, filelist, callback, package.delete_error_is_fatal)
        run_config_defined_hook(package.postuninstall, package)
        if not MojoSetup.cmdline("noprompt") then
            MojoSetup.gui.final(_("Uninstall complete"))
        end
        stop_gui()
    end
end



-- Mainline...

local purpose = nil

-- Have to check argv instead of using cmdline(), for precision's sake.
-- Remember that unlike main()'s argv in C, Lua starts arrays at 1,
--  so argv[2] would be argv[1] in C. Fun.
local argv2 = MojoSetup.info.argv[2]
if argv2 == "manifest" then
    purpose = manifest_management
elseif argv2 == "uninstall" then
    purpose = uninstaller
else
    purpose = installer
    MojoSetup.runfile("config")  -- This builds the MojoSetup.installs table.
end

-- We don't need the "Setup" namespace anymore. Make it eligible
--  for garbage collection.
Setup = nil
MojoSetup.collectgarbage()  -- and collect it, etc.

-- Now do the real work...
purpose()

-- end of mojosetup_mainline.lua ...

