-- thatoneconsolemodder - Main.lua - launcher version 1 -- -- The app launcher for Marcus's site, http://thatoneconsolemodder.app/. It gets the site's app list (apps.ini), -- shows each app with what is installed, and installs or updates the one you pick as an Aurora utility script, -- so nobody has to type an address. It also updates itself from the same list. -- -- Safety rules -- * It downloads only from BASE_URL below. Paths in the app list are always paths on that site. -- * Every file goes into this launcher's tmp folder first and is checked (size and SHA-1, or MD5 when Aurora's -- SHA-1 comes back empty). If any file fails, nothing outside tmp changes. -- * It writes only into Game:\User\Scripts\Utility\\ (and, for apps whose entry asks for it, the -- same folder in your other Aurora instances), plus its own folder: tmp\, tocm.log, tocm.marker, and its own -- Main.lua when it updates itself. -- * Every file it replaces is kept first with .old added to the name (Main.lua -> Main.lua.old; an older .old is -- replaced). If a copy fails, the kept files are put back and the files it had just added are deleted. A folder -- that holds a different script is only replaced after you say so, and the safe answer is the first button. -- * It deletes only its own tmp folder, its log when the log passes 256 KB, and, when an install fails, the files -- that install had just added. -- -- Lab test hook (never part of a release): when a file named mirror.ini sits in this launcher's folder, fetch() -- copies the app list and the files from the folder named in it instead of downloading them, and the app list -- title starts with [LAB MIRROR]. Everything else runs exactly as without it. See launcher\docs\LAUNCHER.md. -- -- For editors: the top level must hold only plain values and function definitions. Aurora runs it while it -- builds the Scripts list, without the string, table or Aurora libraries; a library call there hides the script. scriptTitle = "thatoneconsolemodder" scriptAuthor = "Marcus" scriptVersion = 1 scriptDescription = "Lists the apps on thatoneconsolemodder.app, such as the Aurora Speed Kit, and installs or updates the one you pick. No typing needed." scriptPermissions = { "http", "filesystem" } local BASE_URL = "http://thatoneconsolemodder.app/" -- the only site this launcher talks to (plain HTTP, no redirects) local CATALOG = "apps.ini" -- the app list, below BASE_URL local FORMAT = 1 -- the newest apps.ini format this launcher reads local UTILITY = "Game:\\User\\Scripts\\Utility\\" -- where Aurora looks for utility scripts local MAIN = "Main.lua" local TMP, LOG_FILE, MARKER_FILE, MIRROR_FILE = "tmp", "tocm.log", "tocm.marker", "mirror.ini" local MAX_CATALOG, MAX_SCRIPT, MAX_FILES = 131072, 1048576, 20 -- bytes read from apps.ini / a Main.lua; files per app local WRAP, PAGE = 60, 8 -- one-button message box: characters per line, lines per box local BOX_WRAP, BOX_LINES = 68, 11 -- two-button box: wraps by itself (about 72), shows about 12 lines local LINE = 68 -- popup list lines are cut after about 70 characters local SKIP_DRIVES = { ["game:"] = true, ["system:"] = true, ["hdd0:"] = true, ["hddx:"] = true, ["sysext:"] = true, ["flash:"] = true, ["dvd:"] = true, ["hddvdplayer:"] = true, ["hddvdstorage:"] = true, ["transfercable:"] = true, ["transfercablexbox1:"] = true, ["connx:"] = true } -- scriptPermissions names Aurora accepts (case does not matter), with the names Aurora's Scripts panel shows local PERMS = { http = "HTTP", content = "Content", filesystem = "File System", settings = "Settings", sql = "SQLite", kernel = "Kernel" } local RESERVED = { libraries = true } -- Utility\Libraries is on every script's require path local K = {} -- run-time state set in main() and run(): base, myFolder, site, title, version, online, lab, others, unsure, updated, cache -- ----- Small helpers (every Aurora call is protected; files are checked by size and hash, never trusted) ----- local function int(x) return math.floor(tonumber(x) or 0) end local function trim(s) return (string.gsub(tostring(s or ""), "^%s*(.-)%s*$", "%1")) end local function lower(s) return string.lower(trim(s)) end -- Text from a file or the list, made safe to show: control and non-ASCII characters become '?', cut to max. local function shown(s, max) s = string.gsub(tostring(s or ""), "[^\32-\126]", "?") if #s > max then s = string.sub(s, 1, max) .. "..." end return s end local function stamp() local okd, d = pcall(Aurora.GetDate) local okt, t = pcall(Aurora.GetTime) if not okd or type(d) ~= "table" then d = {} end if not okt or type(t) ~= "table" then t = {} end return string.format("%04d-%02d-%02d %02d:%02d:%02d.%03d", int(d.Year), int(d.Month), int(d.Day), int(t.Hour), int(t.Minute), int(t.Second), int(t.Milliseconds)) end -- Support log: "tocm|" lines in Aurora's debug.log, plus tocm.log in this launcher's folder. local function log(msg) msg = tostring(msg) print("tocm| " .. msg) if not K.base then return end pcall(function() local f = io.open(K.base .. LOG_FILE, "ab") if f then f:write(stamp(), " ", msg, "\r\n") f:close() end end) end local function status(text, cur, total) pcall(Script.SetStatus, text) if cur and total and total > 0 then pcall(Script.SetProgress, cur, total) end end -- Aurora's one-button message box does not wrap text (SpeedKit lab fix F1), so long lines are broken here, at a -- space, or after a backslash inside a long path. A space next to '>' is not used (Xenia lab fix X3), so -- "Back > Scripts" and "v1 -> v2" stay on one line. local function wrap(text, width) local out = {} for line in string.gmatch(tostring(text) .. "\n", "(.-)\n") do while #line > width do local cut = nil for i = width + 1, 2, -1 do if string.sub(line, i, i) == " " and string.sub(line, i - 1, i - 1) ~= ">" and string.sub(line, i + 1, i + 1) ~= ">" and string.sub(line, i + 1, i + 2) ~= "->" then cut = i break end end if cut then out[#out + 1] = string.sub(line, 1, cut - 1) line = string.sub(line, cut + 1) else local b = width for i = width, 2, -1 do if string.sub(line, i, i) == "\\" then b = i break end end out[#out + 1] = string.sub(line, 1, b) line = string.sub(line, b + 1) end end out[#out + 1] = line end return table.concat(out, "\n") end -- The one-button box shows only about 8 lines, so a longer text is shown in parts, "(1/2)" and so on, with a -- Next button. Parts end between paragraphs where possible. Returns false when a part was closed with B. local function say(title, text) local paras, cur = {}, {} for l in string.gmatch(wrap(text, WRAP) .. "\n", "(.-)\n") do if l == "" then if #cur > 0 then paras[#paras + 1] = cur cur = {} end else cur[#cur + 1] = l end end if #cur > 0 then paras[#paras + 1] = cur end local pages, page = {}, {} for _, p in ipairs(paras) do if #page > 0 and #page + 1 + #p > PAGE then pages[#pages + 1] = page page = {} end if #page > 0 then page[#page + 1] = "" end for _, l in ipairs(p) do if #page >= PAGE then pages[#pages + 1] = page page = {} end page[#page + 1] = l end end if #page > 0 or #pages == 0 then pages[#pages + 1] = page end for i, pg in ipairs(pages) do local t = (#pages > 1) and (title .. " (" .. i .. "/" .. #pages .. ")") or title local ok, r = pcall(Script.ShowMessageBox, t, table.concat(pg, "\n"), (i < #pages) and "Next" or "OK") if ok and type(r) == "table" and r.Canceled == true then return false end end return true end -- Two-button box: 1 or 2 for the button picked, nil when it was closed with B. The first button has the focus. local function choose(title, text, first, second) local ok, r = pcall(Script.ShowMessageBox, title, text, first, second) if ok and type(r) == "table" and r.Canceled ~= true and (r.Button == 1 or r.Button == 2) then return r.Button end return nil end -- true only when the first button was chosen (B counts as No) local function ask(title, text, yes, no) return choose(title, text, yes or "Yes", no or "No") == 1 end -- The two-button box shows only about 11 lines: a longer text is shown first with say(), in parts, and then only -- the short question is asked. local function confirm(title, head, body, question, yes, no) local text = body .. "\n\n" .. question if head ~= "" then text = head .. "\n\n" .. text end local n = 0 for _ in string.gmatch(wrap(text, BOX_WRAP) .. "\n", "(.-)\n") do n = n + 1 end if n <= BOX_LINES then return ask(title, text, yes, no) end if not say(title, body) then return false end return ask(title, ((head ~= "") and (head .. "\n\n") or "") .. question, yes, no) end -- number of the chosen line, or nil local function pick(title, items) local ok, r = pcall(Script.ShowPopupList, title, "Nothing to show.", items) if not ok or type(r) ~= "table" or r.Canceled == true or type(r.Selected) ~= "table" then return nil end return tonumber(r.Selected.Key) end local function isThere(p) local ok, r = pcall(FileSystem.FileExists, p) return ok and r == true end local function copyFile(a, b, overwrite) local ok, r = pcall(FileSystem.CopyFile, a, b, overwrite == true) return ok and r == true end -- Size through io first: in the lab, FileSystem.GetFileSize returned 0 for a file CopyFile had just written -- (SpeedKit lab fix F5). GetFileSize is only the fallback; -1 when the file cannot be read. local function fileSize(p) local ok, r = pcall(function() local f = io.open(p, "rb") if not f then return nil end local n = f:seek("end") f:close() return n end) if ok and type(r) == "number" then return r end local ok2, r2 = pcall(FileSystem.GetFileSize, p) if ok2 and type(r2) == "number" then return r2 end return -1 end local function sha1Of(p) local ok, r = pcall(Aurora.Sha1HashFile, p) if ok and type(r) == "string" then return string.upper(r) end return "" end local function md5Of(p) local ok, r = pcall(Aurora.Md5HashFile, p) if ok and type(r) == "string" then return string.upper(r) end return "" end local function crcOf(p) local ok, r = pcall(Aurora.Crc32HashFile, p) if ok and type(r) == "string" then return string.upper(r) end return "" end local function remove(p) if not isThere(p) then return true end pcall(FileSystem.DeleteFile, p) return not isThere(p) end local function makeDir(p) pcall(FileSystem.CreateDirectory, p) -- always returns true, so check afterwards return isThere(p) end local function removeDir(p) if isThere(p) then pcall(FileSystem.DeleteDirectory, p) end return not isThere(p) end -- At most max bytes of a file (byte-exact), or nil. local function readText(p, max) local ok, r = pcall(function() local f = io.open(p, "rb") if not f then return nil end local s = f:read(max) f:close() return s end) if ok and type(r) == "string" then return r end return nil end -- Two files with the same size and CRC32 (Crc32HashFile always gives 8 digits; used for this launcher's own copies). local function sameFile(a, b) local c = crcOf(a) local n = fileSize(a) return c ~= "" and n >= 0 and fileSize(b) == n and crcOf(b) == c end -- Is the file at p exactly the file f describes ({ size, sha1, md5 })? Aurora's Sha1HashFile returns "" for a file -- whose SHA-1 starts with 00 (the site builder refuses such files), so then the MD5 decides. Returns true, or false -- and a reason. local function verify(p, f) if not isThere(p) then return false, "the file is missing" end local n = fileSize(p) if n ~= f.size then return false, "it has " .. n .. " bytes, expected " .. f.size end local h = sha1Of(p) if h ~= "" then if h == f.sha1 then return true end return false, "its SHA-1 does not match" end local m = md5Of(p) if m ~= "" and m == f.md5 then return true end return false, "its checksum does not match" end local function kb(n) if n >= 1048576 then return string.format("%.1f MB", n / 1048576) end return tostring(math.max(1, math.ceil(n / 1024))) .. " KB" end -- A permission list as one sorted, lower-case text, for example "filesystem,http"; "" for none. local function permKey(names) local t, seen = {}, {} for _, n in ipairs(names) do n = lower(n) if n ~= "" and n ~= "none" and not seen[n] then seen[n] = true t[#t + 1] = n end end table.sort(t) return table.concat(t, ",") end -- "File System, HTTP" for "filesystem,http", in the words of Aurora's Scripts panel. local function permText(key) if key == "" then return "no permissions" end local t = {} for n in string.gmatch(key, "[^,]+") do t[#t + 1] = PERMS[n] or n end return table.concat(t, ", ") end -- ----- The app list ------------------------------------------------------------------------------------------ -- Reads INI text. Returns { order = section names in file order, sec = { name = { key = value } }, complete = -- true when the ';end' line was reached, bad = numbers of the lines that could not be read }. Section names and -- keys are kept as written (keys are case-sensitive). A UTF-8 BOM at the start is skipped. A line that is neither -- blank, a comment (';' or '#' first), a [section] line nor key=value inside a section counts as bad, and so do -- the key=value lines under a section line that could not be read. A ';' after a value is part of the value. local function parseIni(text) local res, cur, n = { order = {}, sec = {}, complete = false, bad = {} }, nil, 0 text = tostring(text or "") if string.sub(text, 1, 3) == "\239\187\191" then text = string.sub(text, 4) end for raw in string.gmatch(text .. "\n", "(.-)\n") do n = n + 1 local line = trim(raw) if string.sub(line, 1, 4) == ";end" then res.complete = true break end local c = string.sub(line, 1, 1) if c == "[" then local name = string.match(line, "^%[(.+)%]$") cur = nil if name and trim(name) ~= "" then name = trim(name) if not res.sec[name] then res.sec[name] = {} res.order[#res.order + 1] = name end cur = res.sec[name] else res.bad[#res.bad + 1] = n end elseif c ~= "" and c ~= ";" and c ~= "#" then local k, v = string.match(line, "^([^=]+)=(.*)$") if k and cur then cur[trim(k)] = trim(v) else res.bad[#res.bad + 1] = n end end end return res end -- scriptTitle, scriptVersion and scriptPermissions of a Main.lua, read as text (the file is never run). title is "" -- and version nil when they are not found; perms is a permKey ("" when there is no scriptPermissions). local function readHeader(p) local txt = readText(p, MAX_SCRIPT) if not txt then return { title = "", perms = "" } end txt = "\n" .. txt local _, title = string.match(txt, "\n[ \t]*scriptTitle[ \t]*=[ \t]*([\"'])([^\r\n]-)%1") local v = string.match(txt, "\n[ \t]*scriptVersion[ \t]*=[ \t]*[\"']?([%d%.]+)") local names = {} local pt = string.match(txt, "\n[ \t]*scriptPermissions[ \t]*=[ \t]*(%b{})") if pt then for q in string.gmatch(pt, "[\"']([^\"'\r\n]*)[\"']") do names[#names + 1] = q end end return { title = title or "", version = tonumber(v or ""), versionText = v, perms = permKey(names) } end local function isHex(s, n) return type(s) == "string" and #s == n and not string.find(s, "[^%x]") end -- A file or folder name: letters, digits, space, '.', '_', '-'; starts with a letter or digit; no space or dot at the end. local function okName(s) return type(s) == "string" and #s <= 42 and string.find(s, "^[%w][%w _%.%-]*$") ~= nil and not string.find(s, "[ %.]$") end -- A file name inside the app folder, with optional sub folders separated by \ (each part an okName). local function okPath(s) if type(s) ~= "string" or s == "" or #s > 120 then return false end for part in string.gmatch(s .. "\\", "(.-)\\") do if not okName(part) then return false end end return true end -- A path on this site: no http://, no leading /, no '..', no '//', no '?'. local function okUrl(s) return type(s) == "string" and #s <= 200 and string.find(s, "^[%w][%w%-%._~/]*$") ~= nil and not string.find(s, "..", 1, true) and not string.find(s, "//", 1, true) and string.sub(s, -1) ~= "/" end -- One file of the list: { name, url, size, sha1, md5 } from the keys
name, 
url, ..., or nil and why.
local function readEntry(s, pre)
	local f = { name = s[pre .. "name"], url = s[pre .. "url"], size = tonumber(s[pre .. "size"] or ""),
		sha1 = string.upper(s[pre .. "sha1"] or ""), md5 = string.upper(s[pre .. "md5"] or "") }
	if not okUrl(f.url) then return nil, pre .. "url is not a path on this site" end
	if not f.size or f.size < 0 or f.size ~= math.floor(f.size) then return nil, pre .. "size is not a whole number" end
	if not isHex(f.sha1, 40) then return nil, pre .. "sha1 is not 40 hex digits" end
	if not isHex(f.md5, 32) then return nil, pre .. "md5 is not 32 hex digits" end
	return f
end

-- One app section, or nil and why. The section name is the app's id; title must be the script's scriptTitle.
local function readApp(id, s)
	local app = { id = id, title = s.title or "", versionText = s.version or "", version = tonumber(s.version or ""),
		author = s.author or "", description = s.description or "", folder = s.folder or "",
		scope = s.instances or "", needs = tonumber(s.minlauncher or "") or 0, files = {} }
	if not string.find(id, "^[%w][%w_%.%-]*$") or #id > 40 then return nil, "the section name is not a plain id" end
	if app.title == "" or #app.title > 80 then return nil, "title is missing or too long" end
	-- whole numbers only (review R8): as decimals, 1.10 would count as older than 1.9
	if not app.version or not string.find(app.versionText, "^%d+$") then return nil, "version is not a whole number" end
	if app.author == "" then return nil, "author is missing" end
	if app.description == "" then return nil, "description is missing" end
	if not okName(app.folder) then return nil, "folder is not a plain folder name" end
	if lower(app.folder) == lower(K.myFolder) or lower(app.folder) == lower(K.title) then return nil, "folder is this launcher's own folder" end
	if RESERVED[lower(app.folder)] then return nil, "folder " .. app.folder .. " is reserved for script libraries" end
	if app.scope ~= "all" and app.scope ~= "this" then return nil, "instances must be all or this" end
	-- permissions = the app's scriptPermissions, shown before the install and compared with the downloaded file
	if not s.permissions then return nil, "permissions is missing" end
	local names = {}
	for x in string.gmatch(s.permissions .. ",", "([^,]*),") do names[#names + 1] = x end
	app.perms = permKey(names)
	for n in string.gmatch(app.perms, "[^,]+") do
		if not PERMS[n] then return nil, "permissions has an unknown name (" .. shown(n, 20) .. ")" end
	end
	local n = tonumber(s.files or "")
	if not n or n < 1 or n > MAX_FILES or n ~= math.floor(n) then return nil, "files must be a whole number from 1 to " .. MAX_FILES end
	local seen = {}
	for i = 1, n do
		local pre = "file" .. i .. "."
		local f, why = readEntry(s, pre)
		if not f then return nil, why end
		if not okPath(f.name) then return nil, pre .. "name is not a plain file name" end
		local l = string.lower(f.name)
		-- a replaced file is kept as .old, which must still be a valid name (at most 42 characters)
		local last = string.match(l, "([^\\]+)$")
		if #last > 38 then return nil, pre .. "name is longer than 38 characters" end
		if seen[l] or string.find(l, "%.old$") then return nil, pre .. "name is used twice or ends in .old" end
		seen[l] = true
		if l == "main.lua" then app.main = f end
		app.files[#app.files + 1] = f
	end
	if not app.main then return nil, "no file is named Main.lua" end
	return app
end

-- Lab test hook: the folder named in mirror.ini in this launcher's folder; nil when there is no mirror.ini
-- (always on a user's console); "" when mirror.ini names no folder. Only run() and fetch() call this.
local function mirrorFolder()
	local p = K.base .. MIRROR_FILE
	if not isThere(p) then return nil end
	local s = parseIni(readText(p, 4096) or "").sec.mirror
	local d = s and s.folder or ""
	if d ~= "" and string.sub(d, -1) ~= "\\" then d = d .. "\\" end
	return d
end

-- The only place that gets files from the site. rel = path on the site (for example "apps.ini" or
-- "sk/v1/Main.lua"); dest = where to put it, relative to this launcher's folder (for example "tmp\\apps.ini");
-- what = the words for the status line. Returns true, or false, why, and "site" when the site answered with an
-- HTTP error. The caller checks the file. Lab test hook: with mirror.ini in this launcher's folder, the file is
-- copied from the mirror folder instead of downloaded.
local function fetch(rel, dest, what)
	local path = K.base .. dest
	remove(path)
	remove(path .. ".dl")
	-- Http.Get has no timeout and cannot be cancelled (review R2). The progress box shows only the first line of the
	-- status (about 45 characters, Xenia lab), so what to do when a download hangs is said in the question before
	-- it (install, self-update) and in About; the status itself stays short.
	status("Downloading " .. (what or rel) .. "...")
	local mirror = mirrorFolder()
	if mirror then
		if mirror == "" then return false, "mirror.ini (a lab test file) names no folder" end
		local src = mirror .. string.gsub(rel, "/", "\\")
		log("fetch (lab mirror) " .. src)
		if not isThere(src) then return false, "it is not in the lab mirror folder " .. mirror end
		if copyFile(src, path, true) and isThere(path) then return true end
		return false, "the copy from the lab mirror folder failed"
	end
	log("fetch " .. BASE_URL .. rel)
	local ok, r = pcall(Http.Get, BASE_URL .. rel, dest)   -- nil on network errors; redirects are not followed
	if not ok or type(r) ~= "table" then return false, "the site did not answer (network error)" end
	if r.Success ~= true then
		if r.Canceled == true then return false, "the download was cancelled" end
		if r.ResponseCode then return false, "the site answered with an error (HTTP " .. tostring(r.ResponseCode) .. ")", "site" end
		return false, "the site answered with an error"
	end
	if not isThere(path) then
		-- Never seen so far (review R13): Aurora says where it saved the file in OutputPath. Log it, and move the
		-- file into place as Aurora's own Repo Browser does.
		local out = r.OutputPath
		log("fetch: " .. path .. " is missing after the download; OutputPath=" .. tostring(out) .. ", ResponseCode=" .. tostring(r.ResponseCode))
		if type(out) == "string" and out ~= "" and lower(out) ~= lower(path) and isThere(out) then
			local okm, moved = pcall(FileSystem.MoveFile, out, path, true)
			log("fetch: moved " .. out .. " to " .. path .. ": " .. tostring(okm and moved))
		end
		if not isThere(path) then return false, "the downloaded file is missing" end
	end
	return true
end

-- Gets and reads apps.ini. Returns { format, launcher = file entry with version, apps = { ... }, broken = n,
-- badLines = n }, or nil and a message for the user.
local function loadCatalog()
	status("Getting the app list...", 0, 1)
	if not makeDir(K.base .. TMP) then
		return nil, "The launcher could not create its work folder " .. K.base .. TMP .. ".\n\nNothing was changed."
	end
	local rel = TMP .. "\\" .. CATALOG
	local ok, why, kind = fetch(CATALOG, rel, "the app list")
	if not ok then
		log("app list: " .. tostring(why))
		local advice = "Check the console's network and try again later."
		if kind == "site" then
			advice = "The site may be down for a while. Try again later."
		elseif K.online == false then
			advice = "Aurora also says this console is not online. " .. advice
		end
		return nil, "The launcher could not get the app list from " .. K.site .. ": " .. tostring(why) .. ".\n\n" .. advice .. "\n\nNothing was changed."
	end
	local text = readText(K.base .. rel, MAX_CATALOG) or ""
	remove(K.base .. rel)
	local ini = parseIni(text)
	if not ini.complete then
		log("app list: no ;end line (" .. #text .. " bytes)")
		return nil, "The app list from " .. K.site .. " is incomplete or is not an app list. The download may have stopped early, or something on the network answered instead of the site. Try again later.\n\nNothing was changed."
	end
	local cat = { format = 1, apps = {}, broken = 0, badLines = #ini.bad }
	if #ini.bad > 0 then
		local some = {}
		for i = 1, math.min(#ini.bad, 10) do some[#some + 1] = tostring(ini.bad[i]) end
		log("app list: " .. #ini.bad .. " line(s) could not be read: line " .. table.concat(some, ", ") .. ((#ini.bad > 10) and ", ..." or ""))
	end
	local s = ini.sec.launcher
	if s then
		cat.format = tonumber(s.format or "1") or 1
		local f, bad = readEntry(s, "")
		local v = tonumber(s.version or "")
		if f and v then
			f.version, f.versionText = v, s.version
			cat.launcher = f
		else
			log("app list: [launcher] ignored: " .. tostring(bad or "version is not a number"))
		end
	end
	for _, id in ipairs(ini.order) do
		if id ~= "launcher" then
			local app, bad = readApp(id, ini.sec[id])
			if app then
				cat.apps[#cat.apps + 1] = app
			else
				cat.broken = cat.broken + 1
				log("app list: [" .. id .. "] left out: " .. tostring(bad))
			end
		end
	end
	log("app list: format " .. cat.format .. ", launcher " .. (cat.launcher and ("v" .. cat.launcher.versionText) or "none")
		.. ", " .. #cat.apps .. " app(s), " .. cat.broken .. " left out, " .. cat.badLines .. " bad line(s)")
	return cat
end

-- ----- Finding the other Aurora instances (as the Aurora Speed Kit does) ----------------------------------------
local function looksLikeInstance(root)
	return isThere(root .. "\\Aurora.xex") or isThere(root .. "\\User\\Scripts")
end

-- This launcher writes a fresh token into its own marker file (under Game:) ...
local function markMine()
	local token = "TOCM1-" .. string.gsub(stamp(), "%D", "") .. "-" .. string.gsub(tostring({}), "%W", "")
	local mine = K.base .. MARKER_FILE
	pcall(FileSystem.WriteFile, mine, token)   -- plain text without line breaks, so text mode is fine
	local ok, back = pcall(FileSystem.ReadFile, mine)
	if ok and back == token then return token end
	return nil
end

local function hasToken(dir, token)   -- dir ends with \
	local p = dir .. MARKER_FILE
	if not token or not isThere(p) then return false end
	local ok, t = pcall(FileSystem.ReadFile, p)
	return ok and t == token
end

-- ... and reads the marker at the same place in every found folder: only an exact token match means "this is the
-- Aurora you are running, under its drive name". Returns the numbers of the matching folders.
local function identifyRunning(found)
	local rel = string.match(K.base, "^[Gg][Aa][Mm][Ee]:\\(.+)$")   -- this launcher's folder below Game:\, ends with \
	local hits, token = {}, nil
	if rel then token = markMine() end
	if token then
		for i, f in ipairs(found) do
			if hasToken(f.root .. "\\" .. rel, token) then hits[#hits + 1] = i end
		end
	end
	return hits
end

-- Every other instance: \AuroraInstances\\Aurora and \Aurora on every drive, without the
-- running one (that is always Game:). Sets K.unsure when the running one was not matched to exactly one folder;
-- then it may also be listed under its drive name, which is harmless: a folder that already holds the checked
-- files is never copied to again.
local function scan()
	status("Looking for Aurora instances...", 0, 1)
	local found, seen = {}, {}
	local ok, drives = pcall(FileSystem.GetDrives)
	for _, d in ipairs((ok and type(drives) == "table") and drives or {}) do
		local mp = type(d) == "table" and d.MountPoint or nil
		if type(mp) == "string" and mp ~= "" then
			local key, dname, sys = string.lower(mp), string.lower(tostring(d.Name or "")), string.lower(tostring(d.SystemPath or ""))
			local skip = SKIP_DRIVES[key] or dname == "aurora" or string.find(key, "cache", 1, true) ~= nil
			if not skip and sys ~= "" then
				skip = seen[sys] == true   -- the same partition under a second name
				seen[sys] = true
			end
			if not skip then
				local roots = {}
				local okd, dirs = pcall(FileSystem.GetDirectories, mp .. "\\AuroraInstances\\*")
				for _, e in ipairs((okd and type(dirs) == "table") and dirs or {}) do
					local n = type(e) == "table" and e.Name or nil
					if type(n) == "string" and n ~= "." and n ~= ".." then
						roots[#roots + 1] = { label = n, root = mp .. "\\AuroraInstances\\" .. n .. "\\Aurora" }
					end
				end
				roots[#roots + 1] = { label = "Aurora", root = mp .. "\\Aurora" }
				for _, r in ipairs(roots) do
					if looksLikeInstance(r.root) then found[#found + 1] = { label = r.label, where = mp, root = r.root } end
				end
			end
		end
	end
	local hits, hit, others = identifyRunning(found), {}, {}
	for _, i in ipairs(hits) do hit[i] = true end
	for i, f in ipairs(found) do
		if not hit[i] then others[#others + 1] = f end
	end
	K.unsure = #hits ~= 1
	log("scan: " .. #found .. " instance folder(s); " .. #hits .. " matched this one; " .. #others .. " other(s)")
	for _, f in ipairs(others) do log("  " .. f.root) end
	return others
end

-- ----- Apps: state, install ------------------------------------------------------------------------------------
-- The folders an app goes into: this Aurora (always first), plus every other instance found for instances=all.
local function targetsFor(app)
	local t = { { label = K.unsure and "This Aurora (Game:)" or "This Aurora", dir = UTILITY .. app.folder } }
	if app.scope == "all" then
		for _, inst in ipairs(K.others or {}) do
			t[#t + 1] = { label = inst.label .. " (" .. inst.where .. ")", dir = inst.root .. "\\User\\Scripts\\Utility\\" .. app.folder }
		end
	end
	return t
end

-- What one folder holds, from its files only. key = "missing", "current" (every file is exactly the site's),
-- "older", "newer", "changed" (same script, other files) or "other" (a different scriptTitle; title = that
-- script's title, cut and cleaned for the screen).
local function folderState(app, dir)
	local main = dir .. "\\" .. MAIN
	if not isThere(main) then return { key = "missing" } end
	local h = readHeader(main)
	if lower(h.title) ~= lower(app.title) then return { key = "other", title = shown(trim(h.title), 40) } end
	local all = true
	for _, f in ipairs(app.files) do
		if not verify(dir .. "\\" .. f.name, f) then all = false break end
	end
	if all then return { key = "current", have = h.versionText } end
	if h.version and h.version < app.version then return { key = "older", have = h.versionText } end
	if h.version and h.version > app.version then return { key = "newer", have = h.versionText } end
	return { key = "changed", have = h.versionText }
end

-- The same, kept for this run, for the app list (review R15: the list is shown again after every pick, and
-- checking big files each time is slow). doApp() always looks again, and the kept states are dropped after it.
local function stateOf(app, dir)
	local key = app.id .. "|" .. lower(dir)
	if not K.cache[key] then K.cache[key] = folderState(app, dir) end
	return K.cache[key]
end

local function planText(app, st)
	local k, have = st.key, "v" .. tostring(st.have or "?")
	if k == "missing" then return "not installed -> install v" .. app.versionText end
	if k == "current" then return "v" .. app.versionText .. " installed, up to date" end
	if k == "older" then return have .. " installed -> update to v" .. app.versionText end
	if k == "newer" then return have .. " installed, newer than the site's: left alone" end
	if k == "changed" then return have .. " installed, but its files differ from the site's -> replace" end
	return "holds another script (" .. ((st.title ~= "") and st.title or "no title") .. "): you are asked next"
end

-- The state of one folder in a few words, for the app list.
local function shortText(app, st)
	local k, have, v = st.key, "v" .. tostring(st.have or "?"), "v" .. app.versionText
	if k == "missing" then return "not installed (" .. v .. ")" end
	if k == "current" then return "installed " .. v end
	if k == "older" then return "update available: " .. have .. " -> " .. v end
	if k == "newer" then return "installed " .. have .. ", newer than the site" end
	if k == "changed" then return "installed " .. have .. ", differs from the site" end
	return "folder used by another script"
end

-- One line of the app list: " - <state>", the title cut to fit.
local function listLine(app)
	local st
	if app.needs > K.version then
		st = "needs a newer launcher"
	else
		local n, c, one = 0, { missing = 0, current = 0, older = 0, changed = 0, newer = 0, other = 0 }, nil
		for _, t in ipairs(targetsFor(app)) do
			one = stateOf(app, t.dir)
			c[one.key] = c[one.key] + 1
			n = n + 1
		end
		local v = "v" .. app.versionText
		if n == 1 then st = shortText(app, one)
		elseif c.current == n then st = "installed " .. v .. " (all " .. n .. ")"
		elseif c.missing == n then st = "not installed (" .. v .. ")"
		elseif c.older + c.changed > 0 then st = "update available (" .. v .. ")"
		elseif c.current + c.newer > 0 then st = "installed in " .. (c.current + c.newer) .. " of " .. n
		else st = "not installed (" .. v .. ")" end
	end
	local head, tail = app.title, " - " .. st
	if #head + #tail > LINE then head = string.sub(head, 1, math.max(8, LINE - #tail - 3)) .. "..." end
	return head .. tail
end

-- Downloads every file of the app into tmp\<id>\ and checks each one. Returns the list of checked paths, or nil and
-- a message. Nothing outside tmp changes here.
local function downloadApp(app)
	local rdir = TMP .. "\\" .. app.id
	removeDir(K.base .. rdir)
	if not makeDir(K.base .. rdir) then return nil, "The launcher could not create its work folder " .. K.base .. rdir .. "." end
	local got = {}
	for i, f in ipairs(app.files) do
		status("Downloading " .. f.name .. " (" .. i .. " of " .. #app.files .. ")...", i - 1, #app.files)
		local rel = rdir .. "\\file" .. i
		local ok, why = fetch(f.url, rel, f.name .. " (" .. i .. " of " .. #app.files .. ")")
		if ok then ok, why = verify(K.base .. rel, f) end
		if ok and f == app.main then
			local h = readHeader(K.base .. rel)
			if lower(h.title) ~= lower(app.title) or h.version ~= app.version then
				ok, why = false, "it is not the script the app list describes"
			elseif h.perms ~= app.perms then
				ok, why = false, "it asks for other permissions than the app list says (" .. permText(h.perms) .. ")"
			end
		end
		if not ok then
			log("download " .. f.url .. " failed: " .. tostring(why))
			remove(K.base .. rel)
			return nil, f.name .. " could not be downloaded or did not pass the check: " .. tostring(why) .. "."
		end
		got[i] = K.base .. rel
	end
	status("All files are downloaded and checked", 1, 1)
	log("downloaded and checked " .. #app.files .. " file(s) of " .. app.id)
	return got
end

-- Puts the checked files into one folder: the other files first, Main.lua last, so the old script stays whole until
-- the new files are in place. Before anything is copied, every file that is about to be replaced is kept as
-- <name>.old (review R3; an older .old is replaced); if one cannot be kept, nothing is replaced. If a copy fails,
-- the kept files are put back and the files this call added are deleted. A file that already checks out is not
-- copied again. Returns "done", "same" or "failed" and a short text for the summary.
local function installInto(app, dir, got)
	if not makeDir(dir) then return "failed", "the folder could not be created; nothing was changed" end
	local order = {}
	for i, f in ipairs(app.files) do if f ~= app.main then order[#order + 1] = i end end
	for i, f in ipairs(app.files) do if f == app.main then order[#order + 1] = i end end
	-- 1. Keep every file that will be replaced.
	local kept, added = {}, {}
	for _, i in ipairs(order) do
		local f = app.files[i]
		local dst = dir .. "\\" .. f.name
		if isThere(dst) and not verify(dst, f) then
			local old = dst .. ".old"
			if not (copyFile(dst, old, true) and sameFile(dst, old)) then
				return "failed", f.name .. " could not be kept as " .. f.name .. ".old, so nothing was replaced"
			end
			kept[#kept + 1] = { dst = dst, old = old, name = f.name }
		end
	end
	-- 2. Copy and check each file. On a failure, put back what was there.
	local copied = 0
	for _, i in ipairs(order) do
		local f = app.files[i]
		local dst = dir .. "\\" .. f.name
		if not verify(dst, f) then
			local existed = isThere(dst)
			local sub = string.match(f.name, "^(.*)\\[^\\]+$")
			local ok = (not sub or makeDir(dir .. "\\" .. sub)) and copyFile(got[i], dst, true) and verify(dst, f)
			if not existed then added[#added + 1] = dst end
			if not ok then
				local back = true
				for j = #added, 1, -1 do back = remove(added[j]) and back end
				for j = #kept, 1, -1 do
					local k = kept[j]
					if not sameFile(k.old, k.dst) then back = (copyFile(k.old, k.dst, true) and sameFile(k.old, k.dst)) and back end
				end
				log("install into " .. dir .. ": " .. f.name .. " did not copy correctly; rollback of " .. #kept .. " kept and " .. #added .. " added file(s): " .. tostring(back))
				if back then return "failed", f.name .. " did not copy correctly; the previous files are back in place" end
				return "failed", f.name .. " did not copy correctly, and not every previous file could be put back. They are kept with .old added to the name."
			end
			copied = copied + 1
		end
	end
	if copied == 0 then return "same", "already up to date" end
	if #kept == 1 then return "done", "now v" .. app.versionText .. " (the previous " .. kept[1].name .. " is kept as " .. kept[1].name .. ".old)" end
	if #kept > 1 then return "done", "now v" .. app.versionText .. " (the " .. #kept .. " files it replaced are kept with .old added to the name)" end
	return "done", "v" .. app.versionText .. " installed"
end

-- Shows one app, asks, downloads and installs. Returns "restart", "done" or nil.
local function doApp(app)
	if app.needs > K.version then
		local how = "Update the launcher first: it offers the update when the site has one."
		if K.updated then how = "The launcher was just updated: close it with B and run it again." end
		return say(app.title, app.description .. "\n\nThis app needs version " .. app.needs .. " or newer of this launcher; this is v" .. K.version .. ". " .. how .. "\n\nNothing was changed.")
	end
	status("Checking " .. app.title .. "...", 0, 1)
	local targets, work, asks, plan, update = targetsFor(app), {}, {}, {}, false
	for _, t in ipairs(targets) do
		t.st = folderState(app, t.dir)
		plan[#plan + 1] = t.label .. ": " .. planText(app, t.st)
		local k = t.st.key
		if k == "missing" or k == "older" or k == "changed" then work[#work + 1] = t end
		if k == "older" or k == "changed" then update = true end
		if k == "other" then asks[#asks + 1] = t end
	end
	local size = 0
	for _, f in ipairs(app.files) do size = size + f.size end
	local where = "It goes into the script folder " .. app.folder .. " of the Aurora you are running."
	if app.scope == "all" then where = "It goes into the script folder " .. app.folder .. " of every Aurora instance found (" .. #targets .. ")." end
	local body = app.description .. "\n\nVersion " .. app.versionText .. " by " .. app.author .. ", " .. #app.files .. " file(s), about "
		.. kb(size) .. ". It asks Aurora for: " .. permText(app.perms) .. ". " .. where .. "\n\n" .. table.concat(plan, "\n")
	if #work == 0 and #asks == 0 then return say(app.title, body .. "\n\nNothing to do. Nothing was changed.") end
	body = body .. "\n\nThe launcher downloads and checks every file before it changes anything. Files it replaces are kept with .old added to the name."
		.. " If a download seems stuck for more than 5 minutes, it is safe to turn the console off."
	local verb = update and "Update" or "Install"
	if not confirm(app.title, "", body, verb .. " " .. app.title .. " now?", verb, "Cancel") then return end
	-- A folder that holds another script: only an explicit "Replace it" replaces it (the safe answer is first).
	local skipped = {}
	for _, t in ipairs(asks) do
		local other = (t.st.title ~= "") and ("the script \"" .. t.st.title .. "\"") or "a script without a title"
		-- review R6: without a marker match, the running Aurora can also be listed under its drive name
		local note = K.unsure and " The Aurora you are running may be listed twice: as This Aurora and under its drive name." or ""
		-- the path is broken after a backslash here; Aurora's own wrap cut a long path in mid-word (Xenia lab fix X4)
		if choose("Another script is there", t.label .. ": the folder\n" .. wrap(t.dir, WRAP) .. "\nholds " .. other .. ", not " .. app.title
			.. "." .. note .. "\n\nReplace it? Files it replaces are kept with .old added to the name.", "Skip it", "Replace it") == 2 then
			work[#work + 1] = t
		else
			skipped[#skipped + 1] = t.label .. ": left alone (another script is there)"
		end
	end
	if #work == 0 then return say(app.title, table.concat(skipped, "\n") .. "\n\nNothing was changed.") end
	local got, err = downloadApp(app)
	if not got then
		removeDir(K.base .. TMP .. "\\" .. app.id)
		return say(app.title, err .. "\n\nNothing was changed.")
	end
	local lines, did, didThis = {}, false, false
	for i, t in ipairs(work) do
		status("Installing. Do not turn the console off.", i - 1, #work)
		local code, msg = installInto(app, t.dir, got)
		log("install " .. app.id .. " v" .. app.versionText .. " into " .. t.dir .. ": " .. code .. ", " .. msg)
		lines[#lines + 1] = t.label .. ": " .. msg
		did = did or code == "done"
		-- when the running Aurora may also be listed under its drive name (review R6), any change counts as its own
		if code == "done" and (t == targets[1] or K.unsure) then didThis = true end
	end
	for _, l in ipairs(skipped) do lines[#lines + 1] = l end
	status(app.title .. ": finished", #work, #work)
	removeDir(K.base .. TMP .. "\\" .. app.id)
	local summary = table.concat(lines, "\n")
	if not did then return say(app.title, summary) end
	-- Only a change in the running Aurora needs a restart; other instances read their scripts when they start
	-- (Xenia lab fix X1: the restart was offered after installs into other instances only).
	if not didThis then
		say(app.title, summary .. "\n\nThe Aurora you are running did not change, so it needs no restart. The other instances show the script in Back > Scripts the next time they start.")
		return "done"
	end
	-- Aurora reads the Scripts list when it starts but loads Main.lua at each run (Xenia lab): a new script needs the
	-- restart to show up; an updated one already runs the new code and only shows its new version after the restart.
	local k1 = targets[1].st.key
	local why = "Aurora must restart before the new script shows in Back > Scripts."
	if not K.unsure and (k1 == "older" or k1 == "changed") then why = "Back > Scripts shows the new version after Aurora restarts." end
	if confirm(app.title, "", summary .. "\n\n" .. why, "Restart Aurora now?", "Restart now", "Later") then
		return "restart"
	end
	return "done"
end

-- ----- Updating this launcher --------------------------------------------------------------------------------------
-- L = the [launcher] entry of the app list. Returns "restart", "updated", "declined" or "failed".
local function updateSelf(L)
	-- Xenia lab fix X2: the size is the new file's, so it goes with the new version, not with "this one"
	local body = "A newer version of this launcher is on " .. K.site .. ": v" .. L.versionText .. ", about " .. kb(L.size)
		.. ". This one is v" .. K.version .. ".\n\nThe launcher downloads it, checks it, and then replaces its own Main.lua. The current one is kept as Main.lua.old."
		.. " If the download seems stuck for more than 5 minutes, it is safe to turn the console off."
	if not confirm(K.title, "", body, "Update the launcher now?", "Update", "Not now") then
		log("launcher update to v" .. L.versionText .. " declined")
		return "declined"
	end
	local rel = TMP .. "\\launcher.new"
	local path, main, old = K.base .. rel, K.base .. MAIN, K.base .. MAIN .. ".old"
	makeDir(K.base .. TMP)
	status("Downloading launcher v" .. L.versionText .. "...", 0, 1)
	local ok, why = fetch(L.url, rel, "launcher v" .. L.versionText)
	if ok then ok, why = verify(path, L) end
	if ok then
		local h = readHeader(path)
		if lower(h.title) ~= lower(K.title) or h.version ~= L.version then ok, why = false, "it is not the launcher the app list describes" end
	end
	if not ok then
		remove(path)
		log("launcher update failed: " .. tostring(why))
		say(K.title, "The new launcher could not be downloaded or did not pass the check: " .. tostring(why) .. ".\n\nNothing was changed. This launcher keeps working.")
		return "failed"
	end
	status("Updating. Do not turn the console off.", 0, 1)
	if not (copyFile(main, old, true) and sameFile(main, old)) then
		remove(path)
		log("launcher update: could not keep " .. old)
		say(K.title, "The launcher could not keep a copy of itself as Main.lua.old, so it did not update.\n\nNothing was changed.")
		return "failed"
	end
	if copyFile(path, main, true) and verify(main, L) then
		remove(path)
		log("launcher updated to v" .. L.versionText .. "; the previous one is kept as " .. old)
		if ask(K.title, "The launcher is now v" .. L.versionText .. ". Aurora must restart to show the new version in Back > Scripts.\n\nRestart Aurora now?", "Restart now", "Later") then return "restart" end
		-- Aurora loads Main.lua at each run (Xenia lab: the next run said v2 without a restart); only the version shown
		-- in Back > Scripts waits for the restart.
		say(K.title, "The launcher is updated. This run goes on with the old one; the next time you run the launcher, the new one runs. Back > Scripts shows the new version number after Aurora restarts.")
		return "updated"
	end
	local back = copyFile(old, main, true) and sameFile(old, main)
	log("launcher update: the new Main.lua did not check out; previous one put back: " .. tostring(back))
	remove(path)
	if back then
		say(K.title, "The new launcher could not be put in place, so the current one was put back.\n\nNothing was changed.")
	else
		say(K.title, "The new launcher could not be put in place, and the current one could not be put back. With File Manager, copy\n" .. old .. "\nto\n" .. main)
	end
	return "failed"
end

local function aboutBox()
	local lab = ""
	if K.lab then
		lab = "LAB MIRROR: the file " .. MIRROR_FILE .. " in this launcher's folder makes it read a local copy of the site instead of "
			.. K.site .. ". Delete that file to use the real site.\n\n"
	end
	say("About " .. K.title, lab .. K.title .. " v" .. K.version .. " by Marcus.\n\nIt gets the app list from " .. BASE_URL .. CATALOG
		.. " and installs the app you pick into Game:\\User\\Scripts\\Utility\\<app folder>. Some apps also go into your other Aurora instances; the app's details say so.\n\n"
		.. "Every file is checked before anything changes. Files it replaces are kept with .old added to the name.\n\n"
		.. "If the launcher stays on Downloading for more than 5 minutes, turn the console off and try again later. A download changes nothing.\n\n"
		.. "After an install, restart Aurora so the script shows in Back > Scripts.\n\n"
		.. "If the launcher is missing from Back > Scripts after an update, use File Manager: in\n" .. K.base
		.. "\ndelete Main.lua, then rename Main.lua.old to Main.lua.\n\n"
		.. "To remove an app, delete its folder with File Manager. To remove this launcher, delete\n" .. K.base .. "\n\n"
		.. "Log: " .. K.base .. LOG_FILE .. "\n\n"
		.. "Aurora is made by Phoenix. Phoenix did not make or check this launcher, so please do not ask them for help with it.")
end

-- ----- Entry point ------------------------------------------------------------------------------------------------
local function run()
	-- Aurora's online flag is only used in the failure message (review R11): what sets it is not known, and asking
	-- first could add a question to every run on consoles that are kept off Xbox Live.
	local okn, online = pcall(Aurora.HasInternetConnection)
	K.online = okn and online == true
	if not K.online then log("Aurora says the console is not online; trying anyway") end
	K.lab = mirrorFolder() ~= nil
	if K.lab then log("LAB MIRROR: " .. MIRROR_FILE .. " is in " .. K.base) end
	K.cache = {}
	local cat, err = loadCatalog()
	if not cat then return say(K.title, err) end
	local L = cat.launcher
	if L and L.version > K.version then
		local r = updateSelf(L)
		if r == "restart" then return "restart" end
		K.updated = r == "updated"
	end
	if cat.format > FORMAT then
		if K.updated then
			return say(K.title, "The app list on " .. K.site .. " needs the new launcher. Run the launcher again.")
		end
		return say(K.title, "The app list on " .. K.site .. " needs a newer version of this launcher. Run the launcher again and pick Update when it asks.\n\nNothing was changed.")
	end
	for _, app in ipairs(cat.apps) do
		if app.scope == "all" then K.others = scan() break end
	end
	K.others = K.others or {}
	while true do
		local items, map = {}, {}
		for _, app in ipairs(cat.apps) do
			status("Checking " .. app.title .. "...", #items, #cat.apps)
			items[#items + 1] = listLine(app)
			map[#items] = app
		end
		local upd, broken
		if L and L.version > K.version and not K.updated then
			items[#items + 1] = "Update this launcher to v" .. L.versionText
			upd = #items
		end
		if cat.broken > 0 or cat.badLines > 0 then
			items[#items + 1] = "Some apps on the site could not be read"
			broken = #items
		end
		items[#items + 1] = "About this launcher"
		local about = #items
		status("Pick an app", 1, 1)
		local k = pick((K.lab and "[LAB MIRROR] " or "") .. K.title .. " v" .. K.version .. ": apps on " .. K.site, items)
		if not k then break end
		if map[k] then
			local r = doApp(map[k])
			K.cache = {}
			if r == "restart" then return "restart" end
		elseif k == upd then
			local r = updateSelf(L)
			if r == "restart" then return "restart" end
			K.updated = K.updated or r == "updated"
		elseif k == broken then
			local what = {}
			if cat.broken > 0 then
				what[#what + 1] = (cat.broken == 1) and "1 entry in the app list could not be read, so it is not shown."
					or (cat.broken .. " entries in the app list could not be read, so they are not shown.")
			end
			if cat.badLines > 0 then
				what[#what + 1] = ((cat.badLines == 1) and "1 line" or (cat.badLines .. " lines")) .. " of the app list could not be read."
			end
			say(K.title, table.concat(what, " ") .. " This launcher may be too old, or the list has a mistake. The log has the details:\n"
				.. K.base .. LOG_FILE .. "\n\nNothing was changed.")
		elseif k == about then
			aboutBox()
		end
	end
end

function main()
	local okb, b = pcall(Script.GetBasePath)
	if not okb or type(b) ~= "string" or b == "" then
		return say("thatoneconsolemodder", "The launcher could not find its own folder, so it stopped. Nothing was changed.")
	end
	if string.sub(b, -1) ~= "\\" then b = b .. "\\" end
	K.base = b
	K.myFolder = string.match(b, "([^\\]+)\\$") or ""
	K.site = string.match(BASE_URL, "^https?://([^/]+)") or BASE_URL
	K.title = tostring(scriptTitle)
	K.version = tonumber(scriptVersion) or 0
	if fileSize(b .. LOG_FILE) > 262144 then remove(b .. LOG_FILE) end
	local okv, v = pcall(Aurora.GetDashVersion)
	local aurora = (okv and type(v) == "table") and (tostring(v.Major) .. "." .. tostring(v.Minor) .. " type " .. tostring(v.Type) .. " r" .. tostring(v.Revision)) or "unknown"
	log("start: launcher v" .. K.version .. " in " .. b .. "; Aurora " .. aurora)
	removeDir(b .. TMP)   -- leftovers of an interrupted run
	local ok, res = pcall(run)
	removeDir(b .. TMP)
	if not ok then
		log("ERROR: " .. tostring(res))
		say(K.title, "Something unexpected went wrong, so the launcher stopped:\n" .. tostring(res) .. "\n\nRun the launcher again to see what is installed. If it was replacing files, the previous ones are kept with .old added to the name.")
		return
	end
	if res == "restart" then
		log("restarting Aurora")
		status("Restarting Aurora...", 1, 1)
		pcall(Aurora.Restart)
		return
	end
	log("end")
end