mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
nodejs: download libsimplex into the user cache
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
"version": "7.1.0-beta.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"simplex-chat": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"cpp",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
import {parseArgs} from "util"
|
||||
import type {Backend} from "./core"
|
||||
import {resolveLibsDir} from "./libs"
|
||||
|
||||
const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]"
|
||||
const EXIT_INSTALL_FAILED = 1
|
||||
const EXIT_USAGE = 2
|
||||
|
||||
export function parseInstallArgs(argv: string[]): Backend {
|
||||
const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}})
|
||||
if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install")
|
||||
if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`)
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function main(argv: string[]): Promise<number> {
|
||||
if (argv.includes("-h") || argv.includes("--help")) {
|
||||
console.log(USAGE)
|
||||
return 0
|
||||
}
|
||||
let backend: Backend
|
||||
try {
|
||||
backend = parseInstallArgs(argv)
|
||||
} catch (e) {
|
||||
console.error(`${(e as Error).message}\n${USAGE}`)
|
||||
return EXIT_USAGE
|
||||
}
|
||||
try {
|
||||
const dir = await resolveLibsDir(backend)
|
||||
console.log(`libsimplex installed at: ${dir}`)
|
||||
return 0
|
||||
} catch (e) {
|
||||
console.error(`install failed: ${(e as Error).message}`)
|
||||
return EXIT_INSTALL_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main(process.argv.slice(2)).then(code => { process.exitCode = code })
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
|
||||
import * as simplex from "./simplex"
|
||||
|
||||
export type Backend = "sqlite" | "postgres"
|
||||
|
||||
/**
|
||||
* Initialize chat controller
|
||||
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as fs from "fs"
|
||||
import * as http from "http"
|
||||
import * as https from "https"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {pipeline} from "stream/promises"
|
||||
import extract = require("extract-zip")
|
||||
import type {Backend} from "./core"
|
||||
|
||||
export const LIBS_VERSION = "7.1.0-beta.4"
|
||||
|
||||
const GITHUB_REPO = "simplex-chat/simplex-chat-libs"
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
export const MAX_REDIRECTS = 5
|
||||
const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = {
|
||||
linux: {name: "linux", lib: "libsimplex.so"},
|
||||
darwin: {name: "macos", lib: "libsimplex.dylib"},
|
||||
win32: {name: "windows", lib: "libsimplex.dll"},
|
||||
}
|
||||
const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"}
|
||||
const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"]
|
||||
|
||||
function unsupported(platform: string, arch: string): Error {
|
||||
return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
|
||||
}
|
||||
|
||||
export function platformTag(platform: string, arch: string): string {
|
||||
const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}`
|
||||
if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch)
|
||||
return tag
|
||||
}
|
||||
|
||||
function libName(platform: string = process.platform, arch: string = process.arch): string {
|
||||
const lib = PLATFORMS[platform]?.lib
|
||||
if (!lib) throw unsupported(platform, arch)
|
||||
return lib
|
||||
}
|
||||
|
||||
export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string {
|
||||
if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat")
|
||||
if (platform === "win32") {
|
||||
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set")
|
||||
return path.join(env.LOCALAPPDATA, "simplex-chat")
|
||||
}
|
||||
return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat")
|
||||
}
|
||||
|
||||
export function libsUrl(backend: Backend, tag: string): string {
|
||||
const suffix = backend === "postgres" ? "-postgres" : ""
|
||||
return `https://github.com/${GITHUB_REPO}/releases/download/v${LIBS_VERSION}/simplex-chat-libs-${tag}${suffix}.zip`
|
||||
}
|
||||
|
||||
export function libPath(dir: string): string {
|
||||
return path.join(dir, libName())
|
||||
}
|
||||
|
||||
export async function resolveLibsDir(
|
||||
backend: Backend,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch
|
||||
): Promise<string> {
|
||||
const lib = libName(platform, arch)
|
||||
if (env.SIMPLEX_LIBS_DIR) {
|
||||
const dir = path.resolve(env.SIMPLEX_LIBS_DIR)
|
||||
if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`)
|
||||
return dir
|
||||
}
|
||||
const tag = platformTag(platform, arch)
|
||||
if (backend === "postgres" && tag !== "linux-x86_64") {
|
||||
throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`)
|
||||
}
|
||||
const target = path.resolve(cacheRoot(platform, env), `v${LIBS_VERSION}`, backend)
|
||||
if (!fs.existsSync(path.join(target, lib))) await installLibs(libsUrl(backend, tag), target, lib)
|
||||
return target
|
||||
}
|
||||
|
||||
export async function installLibs(url: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
|
||||
const parent = path.dirname(target)
|
||||
await fs.promises.mkdir(parent, {recursive: true})
|
||||
const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-"))
|
||||
try {
|
||||
console.error(`Downloading libsimplex from ${url} ...`)
|
||||
const zipPath = path.join(tmp, "libs.zip")
|
||||
await download(url, zipPath, timeoutMs)
|
||||
await extract(zipPath, {dir: tmp})
|
||||
const extracted = path.join(tmp, "libs")
|
||||
if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
|
||||
try {
|
||||
await fs.promises.rename(extracted, target)
|
||||
} catch (e) {
|
||||
// Another process installed the same version first; its files are identical.
|
||||
// Windows reports renaming onto an existing directory as EPERM.
|
||||
const code = (e as NodeJS.ErrnoException).code
|
||||
const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target)
|
||||
if (!lost) throw e
|
||||
if (!fs.existsSync(path.join(target, lib))) {
|
||||
throw new Error(`another process partially populated ${target} but libsimplex is missing; remove the directory manually and retry`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
|
||||
function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = url.startsWith("https:") ? https.get : http.get
|
||||
// Once a response arrives, request errors are ignored: a download settles via pipeline, which
|
||||
// waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows.
|
||||
let res: http.IncomingMessage | undefined
|
||||
const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => {
|
||||
res = response
|
||||
const status = response.statusCode ?? 0
|
||||
const location = response.headers.location
|
||||
if (status >= 300 && status < 400 && location) {
|
||||
response.resume()
|
||||
if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`))
|
||||
let next: URL
|
||||
try {
|
||||
next = new URL(location, url)
|
||||
} catch (e) {
|
||||
return reject(e)
|
||||
}
|
||||
if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`))
|
||||
download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (status !== 200) {
|
||||
response.resume()
|
||||
return reject(new Error(`HTTP ${status} downloading ${url}`))
|
||||
}
|
||||
pipeline(response, fs.createWriteStream(dest)).then(resolve, reject)
|
||||
})
|
||||
const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err))
|
||||
req.on("timeout", () => abort(new Error(`timeout downloading ${url}`)))
|
||||
req.on("error", e => { if (!res) reject(e) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as fs from "fs"
|
||||
import * as http from "http"
|
||||
import * as https from "https"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {AddressInfo} from "net"
|
||||
import {cacheRoot, installLibs, LIBS_VERSION, libPath, libsUrl, MAX_REDIRECTS, platformTag, resolveLibsDir} from "../src/libs"
|
||||
import {main, parseInstallArgs} from "../src/cli"
|
||||
import * as libs from "../src/libs"
|
||||
import {storedZip} from "./zip"
|
||||
|
||||
const LIB = "libtest.so"
|
||||
const LINUX_LIB = "libsimplex.so"
|
||||
const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR} = process.env
|
||||
|
||||
function setEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) delete process.env[name]
|
||||
else process.env[name] = value
|
||||
}
|
||||
|
||||
// A path resolution regression must neither download nor write into the real user cache.
|
||||
let home: string
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-"))
|
||||
jest.spyOn(os, "homedir").mockReturnValue(home)
|
||||
jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") })
|
||||
setEnv("XDG_CACHE_HOME", home)
|
||||
setEnv("LOCALAPPDATA", home)
|
||||
setEnv("SIMPLEX_LIBS_DIR", undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME)
|
||||
setEnv("LOCALAPPDATA", LOCALAPPDATA)
|
||||
setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR)
|
||||
fs.rmSync(home, {recursive: true, force: true})
|
||||
})
|
||||
|
||||
describe("paths", () => {
|
||||
let libsDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-"))
|
||||
fs.writeFileSync(libPath(libsDir), "lib")
|
||||
})
|
||||
afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true}))
|
||||
|
||||
it("uses the Python cache layout", () => {
|
||||
expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat"))
|
||||
expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat"))
|
||||
expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat"))
|
||||
expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat"))
|
||||
expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set")
|
||||
})
|
||||
|
||||
it.each([
|
||||
["linux", "x64", "linux-x86_64"],
|
||||
["linux", "arm64", "linux-aarch64"],
|
||||
["darwin", "x64", "macos-x86_64"],
|
||||
["darwin", "arm64", "macos-aarch64"],
|
||||
["win32", "x64", "windows-x86_64"],
|
||||
])("tags %s/%s as %s", (platform, arch, tag) => {
|
||||
expect(platformTag(platform, arch)).toBe(tag)
|
||||
})
|
||||
|
||||
it("rejects unsupported platforms", () => {
|
||||
expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform")
|
||||
})
|
||||
|
||||
it("rejects an unsupported OS with its arch", async () => {
|
||||
await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ")
|
||||
})
|
||||
|
||||
it("uses the release URLs", () => {
|
||||
const release = `https://github.com/simplex-chat/simplex-chat-libs/releases/download/v${LIBS_VERSION}`
|
||||
expect(libsUrl("sqlite", "macos-aarch64")).toBe(`${release}/simplex-chat-libs-macos-aarch64.zip`)
|
||||
expect(libsUrl("postgres", "linux-x86_64")).toBe(`${release}/simplex-chat-libs-linux-x86_64-postgres.zip`)
|
||||
})
|
||||
|
||||
it("resolves an absolute SIMPLEX_LIBS_DIR", async () => {
|
||||
await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir)
|
||||
})
|
||||
|
||||
it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => {
|
||||
fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
|
||||
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir)
|
||||
})
|
||||
|
||||
it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => {
|
||||
fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
|
||||
await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir)
|
||||
})
|
||||
|
||||
it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => {
|
||||
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["linux", "x64", "libsimplex.so"],
|
||||
["darwin", "arm64", "libsimplex.dylib"],
|
||||
["win32", "x64", "libsimplex.dll"],
|
||||
])("looks for the %s library name", async (platform, arch, lib) => {
|
||||
fs.rmSync(libPath(libsDir))
|
||||
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `)
|
||||
})
|
||||
|
||||
it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => {
|
||||
fs.rmSync(libPath(libsDir))
|
||||
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("installLibs", () => {
|
||||
let server: http.Server
|
||||
let base: string
|
||||
let tmp: string
|
||||
const routes: {[p: string]: (res: http.ServerResponse) => void} = {}
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const route = routes[req.url ?? ""]
|
||||
if (route) route(res)
|
||||
else res.writeHead(404).end()
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
})
|
||||
|
||||
afterAll(() => new Promise<void>(resolve => server.close(() => resolve())))
|
||||
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) })
|
||||
afterEach(() => fs.rmSync(tmp, {recursive: true, force: true}))
|
||||
|
||||
const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"})
|
||||
routes["/good.zip"] = res => res.writeHead(200).end(good)
|
||||
routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end()
|
||||
routes["/nolib.zip"] = res => res.writeHead(200).end(storedZip({"other/file": "x"}))
|
||||
routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) }
|
||||
let loopRequests = 0
|
||||
routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() }
|
||||
routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end()
|
||||
routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end()
|
||||
routes["/no-location.zip"] = res => res.writeHead(302).end()
|
||||
routes["/hang.zip"] = () => {}
|
||||
routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end()
|
||||
let downloadDirs: string[] = []
|
||||
routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) }
|
||||
|
||||
function leftovers(): string[] {
|
||||
return fs.readdirSync(tmp).filter(f => f.startsWith(".download-"))
|
||||
}
|
||||
|
||||
// Serves https requests from a local http route (the requested path by default), recording the requested URLs.
|
||||
function mockHttps(route?: string): string[] {
|
||||
const requested: string[] = []
|
||||
jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => {
|
||||
requested.push(url)
|
||||
return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb)
|
||||
}) as unknown as typeof https.get)
|
||||
return requested
|
||||
}
|
||||
|
||||
function renameFails(code: string): void {
|
||||
jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code}))
|
||||
}
|
||||
|
||||
beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) })
|
||||
|
||||
it("follows a redirect and installs libs/", async () => {
|
||||
const target = path.join(tmp, "v1", "sqlite")
|
||||
await installLibs(`${base}/redirect.zip`, target, LIB)
|
||||
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
|
||||
expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true)
|
||||
})
|
||||
|
||||
it("downloads into a temp dir next to the target", async () => {
|
||||
await installLibs(`${base}/observed.zip`, path.join(tmp, "sqlite"), LIB)
|
||||
expect(downloadDirs).toHaveLength(1)
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("stops following a redirect loop", async () => {
|
||||
loopRequests = 0
|
||||
await expect(installLibs(`${base}/loop.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects")
|
||||
expect(loopRequests).toBe(MAX_REDIRECTS + 1)
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("follows an https redirect to another host", async () => {
|
||||
const requested = mockHttps()
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await installLibs("https://libs.test/cross-host.zip", target, LIB)
|
||||
expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"])
|
||||
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
|
||||
})
|
||||
|
||||
it("rejects a redirect from https to http", async () => {
|
||||
mockHttps("/to-http.zip")
|
||||
await expect(installLibs("https://libs.test/libs.zip", path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects an invalid redirect location", async () => {
|
||||
await expect(installLibs(`${base}/bad-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects a redirect without a location", async () => {
|
||||
await expect(installLibs(`${base}/no-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects 404 and cleans up", async () => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await expect(installLibs(`${base}/missing.zip`, target, LIB)).rejects.toThrow("HTTP 404")
|
||||
expect(leftovers()).toEqual([])
|
||||
expect(fs.existsSync(target)).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects a zip without the lib", async () => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await expect(installLibs(`${base}/nolib.zip`, target, LIB)).rejects.toThrow(`libs/${LIB} missing`)
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("times out a request without a response", async () => {
|
||||
await expect(installLibs(`${base}/hang.zip`, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("times out a stalled download", async () => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await expect(installLibs(`${base}/stall.zip`, target, LIB, 200)).rejects.toThrow("timeout")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => {
|
||||
const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"})
|
||||
routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip)
|
||||
const requested = mockHttps("/libsimplex.zip")
|
||||
const cache = path.relative(process.cwd(), path.join(tmp, "cache"))
|
||||
const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64")
|
||||
expect(requested).toEqual([libsUrl(backend, "linux-x86_64")])
|
||||
expect(path.isAbsolute(dir)).toBe(true)
|
||||
expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", `v${LIBS_VERSION}`, backend))
|
||||
expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib")
|
||||
})
|
||||
|
||||
it("does not download a cached lib", async () => {
|
||||
const requested = mockHttps("/good.zip")
|
||||
const cached = path.join(tmp, "simplex-chat", `v${LIBS_VERSION}`, "postgres")
|
||||
fs.mkdirSync(cached, {recursive: true})
|
||||
fs.writeFileSync(path.join(cached, LINUX_LIB), "lib")
|
||||
await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached)
|
||||
expect(requested).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects a target populated without the lib", async () => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
fs.mkdirSync(target)
|
||||
fs.writeFileSync(path.join(target, "other"), "x")
|
||||
await expect(installLibs(`${base}/good.zip`, target, LIB)).rejects.toThrow(`another process partially populated ${target}`)
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => {
|
||||
const requested = mockHttps("/good.zip")
|
||||
await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64")
|
||||
expect(requested).toEqual([])
|
||||
})
|
||||
|
||||
it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
fs.mkdirSync(target)
|
||||
fs.writeFileSync(path.join(target, LIB), "lib")
|
||||
renameFails(code)
|
||||
await expect(installLibs(`${base}/good.zip`, target, LIB)).resolves.toBeUndefined()
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("rethrows EPERM from rename when the target does not exist", async () => {
|
||||
renameFails("EPERM")
|
||||
await expect(installLibs(`${base}/good.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("lets concurrent installs share one target", async () => {
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await Promise.all([
|
||||
installLibs(`${base}/good.zip`, target, LIB),
|
||||
installLibs(`${base}/good.zip`, target, LIB),
|
||||
])
|
||||
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("cli", () => {
|
||||
function installWithLibsDir(dir: string): Promise<number> {
|
||||
setEnv("SIMPLEX_LIBS_DIR", dir)
|
||||
return main(["install"])
|
||||
}
|
||||
|
||||
it("parses install arguments", () => {
|
||||
expect(parseInstallArgs(["install"])).toBe("sqlite")
|
||||
expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres")
|
||||
expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres")
|
||||
expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql")
|
||||
expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing")
|
||||
expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'")
|
||||
expect(() => parseInstallArgs(["run"])).toThrow("expected command: install")
|
||||
expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install")
|
||||
expect(() => parseInstallArgs([])).toThrow("expected command: install")
|
||||
})
|
||||
|
||||
it("prints the libs directory", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
|
||||
fs.writeFileSync(libPath(dir), "lib")
|
||||
const log = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
try {
|
||||
await expect(installWithLibsDir(dir)).resolves.toBe(0)
|
||||
expect(log).toHaveBeenCalledWith(`libsimplex installed at: ${dir}`)
|
||||
} finally {
|
||||
fs.rmSync(dir, {recursive: true, force: true})
|
||||
}
|
||||
})
|
||||
|
||||
it("returns 1 when install fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
|
||||
const error = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
try {
|
||||
await expect(installWithLibsDir(dir)).resolves.toBe(1)
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no"))
|
||||
} finally {
|
||||
fs.rmSync(dir, {recursive: true, force: true})
|
||||
}
|
||||
})
|
||||
|
||||
it("installs the requested backend", async () => {
|
||||
const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs")
|
||||
jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0)
|
||||
expect(resolve).toHaveBeenCalledWith("postgres")
|
||||
})
|
||||
|
||||
it.each(["--help", "-h"])("prints usage on %s", async flag => {
|
||||
const log = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
await expect(main(["install", flag])).resolves.toBe(0)
|
||||
expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]")
|
||||
})
|
||||
|
||||
it("returns 2 with usage on invalid arguments", async () => {
|
||||
const error = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2)
|
||||
expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as zlib from "zlib"
|
||||
|
||||
const LOCAL_FILE_HEADER = 0x04034b50
|
||||
const CENTRAL_DIRECTORY_HEADER = 0x02014b50
|
||||
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
|
||||
const ZIP_VERSION = 20
|
||||
const LOCAL_HEADER_SIZE = 30
|
||||
const CENTRAL_ENTRY_SIZE = 46
|
||||
const END_RECORD_SIZE = 22
|
||||
|
||||
export function storedZip(files: {[name: string]: string}): Buffer {
|
||||
const local: Buffer[] = []
|
||||
const central: Buffer[] = []
|
||||
let offset = 0
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const nameBuf = Buffer.from(name)
|
||||
const data = Buffer.from(content)
|
||||
const crc = zlib.crc32(data)
|
||||
const header = Buffer.alloc(LOCAL_HEADER_SIZE)
|
||||
header.writeUInt32LE(LOCAL_FILE_HEADER, 0)
|
||||
header.writeUInt16LE(ZIP_VERSION, 4)
|
||||
header.writeUInt32LE(crc, 14)
|
||||
header.writeUInt32LE(data.length, 18)
|
||||
header.writeUInt32LE(data.length, 22)
|
||||
header.writeUInt16LE(nameBuf.length, 26)
|
||||
local.push(header, nameBuf, data)
|
||||
const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE)
|
||||
entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0)
|
||||
entry.writeUInt16LE(ZIP_VERSION, 4)
|
||||
entry.writeUInt16LE(ZIP_VERSION, 6)
|
||||
entry.writeUInt32LE(crc, 16)
|
||||
entry.writeUInt32LE(data.length, 20)
|
||||
entry.writeUInt32LE(data.length, 24)
|
||||
entry.writeUInt16LE(nameBuf.length, 28)
|
||||
entry.writeUInt32LE(offset, 42)
|
||||
central.push(entry, nameBuf)
|
||||
offset += header.length + nameBuf.length + data.length
|
||||
}
|
||||
const centralBuf = Buffer.concat(central)
|
||||
const end = Buffer.alloc(END_RECORD_SIZE)
|
||||
end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0)
|
||||
end.writeUInt16LE(Object.keys(files).length, 8)
|
||||
end.writeUInt16LE(Object.keys(files).length, 10)
|
||||
end.writeUInt32LE(centralBuf.length, 12)
|
||||
end.writeUInt32LE(offset, 16)
|
||||
return Buffer.concat([...local, centralBuf, end])
|
||||
}
|
||||
Reference in New Issue
Block a user