import { debug, warning } from "../../deno_ral/log.ts";
import { existsSync, safeRemoveSync } from "../../deno_ral/fs.ts";
import { basename, join, relative } from "../../deno_ral/path.ts";
import { expandPath, which } from "../../core/path.ts";
import { unzip } from "../../core/zip.ts";
import {
hasTexLive,
removePath,
texLiveContext,
texLiveInPath,
} from "../../command/render/latexmk/texlive.ts";
import { execProcess } from "../../core/process.ts";
import {
InstallableTool,
InstallContext,
kUpdatePath,
PackageInfo,
RemotePackageInfo,
ToolConfigurationState,
} from "../types.ts";
import { getLatestRelease } from "../github.ts";
import { hasTinyTex, tinyTexInstallDir } from "./tinytex-info.ts";
import { copyTo } from "../../core/copy.ts";
import { suggestUserBinPaths } from "../../core/path.ts";
import { ensureDirSync, walkSync } from "../../deno_ral/fs.ts";
import {
arch,
isLinux,
isMac,
isWindows,
os as platformOs,
} from "../../deno_ral/platform.ts";
const kDefaultRepos = [
"https://mirrors.rit.edu/CTAN/systems/texlive/tlnet/",
"https://ctan.math.illinois.edu/systems/texlive/tlnet/",
"https://mirror.las.iastate.edu/tex-archive/systems/texlive/tlnet/",
];
const kTinyTexRepo = "rstudio/tinytex-releases";
const kPackageMaximal = "TinyTeX";
const kVersionFileName = "version";
export const tinyTexInstallable: InstallableTool = {
name: "TinyTeX",
prereqs: [{
check: () => {
return isWritable("/usr/local/bin");
},
os: ["darwin"],
message: "The directory /usr/local/bin is not writable.",
}],
installed,
installDir,
binDir,
installedVersion,
verifyConfiguration,
latestRelease: remotePackageInfo,
preparePackage,
install,
afterInstall,
uninstall,
};
async function installed() {
const hasTiny = hasTinyTex();
if (hasTiny) {
return true;
} else {
if (await hasTexLive()) {
return await isTinyTex();
} else {
return false;
}
}
}
async function installDir() {
if (await installed()) {
return Promise.resolve(tinyTexInstallDir());
} else {
return Promise.resolve(undefined);
}
}
function verifyConfiguration(): Promise<ToolConfigurationState> {
return Promise.resolve({ status: "ok" });
}
async function binDir() {
if (await installed()) {
const installDir = tinyTexInstallDir();
if (installDir) {
return Promise.resolve(binFolder(installDir));
} else {
warning(
"Failed to resolve tinytex install directory even though it is installed.",
);
return Promise.resolve(undefined);
}
} else {
return Promise.resolve(undefined);
}
}
async function installedVersion() {
const installDir = tinyTexInstallDir();
if (installDir) {
const versionFile = join(installDir, kVersionFileName);
if (existsSync(versionFile)) {
return await Deno.readTextFile(versionFile);
} else {
return undefined;
}
} else {
return undefined;
}
}
function noteInstalledVersion(version: string) {
const installDir = tinyTexInstallDir();
if (installDir) {
const versionFile = join(installDir, kVersionFileName);
Deno.writeTextFileSync(
versionFile,
version,
);
}
}
async function preparePackage(
context: InstallContext,
): Promise<PackageInfo> {
const pkgInfo = await remotePackageInfo();
const version = pkgInfo.version;
const candidates = tinyTexPkgName(kPackageMaximal, version);
const result = tinyTexUrl(candidates, pkgInfo);
if (result) {
const filePath = join(context.workingDir, result.name);
await context.download(`TinyTex ${version}`, result.url, filePath);
return { filePath, version };
} else {
context.error(
`Couldn't determine what URL to use to download TinyTeX. ` +
`Tried: ${candidates.join(", ")}`,
);
return Promise.reject();
}
}
async function install(
pkgInfo: PackageInfo,
context: InstallContext,
) {
const installDir = tinyTexInstallDir();
if (installDir) {
const parentDir = join(installDir, "..");
const realParentDir = expandPath(parentDir);
const tinyTexDirName = isLinux ? ".TinyTeX" : "TinyTeX";
debug(`TinyTex Directory Information:`);
debug(`> installDir: ${installDir}`);
debug(`> parentDir: ${parentDir}`);
debug(`> realParentDir: ${realParentDir}`);
if (existsSync(realParentDir)) {
debug(`Unzipping file ${pkgInfo.filePath}`);
await context.withSpinner(
{ message: `Unzipping ${basename(pkgInfo.filePath)}` },
async () => {
await unzip(pkgInfo.filePath);
},
);
await context.withSpinner(
{ message: `Moving files` },
() => {
const from = join(context.workingDir, tinyTexDirName);
debug(`Moving files\n> from ${from}\n> to ${installDir}`);
copyTo(from, installDir);
if (isMac) {
for (const file of walkSync(from)) {
if (file.isFile) {
const relativePath = relative(from, file.path);
const destPath = join(installDir, relativePath);
const srcStat = Deno.statSync(file.path);
const destStat = Deno.statSync(destPath);
if (srcStat.mode !== null && srcStat.mode !== destStat.mode) {
Deno.chmodSync(destPath, srcStat.mode);
}
}
}
}
safeRemoveSync(from, { recursive: true });
noteInstalledVersion(pkgInfo.version);
return Promise.resolve();
},
);
context.props[kTlMgrKey] = isWindows
? join(binFolder(installDir), "tlmgr.bat")
: join(binFolder(installDir), "tlmgr");
return Promise.resolve();
} else {
context.error("Installation target directory doesn't exist");
return Promise.reject();
}
} else {
context.error("Unable to determine installation directory");
return Promise.reject();
}
}
function binFolder(installDir: string) {
const nixBinFolder = () => {
const oldBinFolder = join(
installDir,
"bin",
`${Deno.build.arch}-${platformOs}`,
);
if (existsSync(oldBinFolder)) {
return oldBinFolder;
} else {
return join(
installDir,
"bin",
`universal-${platformOs}`,
);
}
};
const winBinFolder = () => {
const oldBinFolder = join(
installDir,
"bin",
"win32",
);
if (existsSync(oldBinFolder)) {
return oldBinFolder;
} else {
return join(
installDir,
"bin",
"windows",
);
}
};
return isWindows ? winBinFolder() : nixBinFolder();
}
async function afterInstall(context: InstallContext) {
const tlmgrPath = context.props[kTlMgrKey] as string;
if (tlmgrPath) {
await context.withSpinner(
{ message: "Verifying tlgpg support" },
async () => {
if (["darwin", "windows"].includes(platformOs)) {
await exec(
tlmgrPath,
[
"-q",
"--repository",
"http://www.preining.info/tlgpg/",
"install",
"tlgpg",
],
);
}
},
);
await context.withSpinner(
{ message: "Configuring font paths" },
async () => {
await exec(
tlmgrPath,
[
"postaction",
"install",
"script",
"xetex",
],
);
},
);
let restartRequired = false;
const defaultRepo = await textLiveRepo();
await context.withSpinner(
{
message: `Setting default repository`,
doneMessage: `Default Repository: ${defaultRepo}`,
},
async () => {
await exec(
tlmgrPath,
["-q", "option", "repository", defaultRepo],
);
},
);
if (context.flags[kUpdatePath]) {
const message =
`Unable to determine a path to use when installing TeX Live.
To complete the installation, please run the following:
${tlmgrPath} option sys_bin <bin_dir_on_path>
${tlmgrPath} path add
This will instruct TeX Live to create symlinks that it needs in <bin_dir_on_path>.`;
const configureBinPath = async (path: string) => {
if (!isWindows) {
const expandedPath = expandPath(path);
ensureDirSync(expandedPath);
await exec(
tlmgrPath,
["option", "sys_bin", expandedPath],
);
return true;
} else {
return true;
}
};
const paths: string[] = [];
const envPath = Deno.env.get("QUARTO_TEXLIVE_BINPATH");
if (envPath) {
paths.push(envPath);
} else if (!isWindows) {
paths.push(...suggestUserBinPaths());
} else {
paths.push(tlmgrPath);
}
const binPathMessage = envPath
? `Setting TeXLive Binpath: ${envPath}`
: !isWindows
? `Updating Path (inspecting ${paths.length} possible paths)`
: "Updating Path";
await context.withSpinner(
{ message: binPathMessage },
async () => {
let result;
for (const path of paths) {
const pathConfigured = await configureBinPath(path);
if (pathConfigured) {
result = await exec(
tlmgrPath,
["path", "add"],
);
if (result.success) {
break;
}
}
}
if (result && !result.success) {
warning(message);
}
},
);
if (isWindows) {
const texLiveInstalled = await hasTexLive();
const texLivePath = await texLiveInPath();
restartRequired = restartRequired || !texLiveInstalled || !texLivePath;
}
}
return Promise.resolve(restartRequired);
} else {
context.error("Couldn't locate tlmgr after installation");
return Promise.reject();
}
}
async function uninstall(context: InstallContext) {
if (!isTinyTex()) {
context.error("Current LateX installation does not appear to be TinyTex");
return Promise.reject();
}
if (context.flags[kUpdatePath]) {
if (await texLiveInPath()) {
await context.withSpinner(
{ message: "Removing commands" },
async () => {
const texLive = await texLiveContext(true);
const result = await removePath(texLive);
if (!result.success) {
context.error("Failed to uninstall");
return Promise.reject();
}
},
);
}
}
await context.withSpinner(
{ message: "Removing directory" },
async () => {
const installDir = tinyTexInstallDir();
if (installDir) {
await Deno.remove(installDir, { recursive: true });
} else {
context.error("Couldn't find install directory");
return Promise.reject();
}
},
);
}
function exec(path: string, cmd: string[]) {
return execProcess({
cmd: path,
args: cmd,
stdout: "piped",
stderr: "piped",
});
}
const kTlMgrKey = "tlmgr";
async function textLiveRepo() {
let autoUrl;
try {
const url = "https://mirror.ctan.org/systems/texlive/tlnet";
const response = await fetch(url, { redirect: "follow" });
autoUrl = response.url;
} catch (_e) {}
if (!autoUrl) {
const randomInt = Math.floor(Math.random() * kDefaultRepos.length);
autoUrl = kDefaultRepos[randomInt];
}
return autoUrl;
}
export function tinyTexPkgName(
base?: string,
ver?: string,
options?: { os?: string; arch?: string },
): string[] {
const effectiveOs = options?.os ??
(isWindows ? "windows" : isLinux ? "linux" : "darwin");
const effectiveArch = options?.arch ?? arch;
base = base || "TinyTeX";
if (!ver) {
const ext = effectiveOs === "windows"
? "zip"
: effectiveOs === "linux"
? "tar.gz"
: "tgz";
return [`${base}.${ext}`];
}
const candidates: string[] = [];
if (effectiveOs === "windows") {
candidates.push(`${base}-windows-${ver}.exe`);
candidates.push(`${base}-${ver}.zip`);
} else if (effectiveOs === "linux") {
if (effectiveArch === "aarch64") {
candidates.push(`${base}-linux-arm64-${ver}.tar.xz`);
candidates.push(`${base}-arm64-${ver}.tar.gz`);
} else {
candidates.push(`${base}-linux-x86_64-${ver}.tar.xz`);
candidates.push(`${base}-${ver}.tar.gz`);
}
} else {
candidates.push(`${base}-darwin-${ver}.tar.xz`);
candidates.push(`${base}-${ver}.tgz`);
}
return candidates;
}
function tinyTexUrl(candidates: string[], remotePkgInfo: RemotePackageInfo) {
for (const pkg of candidates) {
const asset = remotePkgInfo.assets.find((asset) => asset.name === pkg);
if (asset) {
return { url: asset.url, name: pkg };
}
}
return undefined;
}
async function remotePackageInfo(): Promise<RemotePackageInfo> {
const githubRelease = await getLatestRelease(kTinyTexRepo);
return {
url: githubRelease.html_url,
version: githubRelease.tag_name,
assets: githubRelease.assets.map((asset) => {
return { name: asset.name, url: asset.browser_download_url };
}),
};
}
async function isWritable(path: string) {
const desc = { name: "write", path } as const;
const status = await Deno.permissions.query(desc);
return status.state === "granted";
}
async function isTinyTex() {
const root = await texLiveRoot();
if (root) {
if (root.match(/[/\\][Tt]iny[Tt]e[Xx][/\\]?/)) {
return true;
}
const cnfFile = join(root, "texmf-dist/web2c/fmtutil.cnf");
if (existsSync(cnfFile)) {
const cnfText = Deno.readTextFileSync(cnfFile);
const match = cnfText.match(/\W[.]?TinyTeX\W/);
if (match) {
return true;
}
}
return false;
}
return false;
}
async function texLiveRoot() {
const texLivePath = await which("tlmgr");
if (texLivePath) {
const realPath = await Deno.realPath(texLivePath);
if (isWindows) {
return join(realPath, "..", "..", "..");
} else {
const root = join(realPath, "..", "..", "..", "..");
const tlBin = join(root, "bin");
if (existsSync(tlBin)) {
return root;
} else {
return undefined;
}
}
} else {
const installDir = tinyTexInstallDir();
if (installDir && existsSync(installDir)) {
return installDir;
} else {
return undefined;
}
}
}