UNPKG

@owdproject/core

Version:

Nuxt Desktop module for browser desktop experiences (windows, shell, apps)

341 lines (298 loc) 9.87 kB
import { existsSync } from 'node:fs' import { join } from 'node:path' import { defineNuxtModule, installModule, type NuxtModule, } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' import { defu } from 'defu' /** Root `desktop.config.ts` helper — keep this file free of `import.meta` for jiti. */ export function defineDesktopConfig(config: DesktopConfig) { return config } /** * Runtime validation for the object exported from `desktop.config.ts` * (or legacy `owd.config.ts`). */ export function assertValidDesktopUserConfig( config: unknown, configFile = 'desktop.config.ts', ): void { if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error( `[@owdproject/core] ${configFile} must default-export a non-array object from defineDesktopConfig({ ... }).`, ) } const c = config as Record<string, unknown> if (c.theme != null && typeof c.theme !== 'string') { throw new Error( `[@owdproject/core] ${configFile}: \`theme\` must be a string (npm package name of the theme module).`, ) } if (c.apps !== undefined) { if ( !Array.isArray(c.apps) || !(c.apps as unknown[]).every((item) => typeof item === 'string') ) { throw new Error( `[@owdproject/core] ${configFile}: \`apps\` must be an array of strings (Nuxt module package names).`, ) } } if (c.modules !== undefined) { if ( !Array.isArray(c.modules) || !(c.modules as unknown[]).every((item) => typeof item === 'string') ) { throw new Error( `[@owdproject/core] ${configFile}: \`modules\` must be an array of strings (Nuxt module package names).`, ) } } } export const DESKTOP_CONFIG_FILENAME = 'desktop.config.ts' export const LEGACY_DESKTOP_CONFIG_FILENAME = 'owd.config.ts' export const LEGACY_CONFIG_DEPRECATION = '[@owdproject/core] owd.config.ts is deprecated and kept only for backward compatibility; rename to desktop.config.ts (required from @owdproject/core 3.2).' export type ResolvedDesktopConfigPath = { path: string file: string legacy: boolean } export function resolveDesktopConfigPath( rootDir: string, ): ResolvedDesktopConfigPath | null { const desktopPath = join(rootDir, DESKTOP_CONFIG_FILENAME) const legacyPath = join(rootDir, LEGACY_DESKTOP_CONFIG_FILENAME) const hasDesktop = existsSync(desktopPath) const hasLegacy = existsSync(legacyPath) if (hasDesktop && hasLegacy) { console.warn( '[@owdproject/core] Both desktop.config.ts and owd.config.ts exist; using desktop.config.ts. Remove owd.config.ts.', ) return { path: desktopPath, file: DESKTOP_CONFIG_FILENAME, legacy: false } } if (hasDesktop) { return { path: desktopPath, file: DESKTOP_CONFIG_FILENAME, legacy: false } } if (hasLegacy) { return { path: legacyPath, file: LEGACY_DESKTOP_CONFIG_FILENAME, legacy: true, } } return null } export function warnLegacyDesktopConfig( resolved: ResolvedDesktopConfigPath | null | undefined, ): void { if (resolved?.legacy) { console.warn(LEGACY_CONFIG_DEPRECATION) } } /** Path used when creating or updating config from the CLI (always the new filename). */ export function desktopConfigWritePath(desktopDir: string): string { return join(desktopDir, DESKTOP_CONFIG_FILENAME) } /** Nuxt top-level option names — warn if present in desktop.config (likely misconfiguration). */ const NUXT_OPTION_KEYS = new Set([ 'ssr', 'devtools', 'vite', 'nitro', 'runtimeConfig', 'app', 'appConfig', 'css', 'plugins', 'hooks', 'extends', 'compatibilityDate', 'experimental', 'future', 'i18n', 'tailwindcss', 'primevue', ]) /** * Warn when desktop.config.ts contains keys that look like Nuxt options. * Extension keys (fs, terminal, custom app namespaces) are intentionally not validated here. */ export function warnDesktopConfigKeys( config: Record<string, unknown>, configFile = 'desktop.config.ts', ): void { for (const key of Object.keys(config)) { if (NUXT_OPTION_KEYS.has(key)) { console.warn( `[@owdproject/core] ${configFile}: key "${key}" looks like a Nuxt option and is ignored for _nuxt.options. Move it to nuxt.config.ts; shell values belong under defineDesktopConfig({ systemBar, workspaces, ... }).`, ) } } } /** * Writes a resolved extension namespace to `runtimeConfig.public.desktop[configKey]`. * Used by {@link defineDesktopModule} after Nuxt merges inline options with module defaults. */ export function setDesktopExtensionConfig( nuxt: Nuxt, configKey: string, resolved: Record<string, unknown>, ): void { const pub = (nuxt.options.runtimeConfig.public ??= {}) as { desktop?: Record<string, unknown> } const desktop = (pub.desktop ??= {}) desktop[configKey] = resolved } /** * Merges theme or module defaults into an extension namespace on `public.desktop`. * User values from desktop.config win over `partial` (defu: partial fills undefined only). */ export function mergeDesktopExtensionConfig( nuxt: Nuxt, configKey: string, partial: Record<string, unknown>, ): void { const pub = (nuxt.options.runtimeConfig.public ??= {}) as { desktop?: Record<string, unknown> } const desktop = (pub.desktop ??= {}) const existing = desktop[configKey] const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? (existing as Record<string, unknown>) : {} desktop[configKey] = defu(partial, base) } type ModuleLike = { meta?: { configKey?: string } default?: { meta?: { configKey?: string } } } function readConfigKey(mod: ModuleLike | undefined): string | undefined { const key = mod?.meta?.configKey ?? mod?.default?.meta?.configKey return typeof key === 'string' && key.length > 0 ? key : undefined } async function loadModuleDescriptor( modulePath: string, ): Promise<ModuleLike | undefined> { try { const imported = await import(modulePath) return (imported as { default?: ModuleLike }).default ?? imported } catch (e: any) { console.warn(`[@owdproject/core] Failed to load module descriptor for "${modulePath}":`, e.message || e) return undefined } } /** * Installs a desktop theme, module, or app package. * When the package exposes `meta.configKey`, passes `desktop[configKey]` to `installModule` * so Nuxt merges desktop.config with module defaults. */ export async function installDesktopPackage( nuxt: Nuxt, modulePath: string, desktop?: Record<string, unknown>, ): Promise<void> { const descriptor = await loadModuleDescriptor(modulePath) const configKey = readConfigKey(descriptor) if (configKey && desktop) { const slice = desktop[configKey] const inline = slice && typeof slice === 'object' && !Array.isArray(slice) ? (slice as Record<string, unknown>) : {} await installModule(modulePath, inline) return } await installModule(modulePath) } type DesktopModuleMeta = { name: string configKey: string } type DesktopModuleDefinition = NuxtModule< Record<string, unknown>, Record<string, unknown>, false > & { meta: DesktopModuleMeta } /** * Nuxt module wrapper for desktop apps and extension modules (`configKey` → desktop.config namespace). * Publishes merged options to `runtimeConfig.public.desktop[configKey]` after setup runs. */ export function defineDesktopModule( definition: DesktopModuleDefinition, ): ReturnType<typeof defineNuxtModule> { const configKey = definition.meta.configKey if (!configKey) { throw new Error( '[@owdproject/core] defineDesktopModule requires meta.configKey (e.g. "terminal", "fs").', ) } const userSetup = definition.setup return defineNuxtModule({ ...definition, async setup(options, nuxt) { const pub = (nuxt.options.runtimeConfig.public ??= {}) as { desktop?: Record<string, unknown> } const desktop = (pub.desktop ??= {}) const userConfig = desktop[configKey] // Merge userConfig (wins) with options (defaults / inline) const merged = defu( userConfig && typeof userConfig === 'object' ? userConfig : {}, options as Record<string, unknown>, ) // Mutate options in place so userSetup receives the fully merged options Object.assign(options, merged) // Set/update the merged config on public.desktop desktop[configKey] = options if (typeof userSetup === 'function') { return userSetup(options, nuxt) } }, } as NuxtModule) } type DesktopThemeDefinition = NuxtModule< Record<string, unknown>, Record<string, unknown>, false > /** * Nuxt module wrapper for desktop themes. * Merges theme shell defaults into `runtimeConfig.public.desktop` (user desktop.config wins). * * Tailwind content for theme Vue templates is registered via * `@owdproject/kit-primevue/kit/registerTailwindPath` after installing kit-primevue. */ export function defineDesktopTheme( definition: DesktopThemeDefinition, ): ReturnType<typeof defineNuxtModule> { const userSetup = definition.setup return defineNuxtModule({ ...definition, meta: { ...definition.meta, configKey: 'desktop', }, async setup(options, nuxt) { const pub = (nuxt.options.runtimeConfig.public ??= {}) as { desktop?: Record<string, unknown> } // Automatically merge userConfig (pub.desktop) with theme defaults (options) pub.desktop = defu( (pub.desktop ?? {}) as Record<string, unknown>, options as Record<string, unknown>, ) // Mutate options in place so userSetup receives the fully merged options Object.assign(options, pub.desktop) if (typeof userSetup === 'function') { return userSetup(options, nuxt) } }, } as NuxtModule) }