#!/usr/bin/env node /** * Scaffold a suite app from the UltiMail pilot template. * * Usage: * node scripts/new-app.mjs * node scripts/new-app.mjs --all # (re)generate every sibling app * * Each app is a Tauri 2 project under apps//src-tauri that loads the same * shared static export at its own start route, with its own URL scheme + icons. */ import { mkdirSync, writeFileSync, copyFileSync, existsSync, readdirSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = join(__dirname, "..") /** dir is the folder under apps/; crate is the Rust crate name. */ // Schemes follow the `ulti` convention from lib/platform.ts (appScheme) // so inter-app deep links resolve consistently. const APPS = [ { dir: "ultimail", crate: "ultimail", product: "UltiMail", id: "space.ulti.mail", suite: "mail", route: "/mail/", scheme: "ultimail", linkPrefix: "/app/mail" }, { dir: "ultidrive", crate: "ultidrive", product: "UltiDrive", id: "space.ulti.drive", suite: "drive", route: "/drive/", scheme: "ultidrive", linkPrefix: "/app/drive" }, { dir: "ulticalmeet", crate: "ulticalmeet", product: "UltiCal", id: "space.ulti.cal", suite: "agenda", route: "/agenda/", scheme: "ultiagenda", linkPrefix: "/app/agenda" }, { dir: "ultiai", crate: "ultiai", product: "UltiAI", id: "space.ulti.ai", suite: "chat", route: "/chat/", scheme: "ultichat", linkPrefix: "/app/chat" }, { dir: "contacts", crate: "ulticontacts", product: "Contacts", id: "space.ulti.contacts", suite: "contacts", route: "/contacts/", scheme: "ulticontacts", linkPrefix: "/app/contacts" }, ] function cargoToml(a) { return `[package] name = "${a.crate}" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true description = "${a.product} — native shell (Tauri 2)" [lib] name = "${a.crate}_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { workspace = true } [dependencies] tauri = { workspace = true } tauri-plugin-deep-link = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-opener = { workspace = true } ulti-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } [features] default = [] ` } function buildRs() { return `fn main() { tauri_build::build() } ` } function mainRs(a) { return `// Prevents an extra console window on Windows in release. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { ${a.crate}_lib::run() } ` } function libRs(a) { return `//! ${a.product} native shell. Loads the shared static export at \`${a.route}\` //! and wires the suite's native capabilities through \`ulti-core\` + plugins. use tauri::Emitter; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(ulti_core::init()) .setup(|app| { #[cfg(any(target_os = "android", target_os = "ios", desktop))] { use tauri_plugin_deep_link::DeepLinkExt; let handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { let urls: Vec = event.urls().iter().map(|u| u.to_string()).collect(); let _ = handle.emit("ulti://deep-link", urls); }); } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running ${a.product}"); } ` } function tauriConf(a) { return JSON.stringify( { $schema: "https://schema.tauri.app/config/2", productName: a.product, version: "0.1.0", identifier: a.id, build: { frontendDist: "../../../../out", devUrl: "http://localhost:3005", beforeDevCommand: `bash "$ULTI_REPO_ROOT/mobile/scripts/dev-mobile-tauri.sh" ${a.suite}`, beforeBuildCommand: `bash "$ULTI_REPO_ROOT/mobile/scripts/build-mobile.sh" ${a.suite}`, }, app: { windows: [ { label: "main", title: a.product, url: a.route, width: 420, height: 900 }, ], security: { csp: null }, }, bundle: { active: true, targets: "all", category: "Productivity", icon: ["icons/icon.png"], }, plugins: { "deep-link": { mobile: [{ host: "space.ulti.app", pathPrefix: [a.linkPrefix] }], desktop: { schemes: [a.scheme] }, }, }, }, null, 2 ) + "\n" } function capabilities() { return JSON.stringify( { $schema: "../gen/schemas/desktop-schema.json", identifier: "default", description: "Default capabilities for the suite shell.", windows: ["main"], permissions: [ "core:default", "core:window:default", "core:event:default", "core:webview:default", "deep-link:default", "notification:default", "opener:default", "opener:allow-open-url", "ulti-core:default", ], }, null, 2 ) + "\n" } function write(path, contents) { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, contents) } function generate(a, { force = false } = {}) { const base = join(ROOT, "apps", a.dir, "src-tauri") if (a.dir === "ultimail" && !force) { console.log("skip ultimail (pilot template — use --force to overwrite)") return } write(join(base, "Cargo.toml"), cargoToml(a)) write(join(base, "build.rs"), buildRs()) write(join(base, "src", "main.rs"), mainRs(a)) write(join(base, "src", "lib.rs"), libRs(a)) write(join(base, "tauri.conf.json"), tauriConf(a)) write(join(base, "capabilities", "default.json"), capabilities()) // Icons: copy from the pilot if present. const pilotIcons = join(ROOT, "apps", "ultimail", "src-tauri", "icons") const iconsDir = join(base, "icons") mkdirSync(iconsDir, { recursive: true }) if (existsSync(pilotIcons)) { for (const f of readdirSync(pilotIcons)) { copyFileSync(join(pilotIcons, f), join(iconsDir, f)) } } console.log(`generated ${a.dir} (${a.product})`) } const arg = process.argv[2] if (arg === "--all") { for (const a of APPS) generate(a) } else if (arg) { const [crate, product, id, suite, route, scheme] = process.argv.slice(2) generate({ dir: crate, crate, product, id, suite, route, scheme, linkPrefix: `/app/${suite}`, }, { force: true }) } else { console.error("usage: new-app.mjs --all | ") process.exit(1) }