import path, { dirname } from "node:path" import fs from "fs-extra" import { getCanonicalPlatformName, getTutanotaAppVersion, getValidArchitecture, runStep, writeFile } from "./buildUtils.js" import "zx/globals" import * as env from "./env.js" import { preludeEnvPlugin } from "./env.js" import { fileURLToPath } from "node:url" import * as LaunchHtml from "./LaunchHtml.js" import os from "node:os" import { domainConfigs } from "./DomainConfigs.js" import { rolldown } from "rolldown" import { resolveLibs } from "./RollupConfig.js" import { nodeGypPlugin } from "./nodeGypPlugin.js" import { napiPlugin } from "./napiPlugin.js" import { buildRuntimePackages } from "./packageBuilderFunctions.js" import { sh } from "./sh.js" import { copyCryptoPrimitiveCrateIntoWasmDir, WASM_PACK_OUT_DIR } from "./cryptoPrimitivesUtils.js" const buildSrc = dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(path.join(buildSrc, "..")) /** * @param stage * @param host * @param desktop * @param clean * @param networkDebugging * @param app {"mail"|"calendar"} * @returns {Promise} */ export async function runDevBuild({ stage, host, desktop, clean, networkDebugging, app }) { const isCalendarBuild = app === "calendar" const tsConfig = isCalendarBuild ? "tsconfig-calendar-app.json" : "tsconfig.json" const buildDir = isCalendarBuild ? "build-calendar-app" : "build" const liboqsIncludeDir = "libs/webassembly/include" console.log(`Building dev client stage: ${stage} host: ${host} app: ${app}`) if (clean) { await runStep("Clean", () => // parallelize rm Promise.all([ fs.emptyDir(buildDir), fs.rm(liboqsIncludeDir, { recursive: true, force: true }), fs.rm(WASM_PACK_OUT_DIR, { recursive: true, force: true }), ]), ) } await runStep("Packages", async () => { await buildRuntimePackages() }) const version = await getTutanotaAppVersion() await runStep("Types", async () => { await sh`npx tsc --project ${tsConfig} --incremental ${true} --noEmit true` }) /** * @param host {string|null} * @return {DomainConfigMap} */ function updateDomainConfigForHostname(host) { // Non-webapp builds default to local hostname, make sure we add a domain config for it and not fall back on generic whitelabel one if (host == null) { host = "http://" + os.hostname() + ":9000" } const url = new URL(host) const { protocol, hostname } = url const port = parseInt(url.port) // the URL object does not include the port if it is the schema's default const uri = port ? `${protocol}//${hostname}:${port}` : `${protocol}//${hostname}` return { ...domainConfigs, [url.hostname]: { firstPartyDomain: true, partneredDomainTransitionUrl: uri, apiUrl: uri, paymentUrl: `${uri}/braintree.html`, webauthnUrl: `${uri}/webauthn`, legacyWebauthnUrl: `${uri}/webauthn`, webauthnMobileUrl: `${uri}/webauthnmobile`, legacyWebauthnMobileUrl: `${uri}/webauthnmobile`, webauthnRpId: hostname, u2fAppId: `${uri}/u2f-appid.json`, giftCardBaseUrl: `${uri}/giftcard`, referralBaseUrl: `${uri}/signup`, websiteBaseUrl: "https://tuta.com", }, } } const extendedDomainConfigs = updateDomainConfigForHostname(host) await buildWebPart({ stage, host, version, domainConfigs: extendedDomainConfigs, networkDebugging, app }) if (desktop) { await buildDesktopPart({ version, networkDebugging, app }) } } /** * @param p {object} * @param p.stage {string} * @param p.host {string|null} * @param p.version {string} * @param p.domainConfigs {DomainConfigMap} * @param p.networkDebugging {boolean} * @param p.app {"mail"|"calendar"} * @return {Promise} */ async function buildWebPart({ stage, host, version, domainConfigs, networkDebugging, app }) { const isCalendarBuild = app === "calendar" const buildDir = isCalendarBuild ? "build-calendar-app" : "build" const resolvedBuildDir = path.resolve(buildDir) const entryFile = isCalendarBuild ? "src/calendar-app/calendar-app.ts" : "src/mail-app/app.ts" const workerFile = isCalendarBuild ? "src/calendar-app/workerUtils/worker/calendar-worker.ts" : "src/mail-app/workerUtils/worker/mail-worker.ts" // we know which wasm need to be included in the project, instead of running branches condition on each and every file of the project we do some // transformation AOT for our three files (currently only crypto-primitives but argon2 and liboqs will follow await copyCryptoPrimitiveCrateIntoWasmDir({ wasmOutputDir: resolvedBuildDir }) await runStep("Web: Rolldown", async () => { const { rollupWasmLoader } = await import("@tutao/tuta-wasm-loader") const bundle = await rolldown({ input: { app: entryFile, worker: workerFile, "pow-worker": "src/common/api/common/pow-worker.ts" }, define: { // Need it at least until inlining enums is supported LOAD_ASSERTIONS: "false", // see AppType in src/common/misc/ClientConstants.ts APP_TYPE: JSON.stringify(app === "calendar" ? "2" : "1"), }, external: "fs", // qrcode-svg tries to import it on save() plugins: [ resolveLibs(), rollupWasmLoader({ webassemblyLibraries: [ { name: "liboqs.wasm", command: "make -f Makefile_liboqs build", workingDir: "libs/webassembly/", outputPath: path.join(resolvedBuildDir, `liboqs.wasm`), }, { name: "argon2.wasm", command: "make -f Makefile_argon2 build", workingDir: "libs/webassembly/", outputPath: path.join(resolvedBuildDir, `argon2.wasm`), }, ], }), ], }) await bundle.write({ dir: `./${buildDir}/`, format: "esm", // Setting source map to inline for web part because source maps won't be loaded correctly on mobile because requests from dev tools are not // intercepted, so we can't serve the files. sourcemap: "inline", // overwrite the files rather than keeping all versions in the build folder chunkFileNames: "[name]-chunk.js", }) }) // Do assets last so that server that listens to index.html changes does not reload too early await runStep("Web: Assets", async () => { await prepareAssets(stage, host, version, domainConfigs, buildDir, networkDebugging) await fs.promises.writeFile( `${buildDir}/worker-bootstrap.js`, `import "./polyfill.js" import "./worker.js" `, ) }) } async function buildDesktopPart({ version, networkDebugging, app }) { const isCalendarBuild = app === "calendar" const buildDir = isCalendarBuild ? "build-calendar-app" : "build" await runStep("Desktop: Rolldown", async () => { const platform = getCanonicalPlatformName(process.platform) const architecture = getValidArchitecture(process.platform, process.arch) const bundle = await rolldown({ input: ["src/common/desktop/DesktopMain.ts", "src/common/desktop/sqlworker.ts"], platform: "node", external: [ "electron", "memcpy", // optional dep of oxmsg ], plugins: [ resolveLibs(), nodeGypPlugin({ rootDir: projectRoot, platform, architecture, nodeModule: "@signalapp/sqlcipher", // we build for Electron, but it uses NAPI so it's fine to build for node environment: "node", targetName: "node_sqlcipher", }), napiPlugin({ nodeModule: "@tutao/node-mimimi", platform, architecture, }), // the build script for simple-windows-notifications does not build anything on non-win32 so we get errors when trying to copy files platform === "win32" ? nodeGypPlugin({ rootDir: projectRoot, platform, architecture, nodeModule: "@indutny/simple-windows-notifications", environment: "node", targetName: "simple-windows-notifications", }) : undefined, preludeEnvPlugin(env.create({ staticUrl: null, version, mode: "Desktop", dist: false, domainConfigs, networkDebugging })), ], }) await bundle.write({ dir: `./${buildDir}/desktop`, format: "esm", sourcemap: true, // overwrite the files rather than keeping all versions in the build folder chunkFileNames: "[name]-chunk.js", }) }) await runStep("Desktop: assets", async () => { const desktopIconsPath = "./resources/desktop-icons" await fs.copy(desktopIconsPath, `./${buildDir}/desktop/resources/icons`, { overwrite: true }) await fs.move(`./${buildDir}/desktop/resources/icons/logo-solo-dev.png`, `./${buildDir}/desktop/resources/icons/logo-solo-red.png`, { overwrite: true }) await fs.move(`./${buildDir}/desktop/resources/icons/logo-solo-dev-small.png`, `./${buildDir}/desktop/resources/icons/logo-solo-red-small.png`, { overwrite: true, }) const templateGenerator = (await import("./electron-package-json-template.js")).default const packageJSON = await templateGenerator({ nameSuffix: "-debug", version, updateUrl: `http://localhost:9000/client/${buildDir}`, iconPath: path.join(desktopIconsPath, "logo-solo-red.png"), sign: false, architecture: "x64", }) const content = JSON.stringify(packageJSON, null, 2) await fs.createFile(`./${buildDir}/package.json`) await fs.writeFile(`./${buildDir}/package.json`, content, "utf-8") await fs.mkdir(`${buildDir}/desktop`, { recursive: true }) // The preload scripts are run as commonjs scripts and are a special environment so we just copy them directly. await fs.copyFile("src/common/desktop/preload.js", `${buildDir}/desktop/preload.js`) await fs.copyFile("src/common/desktop/preload-webdialog.js", `${buildDir}/desktop/preload-webdialog.js`) }) } const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = __dirname.split(path.sep).slice(0, -1).join(path.sep) async function createBootstrap(env, buildDir) { let jsFileName let htmlFileName switch (env.mode) { case "App": jsFileName = "index-app.js" htmlFileName = "index-app.html" break case "Browser": jsFileName = "index.js" htmlFileName = "index.html" break case "Desktop": jsFileName = "index-desktop.js" htmlFileName = "index-desktop.html" } const imports = [{ src: "polyfill.js" }, { src: jsFileName }] const template = `window.whitelabelCustomizations = null window.env = ${JSON.stringify(env, null, 2)} if (env.staticUrl == null && window.tutaoDefaultApiUrl) { // overriden by js dev server window.env.staticUrl = window.tutaoDefaultApiUrl } import('./app.js')` await writeFile(`./${buildDir}/${jsFileName}`, template) const html = await LaunchHtml.renderHtml(imports, env) await writeFile(`./${buildDir}/${htmlFileName}`, html) } function getStaticUrl(stage, mode, host) { if (stage === "local" && mode === "Browser") { // We would like to use web app build for both JS server and actual server. For that we should avoid hardcoding URL as server // might be running as one of testing HTTPS domains. So instead we override URL when the app is served from JS server // (see DevServer). // This is only relevant for browser environment. return null } else if (stage === "test") { return "https://app.test.tuta.com" } else if (stage === "prod") { return "https://app.tuta.com" } else if (stage === "local") { return "http://" + os.hostname() + ":9000" } else { // host return host } } /** * @param stage {string} * @param host {string|null} * @param version {string} * @param domainConfigs {DomainConfigMap} * @param buildDir {string} * @param networkDebugging {boolean} * @return {Promise} */ export async function prepareAssets(stage, host, version, domainConfigs, buildDir, networkDebugging) { await Promise.all([ await fs.emptyDir(path.join(root, `${buildDir}/images`)), fs.copy(path.join(root, "/resources/favicon"), path.join(root, `/${buildDir}/images`)), fs.copy(path.join(root, "/resources/images/"), path.join(root, `/${buildDir}/images`)), fs.copy(path.join(root, "/resources/pdf/"), path.join(root, `/${buildDir}/pdf`)), fs.copy(path.join(root, "/resources/desktop-icons"), path.join(root, `/${buildDir}/icons`)), fs.copy(path.join(root, "/resources/wordlibrary.json"), path.join(root, `${buildDir}/wordlibrary.json`)), fs.copy(path.join(root, "/src/braintree.html"), path.join(root, `${buildDir}/braintree.html`)), ]) // write empty file await fs.writeFile(`${buildDir}/polyfill.js`, "") /** @type {EnvMode[]} */ const modes = ["Browser", "App", "Desktop"] for (const mode of modes) { await createBootstrap(env.create({ staticUrl: getStaticUrl(stage, mode, host), version, mode, dist: false, domainConfigs, networkDebugging }), buildDir) } }