@owdproject/core
Version:
Nuxt Desktop module for browser desktop experiences (windows, shell, apps)
341 lines (298 loc) • 9.87 kB
text/typescript
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)
}