diff --git a/plugin/src/features/android/files/assets/images.ts b/plugin/src/android/files/assets.ts similarity index 52% rename from plugin/src/features/android/files/assets/images.ts rename to plugin/src/android/files/assets.ts index 73baba4..aa4efc5 100644 --- a/plugin/src/features/android/files/assets/images.ts +++ b/plugin/src/android/files/assets.ts @@ -2,8 +2,86 @@ import * as fs from 'fs' import * as path from 'path' import { vdConvert } from 'vd-tool' -import { MAX_IMAGE_SIZE_BYTES } from '../../../../constants' -import { logger } from '../../../../utils' +import { DEFAULT_ANDROID_USER_IMAGES_PATH, MAX_IMAGE_SIZE_BYTES } from '../../constants' +import type { AndroidWidgetConfig } from '../../types' +import { logger } from '../../utils/logger' + +export interface GenerateAndroidAssetsOptions { + platformProjectRoot: string + projectRoot: string + userImagesPath?: string + widgets: AndroidWidgetConfig[] +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Generates all asset files for Android widgets. + * + * This creates: + * - res/drawable/ directory structure + * - Copies user images to drawable resources as Android-compatible resources + * - Copies widget preview images to drawable resources + * - Validates image sizes for widget compatibility + * + * @returns Map of widgetId to preview image drawable resource name + */ +export async function generateAndroidAssets(options: GenerateAndroidAssetsOptions): Promise> { + const { platformProjectRoot, projectRoot, userImagesPath = DEFAULT_ANDROID_USER_IMAGES_PATH, widgets } = options + + // Create res/drawable directory + const drawablePath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'drawable') + logger.info(`Generating Android assets in ${drawablePath}`) + + // Collect asset paths from the user images directory (including subdirectories) + const assets: string[] = [] + const fullUserImagesPath = path.join(projectRoot, userImagesPath) + + function collectAssetsRecursively(dirPath: string, relativeBase: string) { + if (!fs.existsSync(dirPath) || !fs.lstatSync(dirPath).isDirectory()) { + return + } + + const files = fs.readdirSync(dirPath) + for (const file of files) { + const sourcePath = path.join(dirPath, file) + const stat = fs.lstatSync(sourcePath) + + if (stat.isDirectory()) { + // Recursively collect from subdirectories + collectAssetsRecursively(sourcePath, relativeBase) + } else { + // Add relative path to assets array + const relativePath = path.relative(projectRoot, sourcePath) + assets.push(relativePath) + } + } + } + + collectAssetsRecursively(fullUserImagesPath, userImagesPath) + + // Copy user images to drawable resources + const copiedImages = await copyUserImagesToAndroid(assets, projectRoot, drawablePath) + + if (copiedImages.length > 0) { + logger.info(`Copied ${copiedImages.length} user image(s) to Android drawable resources`) + } + + // Copy preview images to drawable resources + const previewImageMap = await copyPreviewImagesToAndroid(widgets, projectRoot, drawablePath) + + if (previewImageMap.size > 0) { + logger.info(`Copied ${previewImageMap.size} preview image(s) to Android drawable resources`) + } + + return previewImageMap +} + +// ============================================================================ +// Image Handling +// ============================================================================ // Android supports these image/drawable extensions const VALID_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.svg'] @@ -15,7 +93,7 @@ const VALID_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.sv * @param imagePath - Path to the image file * @returns true if the image is within the size limit, false otherwise */ -export function checkImageSize(imagePath: string): boolean { +function checkImageSize(imagePath: string): boolean { const stats = fs.statSync(imagePath) const imageSizeInBytes = stats.size @@ -110,11 +188,7 @@ function processBitmapImage(sourcePath: string, destinationPath: string): void { * @param drawablePath - Path to the res/drawable directory in the Android project * @returns Array of drawable resource names that were copied */ -export async function copyUserImagesToAndroid( - assets: string[], - projectRoot: string, - drawablePath: string -): Promise { +async function copyUserImagesToAndroid(assets: string[], projectRoot: string, drawablePath: string): Promise { const copiedImages: string[] = [] // Ensure drawable directory exists @@ -158,3 +232,56 @@ export async function copyUserImagesToAndroid( return copiedImages } + +// ============================================================================ +// Preview Images +// ============================================================================ + +/** + * Copies preview images to Android drawable resources. + * Returns a map of widgetId to drawable resource name. + */ +async function copyPreviewImagesToAndroid( + widgets: AndroidWidgetConfig[], + projectRoot: string, + drawablePath: string +): Promise> { + const previewImageMap = new Map() + + // Ensure drawable directory exists + if (!fs.existsSync(drawablePath)) { + fs.mkdirSync(drawablePath, { recursive: true }) + } + + for (const widget of widgets) { + if (!widget.previewImage) { + continue + } + + const sourcePath = path.join(projectRoot, widget.previewImage) + + // Validate file exists + if (!fs.existsSync(sourcePath)) { + logger.warn(`Preview image not found for widget '${widget.id}' at ${widget.previewImage}`) + continue + } + + // Check image size + checkImageSize(sourcePath) + + // Generate drawable resource name + const ext = path.extname(widget.previewImage).toLowerCase() + const drawableName = `voltra_widget_${widget.id}_preview` + const destinationPath = path.join(drawablePath, `${drawableName}${ext}`) + + // Copy file + fs.copyFileSync(sourcePath, destinationPath) + logger.info(`Copied preview image for widget '${widget.id}' to ${drawableName}${ext}`) + + previewImageMap.set(widget.id, drawableName) + } + + return previewImageMap +} + +export { copyPreviewImagesToAndroid } diff --git a/plugin/src/features/android/files/index.ts b/plugin/src/android/files/index.ts similarity index 63% rename from plugin/src/features/android/files/index.ts rename to plugin/src/android/files/index.ts index c81403b..53e106e 100644 --- a/plugin/src/features/android/files/index.ts +++ b/plugin/src/android/files/index.ts @@ -1,10 +1,10 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins' -import type { AndroidWidgetConfig } from '../../../types' +import type { AndroidWidgetConfig } from '../../types' import { generateAndroidAssets } from './assets' -import { generateInitialStates } from './initialStates' -import { generateKotlinFiles } from './kotlin' -import { generateXmlFiles } from './xml' +import { generateAndroidInitialStates } from './initialStates' +import { generateWidgetReceivers } from './kotlin' +import { generateWidgetInfoFiles, generateWidgetPlaceholderLayouts, generateWidgetPreviewLayouts } from './xml' export interface GenerateAndroidWidgetFilesProps { widgets: AndroidWidgetConfig[] @@ -29,8 +29,18 @@ export const generateAndroidWidgetFiles: ConfigPlugin { + if (config.modRequest.introspect) { + return config + } + const { platformProjectRoot, projectRoot } = config.modRequest - const packageName = config.android?.package || 'com.example.app' + const packageName = config.android?.package + + if (!packageName) { + throw new Error( + 'Voltra config plugin requires expo.android.package to be set in app.json/app.config.* to configure Android widgets.' + ) + } // Generate assets (drawable images and preview images) const previewImageMap = await generateAndroidAssets({ @@ -41,14 +51,23 @@ export const generateAndroidWidgetFiles: ConfigPlugin { +export async function generateAndroidInitialStates(options: GenerateInitialStatesOptions): Promise { const { widgets, projectRoot, platformProjectRoot } = options // Dynamic import for ESM module compatibility // voltra/android/server is an ESM module, but the plugin is compiled to CommonJS const { renderAndroidWidgetToString } = await import('voltra/android/server') - // Prerender widget states const prerenderedStates = await prerenderWidgetState(widgets, projectRoot, renderAndroidWidgetToString) diff --git a/plugin/src/android/files/kotlin.ts b/plugin/src/android/files/kotlin.ts new file mode 100644 index 0000000..0f410c4 --- /dev/null +++ b/plugin/src/android/files/kotlin.ts @@ -0,0 +1,66 @@ +import dedent from 'dedent' +import * as fs from 'fs' +import * as path from 'path' + +import type { AndroidWidgetConfig } from '../../types' + +export interface GenerateKotlinFilesProps { + platformProjectRoot: string + packageName: string + widgets: AndroidWidgetConfig[] +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Generates Kotlin receiver classes for all configured widgets + */ +export async function generateWidgetReceivers(props: GenerateKotlinFilesProps): Promise { + const { platformProjectRoot, packageName, widgets } = props + + // Determine the package path (e.g., com.example.app -> com/example/app) + const packagePath = packageName.replace(/\./g, '/') + const widgetDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'java', packagePath, 'widget') + + // Ensure the widget directory exists + if (!fs.existsSync(widgetDir)) { + fs.mkdirSync(widgetDir, { recursive: true }) + } + + // Generate a receiver class for each widget + for (const widget of widgets) { + const className = `VoltraWidget_${widget.id}Receiver` + const filePath = path.join(widgetDir, `${className}.kt`) + const content = generateWidgetReceiverClass(widget, packageName) + + fs.writeFileSync(filePath, content, 'utf8') + } +} + +// ============================================================================ +// Widget Receiver +// ============================================================================ + +/** + * Generates Kotlin code for a single widget receiver class + */ +function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: string): string { + // Sanitize the widget id for use as a Kotlin class name + const className = `VoltraWidget_${widget.id}Receiver` + + return dedent` + package ${packageName}.widget + + import voltra.widget.VoltraWidgetReceiver + + /** + * Auto-generated widget receiver for ${widget.displayName} + * Widget ID: ${widget.id} + */ + class ${className} : VoltraWidgetReceiver() { + override val widgetId: String = "${widget.id}" + } + ` +} diff --git a/plugin/src/android/files/xml.ts b/plugin/src/android/files/xml.ts new file mode 100644 index 0000000..d2a41c7 --- /dev/null +++ b/plugin/src/android/files/xml.ts @@ -0,0 +1,270 @@ +import dedent from 'dedent' +import * as fs from 'fs' +import * as path from 'path' + +import type { AndroidWidgetConfig } from '../../types' +import { logger } from '../../utils/logger' + +export interface GenerateXmlFilesProps { + platformProjectRoot: string + projectRoot: string + widgets: AndroidWidgetConfig[] + previewImageMap: Map +} + +// ============================================================================ +// Main Functions +// ============================================================================ + +/** + * Generates widget info XML files for all widgets + */ +export async function generateWidgetInfoFiles(props: { + platformProjectRoot: string + widgets: AndroidWidgetConfig[] +}): Promise { + const { platformProjectRoot, widgets } = props + const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml') + const valuesPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'values') + + // Ensure directories exist + if (!fs.existsSync(xmlPath)) { + fs.mkdirSync(xmlPath, { recursive: true }) + } + if (!fs.existsSync(valuesPath)) { + fs.mkdirSync(valuesPath, { recursive: true }) + } + + // Generate widget info XML for each widget (without preview info for now) + for (const widget of widgets) { + const widgetInfoPath = path.join(xmlPath, `voltra_widget_${widget.id}_info.xml`) + const widgetInfoContent = generateWidgetInfoXml(widget, undefined, undefined) + fs.writeFileSync(widgetInfoPath, widgetInfoContent, 'utf8') + } + + // Generate string resources for all widgets + const stringsPath = path.join(valuesPath, 'voltra_widgets.xml') + const stringsContent = generateStringResourcesXml(widgets) + fs.writeFileSync(stringsPath, stringsContent, 'utf8') +} + +/** + * Generates placeholder layout XML + */ +export async function generateWidgetPlaceholderLayouts(props: { platformProjectRoot: string }): Promise { + const layoutPath = path.join(props.platformProjectRoot, 'app', 'src', 'main', 'res', 'layout') + + if (!fs.existsSync(layoutPath)) { + fs.mkdirSync(layoutPath, { recursive: true }) + } + + const placeholderLayoutPath = path.join(layoutPath, 'voltra_widget_placeholder.xml') + const placeholderLayoutContent = generatePlaceholderLayoutXml() + fs.writeFileSync(placeholderLayoutPath, placeholderLayoutContent, 'utf8') +} + +/** + * Generates preview layouts for widgets + */ +export async function generateWidgetPreviewLayouts(props: GenerateXmlFilesProps): Promise { + const { platformProjectRoot, projectRoot, widgets, previewImageMap } = props + const layoutPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'layout') + const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml') + + if (!fs.existsSync(layoutPath)) { + fs.mkdirSync(layoutPath, { recursive: true }) + } + + // Generate preview layouts and get the map + const previewLayoutMap = await generatePreviewLayouts(widgets, projectRoot, layoutPath, previewImageMap) + + // Update widget info files with preview layout references + for (const widget of widgets) { + if (previewLayoutMap.has(widget.id) || previewImageMap.has(widget.id)) { + const widgetInfoPath = path.join(xmlPath, `voltra_widget_${widget.id}_info.xml`) + const previewImageResourceName = previewImageMap.get(widget.id) + const previewLayoutResourceName = previewLayoutMap.get(widget.id) + const widgetInfoContent = generateWidgetInfoXml(widget, previewImageResourceName, previewLayoutResourceName) + fs.writeFileSync(widgetInfoPath, widgetInfoContent, 'utf8') + } + } +} + +// ============================================================================ +// Widget Info XML +// ============================================================================ + +/** + * Generates widget provider info XML for a single widget + */ +function generateWidgetInfoXml( + widget: AndroidWidgetConfig, + previewImageResourceName?: string, + previewLayoutResourceName?: string +): string { + const { targetCellWidth, targetCellHeight } = widget + const resizeMode = widget.resizeMode || 'horizontal|vertical' + const widgetCategory = widget.widgetCategory || 'home_screen' + + let minWidth = widget.minWidth + if (minWidth === undefined && widget.minCellWidth !== undefined) { + minWidth = widget.minCellWidth * 70 - 30 + } + + let minHeight = widget.minHeight + if (minHeight === undefined && widget.minCellHeight !== undefined) { + minHeight = widget.minCellHeight * 70 - 30 + } + + const minWidthAttr = minWidth !== undefined ? `\n android:minWidth="${minWidth}dp"` : '' + const minHeightAttr = minHeight !== undefined ? `\n android:minHeight="${minHeight}dp"` : '' + const previewImageAttr = previewImageResourceName + ? `\n android:previewImage="@drawable/${previewImageResourceName}"` + : '' + const previewLayoutAttr = previewLayoutResourceName + ? `\n android:previewLayout="@layout/${previewLayoutResourceName}"` + : '' + + return dedent` + + + + ` +} + +// ============================================================================ +// String Resources XML +// ============================================================================ + +/** + * Generates string resources for widget display names and descriptions + */ +function generateStringResourcesXml(widgets: AndroidWidgetConfig[]): string { + const stringEntries = widgets + .map( + (widget) => + `${widget.displayName}\n ${widget.description}` + ) + .join('\n ') + + return dedent` + + + + ${stringEntries} + + ` +} + +// ============================================================================ +// Placeholder Layout XML +// ============================================================================ + +/** + * Generates placeholder layout XML for widgets + * This will be replaced by Glance at runtime + */ +function generatePlaceholderLayoutXml(): string { + return dedent` + + + + + ` +} + +// ============================================================================ +// Preview Layout +// ============================================================================ + +/** + * Generates an auto-layout XML for image-based preview. + * This is used when previewImage is provided but no custom previewLayout. + */ +function generateAutoImagePreviewLayout(widgetId: string, drawableResourceName: string): string { + return dedent` + + + + + ` +} + +/** + * Generates preview layout XML files for all widgets. + * Returns a map of widgetId to layout resource name. + * + * Strategy: + * - If previewLayout is provided: copy user's custom XML + * - Else if previewImage is provided: generate auto-layout with ImageView + * - Otherwise: no preview layout generated + */ +async function generatePreviewLayouts( + widgets: AndroidWidgetConfig[], + projectRoot: string, + layoutPath: string, + previewImageMap: Map +): Promise> { + const previewLayoutMap = new Map() + + // Ensure layout directory exists + if (!fs.existsSync(layoutPath)) { + fs.mkdirSync(layoutPath, { recursive: true }) + } + + for (const widget of widgets) { + let layoutContent: string | null = null + const layoutResourceName = `voltra_widget_${widget.id}_preview` + const layoutFilePath = path.join(layoutPath, `${layoutResourceName}.xml`) + + // Strategy 1: User provided custom preview layout + if (widget.previewLayout) { + const sourcePath = path.join(projectRoot, widget.previewLayout) + + if (!fs.existsSync(sourcePath)) { + logger.warn(`Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}`) + continue + } + + // Copy user's custom XML + layoutContent = fs.readFileSync(sourcePath, 'utf8') + logger.info(`Using custom preview layout for widget '${widget.id}'`) + } + // Strategy 2: Auto-generate layout from preview image + else if (widget.previewImage && previewImageMap.has(widget.id)) { + const drawableResourceName = previewImageMap.get(widget.id)! + layoutContent = generateAutoImagePreviewLayout(widget.id, drawableResourceName) + logger.info(`Generated auto preview layout for widget '${widget.id}' from preview image`) + } + + // Write layout file if we have content + if (layoutContent) { + fs.writeFileSync(layoutFilePath, layoutContent, 'utf8') + previewLayoutMap.set(widget.id, layoutResourceName) + } + } + + return previewLayoutMap +} diff --git a/plugin/src/features/android/index.ts b/plugin/src/android/index.ts similarity index 79% rename from plugin/src/features/android/index.ts rename to plugin/src/android/index.ts index 0f1cc0d..c01af96 100644 --- a/plugin/src/features/android/index.ts +++ b/plugin/src/android/index.ts @@ -1,7 +1,7 @@ import { ConfigPlugin, withPlugins } from '@expo/config-plugins' -import type { AndroidPluginProps } from '../../types' -import { validateAndroidWidgetConfig } from '../../validation/validateAndroidWidget' +import type { AndroidPluginProps } from '../types' +import { validateAndroidWidgetConfig } from '../validation' import { generateAndroidWidgetFiles } from './files' import { configureAndroidManifest } from './manifest' @@ -16,6 +16,12 @@ import { configureAndroidManifest } from './manifest' export const withAndroid: ConfigPlugin = (config, props) => { const { widgets, userImagesPath } = props + if (!config.android?.package) { + throw new Error( + 'Voltra config plugin requires expo.android.package to be set in app.json/app.config.* to configure Android widgets.' + ) + } + // Get project root from modRequest if available, otherwise validation will skip file checks const projectRoot = (config as any).modRequest?.projectRoot diff --git a/plugin/src/features/android/manifest/index.ts b/plugin/src/android/manifest.ts similarity index 97% rename from plugin/src/features/android/manifest/index.ts rename to plugin/src/android/manifest.ts index 8dc9006..ec0b271 100644 --- a/plugin/src/features/android/manifest/index.ts +++ b/plugin/src/android/manifest.ts @@ -1,7 +1,7 @@ import { ConfigPlugin, withAndroidManifest } from '@expo/config-plugins' import { AndroidConfig } from 'expo/config-plugins' -import type { AndroidWidgetConfig } from '../../../types' +import type { AndroidWidgetConfig } from '../types' export interface ConfigureAndroidManifestProps { widgets: AndroidWidgetConfig[] diff --git a/plugin/src/constants.ts b/plugin/src/constants.ts new file mode 100644 index 0000000..b28ec38 --- /dev/null +++ b/plugin/src/constants.ts @@ -0,0 +1,60 @@ +import type { WidgetFamily } from './types' + +/** + * Constants for the Voltra plugin + */ + +// ============================================================================ +// iOS Constants +// ============================================================================ + +export const IOS = { + /** Minimum iOS deployment target version */ + DEPLOYMENT_TARGET: '17.0', + + /** Swift language version */ + SWIFT_VERSION: '5.0', + + /** Target device families (1 = iPhone, 2 = iPad) */ + DEVICE_FAMILY: '1,2', + + /** Last Swift migration version for Xcode */ + LAST_SWIFT_MIGRATION: 1250, +} as const + +// ============================================================================ +// Path Constants +// ============================================================================ + +/** Default path for user-provided widget images */ +export const DEFAULT_USER_IMAGES_PATH = './assets/voltra' + +/** Default path for user-provided Android widget images */ +export const DEFAULT_ANDROID_USER_IMAGES_PATH = './assets/voltra-android' + +// ============================================================================ +// Widget Constants +// ============================================================================ + +/** Maximum image size in bytes for Live Activities (4KB limit) */ +export const MAX_IMAGE_SIZE_BYTES = 4096 + +/** Supported image extensions for widget assets */ +export const SUPPORTED_IMAGE_EXTENSIONS = /\.(png|jpg|jpeg)$/i + +/** Default widget families when not specified */ +export const DEFAULT_WIDGET_FAMILIES: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge'] + +/** Maps JS widget family names to SwiftUI WidgetFamily enum cases */ +export const WIDGET_FAMILY_MAP: Record = { + systemSmall: '.systemSmall', + systemMedium: '.systemMedium', + systemLarge: '.systemLarge', + systemExtraLarge: '.systemExtraLarge', + accessoryCircular: '.accessoryCircular', + accessoryRectangular: '.accessoryRectangular', + accessoryInline: '.accessoryInline', +} + +/** Extensions to try when resolving module paths for pre-rendering */ +export const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', ''] diff --git a/plugin/src/constants/index.ts b/plugin/src/constants/index.ts deleted file mode 100644 index e94d0e4..0000000 --- a/plugin/src/constants/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Re-exports all constants - */ - -export { IOS } from './ios' -export { DEFAULT_ANDROID_USER_IMAGES_PATH, DEFAULT_USER_IMAGES_PATH } from './paths' -export { - DEFAULT_WIDGET_FAMILIES, - MAX_IMAGE_SIZE_BYTES, - MODULE_EXTENSIONS, - SUPPORTED_IMAGE_EXTENSIONS, - WIDGET_FAMILY_MAP, -} from './widgets' diff --git a/plugin/src/constants/ios.ts b/plugin/src/constants/ios.ts deleted file mode 100644 index 926a7fa..0000000 --- a/plugin/src/constants/ios.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * iOS-related constants for the Voltra plugin - */ - -export const IOS = { - /** Minimum iOS deployment target version */ - DEPLOYMENT_TARGET: '17.0', - - /** Swift language version */ - SWIFT_VERSION: '5.0', - - /** Target device families (1 = iPhone, 2 = iPad) */ - DEVICE_FAMILY: '1,2', - - /** Last Swift migration version for Xcode */ - LAST_SWIFT_MIGRATION: 1250, -} as const diff --git a/plugin/src/constants/paths.ts b/plugin/src/constants/paths.ts deleted file mode 100644 index aef06d4..0000000 --- a/plugin/src/constants/paths.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Path-related constants for the Voltra plugin - */ - -/** Default path for user-provided widget images */ -export const DEFAULT_USER_IMAGES_PATH = './assets/voltra' - -/** Default path for user-provided Android widget images */ -export const DEFAULT_ANDROID_USER_IMAGES_PATH = './assets/voltra-android' diff --git a/plugin/src/constants/widgets.ts b/plugin/src/constants/widgets.ts deleted file mode 100644 index 35f4b60..0000000 --- a/plugin/src/constants/widgets.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { WidgetFamily } from '../types' - -/** - * Widget-related constants for the Voltra plugin - */ - -/** Maximum image size in bytes for Live Activities (4KB limit) */ -export const MAX_IMAGE_SIZE_BYTES = 4096 - -/** Supported image extensions for widget assets */ -export const SUPPORTED_IMAGE_EXTENSIONS = /\.(png|jpg|jpeg)$/i - -/** Default widget families when not specified */ -export const DEFAULT_WIDGET_FAMILIES: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge'] - -/** Maps JS widget family names to SwiftUI WidgetFamily enum cases */ -export const WIDGET_FAMILY_MAP: Record = { - systemSmall: '.systemSmall', - systemMedium: '.systemMedium', - systemLarge: '.systemLarge', - systemExtraLarge: '.systemExtraLarge', - accessoryCircular: '.accessoryCircular', - accessoryRectangular: '.accessoryRectangular', - accessoryInline: '.accessoryInline', -} - -/** Extensions to try when resolving module paths for pre-rendering */ -export const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', ''] diff --git a/plugin/src/features/android/files/assets/index.ts b/plugin/src/features/android/files/assets/index.ts deleted file mode 100644 index c31c252..0000000 --- a/plugin/src/features/android/files/assets/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import { DEFAULT_ANDROID_USER_IMAGES_PATH } from '../../../../constants' -import type { AndroidWidgetConfig } from '../../../../types' -import { logger } from '../../../../utils' -import { copyUserImagesToAndroid } from './images' -import { copyPreviewImagesToAndroid } from './preview' - -export interface GenerateAndroidAssetsOptions { - platformProjectRoot: string - projectRoot: string - userImagesPath?: string - widgets: AndroidWidgetConfig[] -} - -export { copyPreviewImagesToAndroid } - -/** - * Generates all asset files for Android widgets. - * - * This creates: - * - res/drawable/ directory structure - * - Copies user images to drawable resources as Android-compatible resources - * - Copies widget preview images to drawable resources - * - Validates image sizes for widget compatibility - * - * @returns Map of widgetId to preview image drawable resource name - */ -export async function generateAndroidAssets(options: GenerateAndroidAssetsOptions): Promise> { - const { platformProjectRoot, projectRoot, userImagesPath = DEFAULT_ANDROID_USER_IMAGES_PATH, widgets } = options - - // Create res/drawable directory - const drawablePath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'drawable') - logger.info(`Generating Android assets in ${drawablePath}`) - - // Collect asset paths from the user images directory (including subdirectories) - const assets: string[] = [] - const fullUserImagesPath = path.join(projectRoot, userImagesPath) - - function collectAssetsRecursively(dirPath: string, relativeBase: string) { - if (!fs.existsSync(dirPath) || !fs.lstatSync(dirPath).isDirectory()) { - return - } - - const files = fs.readdirSync(dirPath) - for (const file of files) { - const sourcePath = path.join(dirPath, file) - const stat = fs.lstatSync(sourcePath) - - if (stat.isDirectory()) { - // Recursively collect from subdirectories - collectAssetsRecursively(sourcePath, relativeBase) - } else { - // Add relative path to assets array - const relativePath = path.relative(projectRoot, sourcePath) - assets.push(relativePath) - } - } - } - - collectAssetsRecursively(fullUserImagesPath, userImagesPath) - - // Copy user images to drawable resources - const copiedImages = await copyUserImagesToAndroid(assets, projectRoot, drawablePath) - - if (copiedImages.length > 0) { - logger.info(`Copied ${copiedImages.length} user image(s) to Android drawable resources`) - } - - // Copy preview images to drawable resources - const previewImageMap = await copyPreviewImagesToAndroid(widgets, projectRoot, drawablePath) - - if (previewImageMap.size > 0) { - logger.info(`Copied ${previewImageMap.size} preview image(s) to Android drawable resources`) - } - - return previewImageMap -} diff --git a/plugin/src/features/android/files/assets/preview.ts b/plugin/src/features/android/files/assets/preview.ts deleted file mode 100644 index b5b195a..0000000 --- a/plugin/src/features/android/files/assets/preview.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import type { AndroidWidgetConfig } from '../../../../types' -import { logger } from '../../../../utils' -import { checkImageSize } from './images' - -/** - * Copies preview images to Android drawable resources. - * Returns a map of widgetId to drawable resource name. - */ -export async function copyPreviewImagesToAndroid( - widgets: AndroidWidgetConfig[], - projectRoot: string, - drawablePath: string -): Promise> { - const previewImageMap = new Map() - - // Ensure drawable directory exists - if (!fs.existsSync(drawablePath)) { - fs.mkdirSync(drawablePath, { recursive: true }) - } - - for (const widget of widgets) { - if (!widget.previewImage) { - continue - } - - const sourcePath = path.join(projectRoot, widget.previewImage) - - // Validate file exists - if (!fs.existsSync(sourcePath)) { - logger.warn(`Preview image not found for widget '${widget.id}' at ${widget.previewImage}`) - continue - } - - // Check image size - checkImageSize(sourcePath) - - // Generate drawable resource name - const ext = path.extname(widget.previewImage).toLowerCase() - const drawableName = `voltra_widget_${widget.id}_preview` - const destinationPath = path.join(drawablePath, `${drawableName}${ext}`) - - // Copy file - fs.copyFileSync(sourcePath, destinationPath) - logger.info(`Copied preview image for widget '${widget.id}' to ${drawableName}${ext}`) - - previewImageMap.set(widget.id, drawableName) - } - - return previewImageMap -} diff --git a/plugin/src/features/android/files/kotlin/index.ts b/plugin/src/features/android/files/kotlin/index.ts deleted file mode 100644 index 8fe33a8..0000000 --- a/plugin/src/features/android/files/kotlin/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import type { AndroidWidgetConfig } from '../../../../types' -import { generateWidgetReceiverClass } from './widgetReceiver' - -export interface GenerateKotlinFilesProps { - platformProjectRoot: string - packageName: string - widgets: AndroidWidgetConfig[] -} - -/** - * Generates Kotlin receiver classes for all configured widgets - */ -export async function generateKotlinFiles(props: GenerateKotlinFilesProps): Promise { - const { platformProjectRoot, packageName, widgets } = props - - // Determine the package path (e.g., com.example.app -> com/example/app) - const packagePath = packageName.replace(/\./g, '/') - const widgetDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'java', packagePath, 'widget') - - // Ensure the widget directory exists - if (!fs.existsSync(widgetDir)) { - fs.mkdirSync(widgetDir, { recursive: true }) - } - - // Generate a receiver class for each widget - for (const widget of widgets) { - const className = `VoltraWidget_${widget.id}Receiver` - const filePath = path.join(widgetDir, `${className}.kt`) - const content = generateWidgetReceiverClass(widget, packageName) - - fs.writeFileSync(filePath, content, 'utf8') - } -} diff --git a/plugin/src/features/android/files/kotlin/widgetReceiver.ts b/plugin/src/features/android/files/kotlin/widgetReceiver.ts deleted file mode 100644 index f966b0e..0000000 --- a/plugin/src/features/android/files/kotlin/widgetReceiver.ts +++ /dev/null @@ -1,25 +0,0 @@ -import dedent from 'dedent' - -import type { AndroidWidgetConfig } from '../../../../types' - -/** - * Generates Kotlin code for a single widget receiver class - */ -export function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: string): string { - // Sanitize the widget id for use as a Kotlin class name - const className = `VoltraWidget_${widget.id}Receiver` - - return dedent` - package ${packageName}.widget - - import voltra.widget.VoltraWidgetReceiver - - /** - * Auto-generated widget receiver for ${widget.displayName} - * Widget ID: ${widget.id} - */ - class ${className} : VoltraWidgetReceiver() { - override val widgetId: String = "${widget.id}" - } - ` -} diff --git a/plugin/src/features/android/files/xml/index.ts b/plugin/src/features/android/files/xml/index.ts deleted file mode 100644 index 0f3a3b0..0000000 --- a/plugin/src/features/android/files/xml/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import type { AndroidWidgetConfig } from '../../../../types' -import { generatePlaceholderLayoutXml } from './placeholderLayout' -import { generatePreviewLayouts } from './previewLayout' -import { generateStringResourcesXml } from './stringResources' -import { generateWidgetInfoXml } from './widgetInfo' - -export interface GenerateXmlFilesProps { - platformProjectRoot: string - projectRoot: string - widgets: AndroidWidgetConfig[] - previewImageMap: Map -} - -/** - * Generates all XML files for Android widgets - */ -export async function generateXmlFiles(props: GenerateXmlFilesProps): Promise { - const { platformProjectRoot, projectRoot, widgets, previewImageMap } = props - - const resPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res') - const xmlPath = path.join(resPath, 'xml') - const layoutPath = path.join(resPath, 'layout') - const valuesPath = path.join(resPath, 'values') - - // Ensure directories exist - for (const dir of [xmlPath, layoutPath, valuesPath]) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - } - - // Generate preview layouts - const previewLayoutMap = await generatePreviewLayouts(widgets, projectRoot, layoutPath, previewImageMap) - - // Generate widget info XML for each widget - for (const widget of widgets) { - const widgetInfoPath = path.join(xmlPath, `voltra_widget_${widget.id}_info.xml`) - const previewImageResourceName = previewImageMap.get(widget.id) - const previewLayoutResourceName = previewLayoutMap.get(widget.id) - const widgetInfoContent = generateWidgetInfoXml(widget, previewImageResourceName, previewLayoutResourceName) - fs.writeFileSync(widgetInfoPath, widgetInfoContent, 'utf8') - } - - // Generate placeholder layout (shared by all widgets) - const placeholderLayoutPath = path.join(layoutPath, 'voltra_widget_placeholder.xml') - const placeholderLayoutContent = generatePlaceholderLayoutXml() - fs.writeFileSync(placeholderLayoutPath, placeholderLayoutContent, 'utf8') - - // Generate string resources for all widgets - const stringsPath = path.join(valuesPath, 'voltra_widgets.xml') - const stringsContent = generateStringResourcesXml(widgets) - fs.writeFileSync(stringsPath, stringsContent, 'utf8') -} diff --git a/plugin/src/features/android/files/xml/placeholderLayout.ts b/plugin/src/features/android/files/xml/placeholderLayout.ts deleted file mode 100644 index b325715..0000000 --- a/plugin/src/features/android/files/xml/placeholderLayout.ts +++ /dev/null @@ -1,22 +0,0 @@ -import dedent from 'dedent' - -/** - * Generates placeholder layout XML for widgets - * This will be replaced by Glance at runtime - */ -export function generatePlaceholderLayoutXml(): string { - return dedent` - - - - - ` -} diff --git a/plugin/src/features/android/files/xml/previewLayout.ts b/plugin/src/features/android/files/xml/previewLayout.ts deleted file mode 100644 index 33c233f..0000000 --- a/plugin/src/features/android/files/xml/previewLayout.ts +++ /dev/null @@ -1,83 +0,0 @@ -import dedent from 'dedent' -import * as fs from 'fs' -import * as path from 'path' - -import type { AndroidWidgetConfig } from '../../../../types' -import { logger } from '../../../../utils' - -/** - * Generates an auto-layout XML for image-based preview. - * This is used when previewImage is provided but no custom previewLayout. - */ -function generateAutoImagePreviewLayout(widgetId: string, drawableResourceName: string): string { - return dedent` - - - - - ` -} - -/** - * Generates preview layout XML files for all widgets. - * Returns a map of widgetId to layout resource name. - * - * Strategy: - * - If previewLayout is provided: copy user's custom XML - * - Else if previewImage is provided: generate auto-layout with ImageView - * - Otherwise: no preview layout generated - */ -export async function generatePreviewLayouts( - widgets: AndroidWidgetConfig[], - projectRoot: string, - layoutPath: string, - previewImageMap: Map -): Promise> { - const previewLayoutMap = new Map() - - // Ensure layout directory exists - if (!fs.existsSync(layoutPath)) { - fs.mkdirSync(layoutPath, { recursive: true }) - } - - for (const widget of widgets) { - let layoutContent: string | null = null - const layoutResourceName = `voltra_widget_${widget.id}_preview` - const layoutFilePath = path.join(layoutPath, `${layoutResourceName}.xml`) - - // Strategy 1: User provided custom preview layout - if (widget.previewLayout) { - const sourcePath = path.join(projectRoot, widget.previewLayout) - - if (!fs.existsSync(sourcePath)) { - logger.warn(`Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}`) - continue - } - - // Copy user's custom XML - layoutContent = fs.readFileSync(sourcePath, 'utf8') - logger.info(`Using custom preview layout for widget '${widget.id}'`) - } - // Strategy 2: Auto-generate layout from preview image - else if (widget.previewImage && previewImageMap.has(widget.id)) { - const drawableResourceName = previewImageMap.get(widget.id)! - layoutContent = generateAutoImagePreviewLayout(widget.id, drawableResourceName) - logger.info(`Generated auto preview layout for widget '${widget.id}' from preview image`) - } - - // Write layout file if we have content - if (layoutContent) { - fs.writeFileSync(layoutFilePath, layoutContent, 'utf8') - previewLayoutMap.set(widget.id, layoutResourceName) - } - } - - return previewLayoutMap -} diff --git a/plugin/src/features/android/files/xml/stringResources.ts b/plugin/src/features/android/files/xml/stringResources.ts deleted file mode 100644 index fea7357..0000000 --- a/plugin/src/features/android/files/xml/stringResources.ts +++ /dev/null @@ -1,23 +0,0 @@ -import dedent from 'dedent' - -import type { AndroidWidgetConfig } from '../../../../types' - -/** - * Generates string resources for widget display names and descriptions - */ -export function generateStringResourcesXml(widgets: AndroidWidgetConfig[]): string { - const stringEntries = widgets - .map( - (widget) => - ` ${widget.displayName}\n ${widget.description}` - ) - .join('\n') - - return dedent` - - - - ${stringEntries} - - ` -} diff --git a/plugin/src/features/android/files/xml/widgetInfo.ts b/plugin/src/features/android/files/xml/widgetInfo.ts deleted file mode 100644 index c2b63cf..0000000 --- a/plugin/src/features/android/files/xml/widgetInfo.ts +++ /dev/null @@ -1,48 +0,0 @@ -import dedent from 'dedent' - -import type { AndroidWidgetConfig } from '../../../../types' - -/** - * Generates widget provider info XML for a single widget - */ -export function generateWidgetInfoXml( - widget: AndroidWidgetConfig, - previewImageResourceName?: string, - previewLayoutResourceName?: string -): string { - const { targetCellWidth, targetCellHeight } = widget - const resizeMode = widget.resizeMode || 'horizontal|vertical' - const widgetCategory = widget.widgetCategory || 'home_screen' - - let minWidth = widget.minWidth - if (minWidth === undefined && widget.minCellWidth !== undefined) { - minWidth = widget.minCellWidth * 70 - 30 - } - - let minHeight = widget.minHeight - if (minHeight === undefined && widget.minCellHeight !== undefined) { - minHeight = widget.minCellHeight * 70 - 30 - } - - const minWidthAttr = minWidth !== undefined ? `\n android:minWidth="${minWidth}dp"` : '' - const minHeightAttr = minHeight !== undefined ? `\n android:minHeight="${minHeight}dp"` : '' - const previewImageAttr = previewImageResourceName - ? `\n android:previewImage="@drawable/${previewImageResourceName}"` - : '' - const previewLayoutAttr = previewLayoutResourceName - ? `\n android:previewLayout="@layout/${previewLayoutResourceName}"` - : '' - - return dedent` - - - - ` -} diff --git a/plugin/src/features/ios/files/assets/catalog.ts b/plugin/src/features/ios/files/assets/catalog.ts deleted file mode 100644 index 3509ea9..0000000 --- a/plugin/src/features/ios/files/assets/catalog.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -/** - * Contents.json for the root of Assets.xcassets - */ -const ASSETS_CATALOG_CONTENTS = { - info: { - author: 'xcode', - version: 1, - }, -} - -/** - * Contents.json for an imageset - */ -function createImagesetContents(filename: string) { - return { - images: [ - { - filename, - idiom: 'universal', - }, - ], - info: { - author: 'xcode', - version: 1, - }, - } -} - -/** - * Generates the Assets.xcassets directory structure for a widget extension. - * - * Creates the minimal required asset catalog structure: - * - Assets.xcassets/ - * - Contents.json (required root manifest) - * - * @param targetPath - Path to the widget extension target directory - */ -export function generateAssetsCatalog(targetPath: string): void { - const assetsPath = path.join(targetPath, 'Assets.xcassets') - - // Create Assets.xcassets directory - if (!fs.existsSync(assetsPath)) { - fs.mkdirSync(assetsPath, { recursive: true }) - } - - // Write root Contents.json - fs.writeFileSync(path.join(assetsPath, 'Contents.json'), JSON.stringify(ASSETS_CATALOG_CONTENTS, null, 2)) -} - -/** - * Adds an image to the Assets.xcassets catalog as an imageset. - * - * Creates: - * - {imageName}.imageset/ - * - {originalFilename} - * - Contents.json - * - * @param assetsPath - Path to Assets.xcassets directory - * @param imagePath - Path to the source image file - */ -export function addImageToAssetsCatalog(assetsPath: string, imagePath: string): void { - const filename = path.basename(imagePath) - const imageName = path.basename(filename, path.extname(filename)) - const imagesetPath = path.join(assetsPath, `${imageName}.imageset`) - - // Create imageset directory - if (!fs.existsSync(imagesetPath)) { - fs.mkdirSync(imagesetPath, { recursive: true }) - } - - // Copy the image file - fs.copyFileSync(imagePath, path.join(imagesetPath, filename)) - - // Write Contents.json for the imageset - fs.writeFileSync(path.join(imagesetPath, 'Contents.json'), JSON.stringify(createImagesetContents(filename), null, 2)) -} diff --git a/plugin/src/features/ios/files/assets/images.ts b/plugin/src/features/ios/files/assets/images.ts deleted file mode 100644 index 1a99f9a..0000000 --- a/plugin/src/features/ios/files/assets/images.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_EXTENSIONS } from '../../../../constants' -import { logger } from '../../../../utils' -import { addImageToAssetsCatalog } from './catalog' - -/** - * Checks if an image file exceeds the size limit for Live Activities. - * Logs a warning if the image is too large. - * - * @param imagePath - Path to the image file - * @returns true if the image is within the size limit, false otherwise - */ -export function checkImageSize(imagePath: string): boolean { - const stats = fs.statSync(imagePath) - const imageSizeInBytes = stats.size - - if (imageSizeInBytes >= MAX_IMAGE_SIZE_BYTES) { - const fileName = path.basename(imagePath) - logger.warnRed( - `Image "${fileName}" is ${imageSizeInBytes} bytes (${(imageSizeInBytes / 1024).toFixed(2)}KB). ` + - `This image will not display correctly in Live Activities as images for Live Activities need to be lower than 4KB.` - ) - return false - } - - return true -} - -/** - * Checks if a file is a supported image type. - */ -export function isSupportedImage(filePath: string): boolean { - return SUPPORTED_IMAGE_EXTENSIONS.test(path.extname(filePath)) -} - -/** - * Copies user images from the source directory to the widget's Assets.xcassets. - * - * Images are validated for size (must be < 4KB for Live Activities) and - * added as imagesets to the asset catalog. - * - * @param userImagesPath - Path to directory containing user images (relative to project root) - * @param targetAssetsPath - Path to the Assets.xcassets directory in the widget target - * @returns Array of image filenames that were copied - */ -export function copyUserImages(userImagesPath: string, targetAssetsPath: string): string[] { - const copiedImages: string[] = [] - - if (!fs.existsSync(userImagesPath)) { - logger.warn(`Skipping user images: directory does not exist at ${userImagesPath}`) - return copiedImages - } - - if (!fs.lstatSync(userImagesPath).isDirectory()) { - logger.warn(`Skipping user images: ${userImagesPath} is not a directory`) - return copiedImages - } - - const files = fs.readdirSync(userImagesPath) - - for (const file of files) { - const sourcePath = path.join(userImagesPath, file) - - // Skip directories and non-image files - if (fs.lstatSync(sourcePath).isDirectory()) { - continue - } - - if (!isSupportedImage(file)) { - continue - } - - // Check image size for Live Activity compatibility (warns if too large) - checkImageSize(sourcePath) - - // Add to asset catalog - addImageToAssetsCatalog(targetAssetsPath, sourcePath) - copiedImages.push(file) - } - - if (copiedImages.length > 0) { - logger.info(`Copied ${copiedImages.length} user image(s) to widget assets`) - } - - return copiedImages -} diff --git a/plugin/src/features/ios/files/assets/index.ts b/plugin/src/features/ios/files/assets/index.ts deleted file mode 100644 index e754726..0000000 --- a/plugin/src/features/ios/files/assets/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as path from 'path' - -import { DEFAULT_USER_IMAGES_PATH } from '../../../../constants' -import { logger } from '../../../../utils' -import { generateAssetsCatalog } from './catalog' -import { copyUserImages } from './images' - -export interface GenerateAssetsOptions { - targetPath: string - userImagesPath?: string -} - -/** - * Generates all asset files for the widget extension. - * - * This creates: - * - Assets.xcassets/ directory structure - * - Copies user images to the asset catalog - */ -export function generateAssets(options: GenerateAssetsOptions): void { - const { targetPath, userImagesPath = DEFAULT_USER_IMAGES_PATH } = options - - // Generate Assets.xcassets structure - generateAssetsCatalog(targetPath) - logger.info('Generated Assets.xcassets') - - // Copy user images to asset catalog - const assetsPath = path.join(targetPath, 'Assets.xcassets') - copyUserImages(userImagesPath, assetsPath) -} diff --git a/plugin/src/features/ios/files/swift/index.ts b/plugin/src/features/ios/files/swift/index.ts deleted file mode 100644 index 82ddc51..0000000 --- a/plugin/src/features/ios/files/swift/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import type { WidgetConfig } from '../../../../types' -import { logger } from '../../../../utils' -import { prerenderWidgetState } from '../../../../utils/prerender' -import { generateInitialStatesSwift } from './initialStates' -import { generateDefaultWidgetBundleSwift, generateWidgetBundleSwift } from './widgetBundle' - -export interface GenerateSwiftFilesOptions { - targetPath: string - projectRoot: string - widgets?: WidgetConfig[] -} - -/** - * Generates all Swift files for the widget extension. - * - * This creates: - * - VoltraWidgetInitialStates.swift (pre-rendered widget states) - * - VoltraWidgetBundle.swift (widget bundle definition) - */ -export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Promise { - const { targetPath, projectRoot, widgets } = options - - // Dynamic import for ESM module compatibility - // voltra/server is an ESM module, but the plugin is compiled to CommonJS - const { renderWidgetToString } = await import('voltra/server') - - // Prerender widget initial states if any widgets have initialStatePath configured - const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot, renderWidgetToString) - - // Generate the initial states Swift file - const initialStatesContent = generateInitialStatesSwift(prerenderedStates) - const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') - fs.writeFileSync(initialStatesPath, initialStatesContent) - - logger.info(`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} pre-rendered widget states`) - - // Generate the widget bundle Swift file - const widgetBundleContent = - widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift() - - const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift') - fs.writeFileSync(widgetBundlePath, widgetBundleContent) - - logger.info(`Generated VoltraWidgetBundle.swift with ${widgets?.length ?? 0} home screen widgets`) -} diff --git a/plugin/src/features/ios/files/swift/initialStates.ts b/plugin/src/features/ios/files/swift/initialStates.ts deleted file mode 100644 index 0b7f101..0000000 --- a/plugin/src/features/ios/files/swift/initialStates.ts +++ /dev/null @@ -1,89 +0,0 @@ -import dedent from 'dedent' - -/** - * Generates Swift code that bundles pre-rendered widget initial states. - */ -export function generateInitialStatesSwift(prerenderedStates: Map): string { - if (prerenderedStates.size === 0) { - return generateEmptyInitialStatesSwift() - } - - // Generate the bundled states dictionary - const stateEntries = Array.from(prerenderedStates.entries()) - .map(([widgetId, json]) => { - const delimiter = getSwiftRawStringDelimiter(json) - return ` "${widgetId}": ${delimiter}"${json}"${delimiter}` - }) - .join(',\n') - - return dedent` - // - // VoltraWidgetInitialStates.swift - // - // Auto-generated by Voltra config plugin. - // Contains pre-rendered initial states for home screen widgets. - // - - import Foundation - - public enum VoltraWidgetInitialStates { - private static let bundledStates: [String: String] = [ - ${stateEntries} - ] - - /// Get the bundled initial state JSON for a widget. - /// Returns nil if no initial state was configured for the widget. - public static func getInitialState(for widgetId: String) -> Data? { - guard let jsonString = bundledStates[widgetId] else { return nil } - return jsonString.data(using: .utf8) - } - } - ` -} - -/** - * Generates empty Swift code when no widgets have initial states configured. - */ -function generateEmptyInitialStatesSwift(): string { - return dedent` - // - // VoltraWidgetInitialStates.swift - // - // Auto-generated by Voltra config plugin. - // No widget initial states configured. - // - - import Foundation - - public enum VoltraWidgetInitialStates { - /// Get the bundled initial state JSON for a widget. - /// Always returns nil since no initial states are configured. - public static func getInitialState(for widgetId: String) -> Data? { - return nil - } - } - ` -} - -/** - * Determines the appropriate Swift raw string delimiter for a given string. - * Counts the maximum consecutive '#' characters after a '"' in the content - * and returns a delimiter with one more '#' than that maximum. - * - * For example: - * - Content has no '"#' → returns '#' - * - Content has '"#' (1 hash) → returns '##' - * - Content has '"##' (2 hashes) → returns '###' - */ -function getSwiftRawStringDelimiter(str: string): string { - // Find all sequences of '#' that follow a '"' - const matches = str.match(/"#+/g) - - if (!matches) { - return '#' - } - - // Find the maximum number of consecutive '#' after a '"' - const maxHashes = Math.max(...matches.map((m) => m.length - 1)) // -1 to exclude the '"' - return '#'.repeat(maxHashes + 1) -} diff --git a/plugin/src/features/ios/files/swift/widgetBundle.ts b/plugin/src/features/ios/files/swift/widgetBundle.ts deleted file mode 100644 index 06b7558..0000000 --- a/plugin/src/features/ios/files/swift/widgetBundle.ts +++ /dev/null @@ -1,105 +0,0 @@ -import dedent from 'dedent' - -import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../../../constants' -import type { WidgetConfig } from '../../../../types' - -/** - * Generates Swift code for a single widget struct - */ -function generateWidgetStruct(widget: WidgetConfig): string { - const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES - const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ') - - // Sanitize the widget id for use as a Swift identifier - const structName = `VoltraWidget_${widget.id}` - - return dedent` - public struct ${structName}: Widget { - private let widgetId = "${widget.id}" - - public init() {} - - public var body: some WidgetConfiguration { - StaticConfiguration( - kind: "Voltra_Widget_${widget.id}", - provider: VoltraHomeWidgetProvider( - widgetId: widgetId, - initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) - ) - ) { entry in - VoltraHomeWidgetView(entry: entry) - } - .configurationDisplayName("${widget.displayName}") - .description("${widget.description}") - .supportedFamilies([${familiesSwift}]) - .contentMarginsDisabled() - } - } - ` -} - -/** - * Generates the VoltraWidgetBundle.swift file content with configured widgets - */ -export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { - // Generate widget structs - const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') - - // Generate widget bundle body entries - const widgetInstances = widgets.map((w) => ` VoltraWidget_${w.id}()`).join('\n') - - return dedent` - // - // VoltraWidgetBundle.swift - // - // Auto-generated by Voltra config plugin. - // This file defines which Voltra widgets are available in your app. - // - - import SwiftUI - import WidgetKit - import VoltraWidget - - @main - struct VoltraWidgetBundle: WidgetBundle { - var body: some Widget { - // Live Activity (with Watch/CarPlay support) - VoltraWidget() - - // Home Screen Widgets - ${widgetInstances} - } - } - - // MARK: - Home Screen Widget Definitions - - ${widgetStructs} - ` -} - -/** - * Generates the VoltraWidgetBundle.swift file content when no widgets are configured - * (only Live Activities) - */ -export function generateDefaultWidgetBundleSwift(): string { - return dedent` - // - // VoltraWidgetBundle.swift - // - // This file defines which Voltra widgets are available in your app. - // You can customize which widgets to include by adding or removing them below. - // - - import SwiftUI - import WidgetKit - import VoltraWidget // Import Voltra widgets - - @main - struct VoltraWidgetBundle: WidgetBundle { - var body: some Widget { - // Live Activity (with Watch/CarPlay support) - VoltraWidget() - } - } - ` -} diff --git a/plugin/src/features/ios/plist/index.ts b/plugin/src/features/ios/plist/index.ts deleted file mode 100644 index a3df198..0000000 --- a/plugin/src/features/ios/plist/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ConfigPlugin, InfoPlist, withInfoPlist } from '@expo/config-plugins' -import plist from '@expo/plist' -import { readFileSync, writeFileSync } from 'fs' -import { join as joinPath } from 'path' - -export interface ConfigureMainAppPlistProps { - targetName: string - groupIdentifier?: string -} - -/** - * Plugin step that configures the Info.plist files. - * - * This: - * - Updates the widget extension's Info.plist with URL schemes - * - Removes incompatible NSExtension keys for WidgetKit - * - Adds group identifier if configured - */ -export const configureMainAppPlist: ConfigPlugin = ( - expoConfig, - { targetName, groupIdentifier } -) => - withInfoPlist(expoConfig, (plistConfig) => { - const scheme = typeof expoConfig.scheme === 'string' ? expoConfig.scheme : expoConfig.ios?.bundleIdentifier - - if (scheme) { - const targetPath = joinPath(plistConfig.modRequest.platformProjectRoot, targetName) - const filePath = joinPath(targetPath, 'Info.plist') - const content = plist.parse(readFileSync(filePath, 'utf8')) as InfoPlist - - // WidgetKit extensions must NOT declare NSExtensionPrincipalClass/MainStoryboard. - // The @main WidgetBundle in Swift is the entry point. - const ext = (content as any).NSExtension as Record | undefined - if (ext) { - delete ext.NSExtensionPrincipalClass - delete ext.NSExtensionMainStoryboard - } - - content.CFBundleURLTypes = [ - { - CFBundleURLSchemes: [scheme], - }, - ] - - // Only set group identifier if provided - if (groupIdentifier) { - ;(content as any)['Voltra_AppGroupIdentifier'] = groupIdentifier - } - - writeFileSync(filePath, plist.build(content)) - } - - return plistConfig - }) diff --git a/plugin/src/features/ios/podfile/index.ts b/plugin/src/features/ios/podfile/index.ts deleted file mode 100644 index 9e4f3be..0000000 --- a/plugin/src/features/ios/podfile/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { ConfigPlugin, withPodfile as withExpoPodfile } from '@expo/config-plugins' - -const packageJson = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', '..', 'package.json'), 'utf8') -const libraryName = JSON.parse(packageJson).name - -/** - * Generates the Podfile target content for the widget extension. - */ -function getTargetContent(targetName: string): string { - const backtick = '`' - return ` -# Voltra Widget Extension Target -# DO NOT MODIFY THIS FILE - IT IS AUTO-GENERATED BY THE VOLTRA PLUGIN -target '${targetName}' do - use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] - use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] - - require 'json' - project_root = "#{Pod::Config.instance.installation_root}/.." - voltra_module = JSON.parse(${backtick}npx expo-modules-autolinking search -p apple --json --project-root #{project_root}${backtick}) - podspec_dir_path = Pathname.new(File.join(voltra_module['${libraryName}']['path'], 'ios')).relative_path_from(Pathname.new(__dir__)).to_path - - pod 'VoltraWidget', :path => podspec_dir_path -end` -} - -export interface ConfigurePodfileProps { - targetName: string -} - -/** - * Plugin step that adds the Podfile target for the widget extension. - */ -export const configurePodfile: ConfigPlugin = (config, { targetName }) => { - return withExpoPodfile(config, (podfile) => { - const targetContent = getTargetContent(targetName) - - // Check if target already exists (avoid duplicates) - const targetMarker = "target '" + targetName + "'" - if (podfile.modResults.contents.includes(targetMarker)) { - return podfile - } - - podfile.modResults.contents = podfile.modResults.contents + '\n' + targetContent - return podfile - }) -} diff --git a/plugin/src/features/ios/xcode/build/index.ts b/plugin/src/features/ios/xcode/build/index.ts deleted file mode 100644 index 0414b1d..0000000 --- a/plugin/src/features/ios/xcode/build/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -import type { WidgetFiles } from '../../../../types' -import { addXCConfigurationList } from './configurationList' -import { addBuildPhases } from './phases' - -export interface ConfigureBuildOptions { - targetName: string - targetUuid: string - bundleIdentifier: string - deploymentTarget: string - currentProjectVersion: string - marketingVersion?: string - groupName: string - productFile: { - uuid: string - target: string - basename: string - group: string - } - widgetFiles: WidgetFiles - codeSignStyle?: string - developmentTeam?: string - provisioningProfileSpecifier?: string -} - -/** - * Configures the build settings and phases for the widget extension. - * - * This: - * - Creates the XCConfigurationList with Debug/Release configurations - * - Adds all required build phases (Sources, CopyFiles, Frameworks, Resources) - */ -export function configureBuild(xcodeProject: XcodeProject, options: ConfigureBuildOptions) { - const { - targetName, - targetUuid, - bundleIdentifier, - deploymentTarget, - currentProjectVersion, - marketingVersion, - groupName, - productFile, - widgetFiles, - codeSignStyle, - developmentTeam, - provisioningProfileSpecifier, - } = options - - const xCConfigurationList = addXCConfigurationList(xcodeProject, { - targetName, - currentProjectVersion, - bundleIdentifier, - deploymentTarget, - marketingVersion, - codeSignStyle, - developmentTeam, - provisioningProfileSpecifier, - }) - - addBuildPhases(xcodeProject, { - targetUuid, - groupName, - productFile, - widgetFiles, - }) - - return xCConfigurationList -} diff --git a/plugin/src/features/ios/xcode/build/phases.ts b/plugin/src/features/ios/xcode/build/phases.ts deleted file mode 100644 index d4f6620..0000000 --- a/plugin/src/features/ios/xcode/build/phases.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' -import * as util from 'util' - -import type { WidgetFiles } from '../../../../types' - -export interface AddBuildPhasesOptions { - targetUuid: string - groupName: string - productFile: { - uuid: string - target: string - basename: string - group: string - } - widgetFiles: WidgetFiles -} - -/** - * Adds all required build phases for the widget extension target. - */ -export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhasesOptions): void { - const { targetUuid, groupName, productFile, widgetFiles } = options - const buildPath = `""` - const folderType = 'app_extension' - - const { swiftFiles, intentFiles, assetDirectories } = widgetFiles - - // Sources build phase - xcodeProject.addBuildPhase( - [...swiftFiles, ...intentFiles], - 'PBXSourcesBuildPhase', - groupName, - targetUuid, - folderType, - buildPath - ) - - // Copy files build phase - xcodeProject.addBuildPhase( - [], - 'PBXCopyFilesBuildPhase', - groupName, - xcodeProject.getFirstTarget().uuid, - folderType, - buildPath - ) - - xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, productFile.target).files.push({ - value: productFile.uuid, - comment: util.format('%s in %s', productFile.basename, productFile.group), - }) - xcodeProject.addToPbxBuildFileSection(productFile) - - // Frameworks build phase - xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', groupName, targetUuid, folderType, buildPath) - - // Resources build phase - xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid) -} diff --git a/plugin/src/features/ios/xcode/groups.ts b/plugin/src/features/ios/xcode/groups.ts deleted file mode 100644 index 57fbdce..0000000 --- a/plugin/src/features/ios/xcode/groups.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -import type { WidgetFiles } from '../../../types' - -export interface AddPbxGroupOptions { - targetName: string - widgetFiles: WidgetFiles -} - -/** - * Adds a PBXGroup for the widget extension files. - */ -export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { - const { targetName, widgetFiles } = options - const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles - - // Add PBX group with all widget files - const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup( - [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories], - targetName, - targetName - ) - - // Add PBXGroup to top level group - const groups = xcodeProject.hash.project.objects['PBXGroup'] - if (pbxGroupUuid) { - Object.keys(groups).forEach(function (key) { - if (groups[key].name === undefined && groups[key].path === undefined) { - xcodeProject.addToPbxGroup(pbxGroupUuid, key) - } - }) - } -} diff --git a/plugin/src/features/ios/xcode/productFile.ts b/plugin/src/features/ios/xcode/productFile.ts deleted file mode 100644 index cb06787..0000000 --- a/plugin/src/features/ios/xcode/productFile.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -export interface AddProductFileOptions { - targetName: string - groupName: string -} - -/** - * Adds the product file (.appex) for the widget extension. - */ -export function addProductFile(xcodeProject: XcodeProject, options: AddProductFileOptions) { - const { targetName, groupName } = options - - const productFileOptions = { - basename: `${targetName}.appex`, - group: groupName, - explicitFileType: 'wrapper.app-extension', - settings: { - ATTRIBUTES: ['RemoveHeadersOnCopy'], - }, - includeInIndex: 0, - path: `${targetName}.appex`, - sourceTree: 'BUILT_PRODUCTS_DIR', - } - - const productFile = xcodeProject.addProductFile(targetName, productFileOptions) - - return productFile -} diff --git a/plugin/src/features/ios/xcode/target/dependency.ts b/plugin/src/features/ios/xcode/target/dependency.ts deleted file mode 100644 index 276a775..0000000 --- a/plugin/src/features/ios/xcode/target/dependency.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -/** - * Adds a target dependency so the main app depends on the widget extension. - */ -export function addTargetDependency(xcodeProject: XcodeProject, target: { uuid: string }): void { - if (!xcodeProject.hash.project.objects['PBXTargetDependency']) { - xcodeProject.hash.project.objects['PBXTargetDependency'] = {} - } - if (!xcodeProject.hash.project.objects['PBXContainerItemProxy']) { - xcodeProject.hash.project.objects['PBXContainerItemProxy'] = {} - } - - xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [target.uuid]) -} diff --git a/plugin/src/features/ios/xcode/target/index.ts b/plugin/src/features/ios/xcode/target/index.ts deleted file mode 100644 index 204f196..0000000 --- a/plugin/src/features/ios/xcode/target/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -import { addTargetDependency } from './dependency' -import { addToPbxNativeTargetSection } from './nativeTarget' -import { addToPbxProjectSection } from './projectSection' - -export interface ConfigureTargetOptions { - targetName: string - targetUuid: string - productFile: { fileRef: string } - xCConfigurationList: { uuid: string } -} - -/** - * Configures the widget extension target in the Xcode project. - * - * This: - * - Adds the target to PBXNativeTarget section - * - Adds the target to PBXProject section - * - Adds a dependency from the main app to the widget extension - */ -export function configureTarget(xcodeProject: XcodeProject, options: ConfigureTargetOptions) { - const target = addToPbxNativeTargetSection(xcodeProject, options) - addToPbxProjectSection(xcodeProject, target) - addTargetDependency(xcodeProject, target) - - return target -} diff --git a/plugin/src/features/ios/xcode/target/nativeTarget.ts b/plugin/src/features/ios/xcode/target/nativeTarget.ts deleted file mode 100644 index c1a2b1f..0000000 --- a/plugin/src/features/ios/xcode/target/nativeTarget.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -export interface AddNativeTargetOptions { - targetName: string - targetUuid: string - productFile: { fileRef: string } - xCConfigurationList: { uuid: string } -} - -/** - * Adds the widget extension target to the PBXNativeTarget section. - */ -export function addToPbxNativeTargetSection(xcodeProject: XcodeProject, options: AddNativeTargetOptions) { - const { targetName, targetUuid, productFile, xCConfigurationList } = options - - const target = { - uuid: targetUuid, - pbxNativeTarget: { - isa: 'PBXNativeTarget', - name: targetName, - productName: targetName, - productReference: productFile.fileRef, - productType: `"com.apple.product-type.app-extension"`, - buildConfigurationList: xCConfigurationList.uuid, - buildPhases: [], - buildRules: [], - dependencies: [], - }, - } - - xcodeProject.addToPbxNativeTargetSection(target) - - return target -} diff --git a/plugin/src/features/ios/xcode/target/projectSection.ts b/plugin/src/features/ios/xcode/target/projectSection.ts deleted file mode 100644 index d76684f..0000000 --- a/plugin/src/features/ios/xcode/target/projectSection.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { XcodeProject } from '@expo/config-plugins' - -import { IOS } from '../../../../constants' - -/** - * Adds the target to the PBXProject section. - */ -export function addToPbxProjectSection(xcodeProject: XcodeProject, target: { uuid: string }): void { - xcodeProject.addToPbxProjectSection(target) - - // Add target attributes to project section - const projectSection = xcodeProject.pbxProjectSection() - const firstProject = xcodeProject.getFirstProject() - - if (!projectSection[firstProject.uuid].attributes.TargetAttributes) { - projectSection[firstProject.uuid].attributes.TargetAttributes = {} - } - - projectSection[firstProject.uuid].attributes.TargetAttributes[target.uuid] = { - LastSwiftMigration: IOS.LAST_SWIFT_MIGRATION, - } -} diff --git a/plugin/src/index.ts b/plugin/src/index.ts index cca956f..4fc7587 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,11 +1,11 @@ import { IOSConfig } from 'expo/config-plugins' +import { withAndroid } from './android' import { IOS } from './constants' -import { withAndroid } from './features/android' -import { withIOS } from './features/ios' -import { withPushNotifications } from './features/pushNotifications' +import { withIOS, withPushNotifications } from './ios' +import { withIOS as withIOSWidget } from './ios-widget' import type { VoltraConfigPlugin } from './types' -import { ensureURLScheme } from './utils' +import { ensureURLScheme } from './utils/urlScheme' import { validateProps } from './validation' /** @@ -20,6 +20,12 @@ const withVoltra: VoltraConfigPlugin = (config, props = {}) => { // Validate props at entry point validateProps(props) + if (!config.ios?.bundleIdentifier) { + throw new Error( + 'Voltra config plugin requires expo.ios.bundleIdentifier to be set in app.json/app.config.* to configure the iOS widget extension.' + ) + } + // Use deploymentTarget from props if provided, otherwise fall back to default const deploymentTarget = props.deploymentTarget || IOS.DEPLOYMENT_TARGET // Use custom targetName if provided, otherwise fall back to default "{AppName}LiveActivity" @@ -29,22 +35,14 @@ const withVoltra: VoltraConfigPlugin = (config, props = {}) => { // Ensure URL scheme is set for widget deep linking config = ensureURLScheme(config) - // Add Live Activities support to main app Info.plist - config.ios = { - ...config.ios, - infoPlist: { - ...config.ios?.infoPlist, - NSSupportsLiveActivities: true, - NSSupportsLiveActivitiesFrequentUpdates: false, - // Only add group identifier if provided - ...(props?.groupIdentifier ? { Voltra_AppGroupIdentifier: props.groupIdentifier } : {}), - // Store widget IDs in Info.plist for native module to access - ...(props?.widgets && props.widgets.length > 0 ? { Voltra_WidgetIds: props.widgets.map((w) => w.id) } : {}), - }, - } - - // Apply iOS configuration (files, xcode, podfile, plist, eas) + // Configure iOS main app (Info.plist, entitlements, EAS) config = withIOS(config, { + groupIdentifier: props?.groupIdentifier, + widgetIds: props?.widgets && props.widgets.length > 0 ? props.widgets.map((w) => w.id) : undefined, + }) + + // Configure iOS widget extension (files, xcode, podfile, plist, eas) + config = withIOSWidget(config, { targetName, bundleIdentifier, deploymentTarget, diff --git a/plugin/src/features/ios/eas/index.ts b/plugin/src/ios-widget/eas.ts similarity index 93% rename from plugin/src/features/ios/eas/index.ts rename to plugin/src/ios-widget/eas.ts index 8dc0352..c7b10c6 100644 --- a/plugin/src/features/ios/eas/index.ts +++ b/plugin/src/ios-widget/eas.ts @@ -1,6 +1,7 @@ import { ConfigPlugin } from '@expo/config-plugins' -import { addApplicationGroupsEntitlement, getWidgetExtensionEntitlements } from '../files/entitlements' +import { addApplicationGroupsEntitlement } from '../utils/entitlements' +import { getWidgetExtensionEntitlements } from './files/entitlements' export interface ConfigureEasBuildProps { bundleIdentifier: string diff --git a/plugin/src/ios-widget/files/assets.ts b/plugin/src/ios-widget/files/assets.ts new file mode 100644 index 0000000..15bcc28 --- /dev/null +++ b/plugin/src/ios-widget/files/assets.ts @@ -0,0 +1,200 @@ +import * as fs from 'fs' +import * as path from 'path' + +import { DEFAULT_USER_IMAGES_PATH, MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_EXTENSIONS } from '../../constants' +import { logger } from '../../utils/logger' + +export interface GenerateAssetsOptions { + targetPath: string + userImagesPath?: string +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Generates all asset files for the widget extension. + * + * This creates: + * - Assets.xcassets/ directory structure + * - Copies user images to the asset catalog + */ +export function generateAssets(options: GenerateAssetsOptions): void { + const { targetPath, userImagesPath = DEFAULT_USER_IMAGES_PATH } = options + + // Generate Assets.xcassets structure + generateAssetsCatalog(targetPath) + logger.info('Generated Assets.xcassets') + + // Copy user images to asset catalog + const assetsPath = path.join(targetPath, 'Assets.xcassets') + copyUserImages(userImagesPath, assetsPath) +} + +// ============================================================================ +// Asset Catalog +// ============================================================================ + +/** + * Contents.json for the root of Assets.xcassets + */ +const ASSETS_CATALOG_CONTENTS = { + info: { + author: 'xcode', + version: 1, + }, +} + +/** + * Contents.json for an imageset + */ +function createImagesetContents(filename: string) { + return { + images: [ + { + filename, + idiom: 'universal', + }, + ], + info: { + author: 'xcode', + version: 1, + }, + } +} + +/** + * Generates the Assets.xcassets directory structure for a widget extension. + * + * Creates the minimal required asset catalog structure: + * - Assets.xcassets/ + * - Contents.json (required root manifest) + * + * @param targetPath - Path to the widget extension target directory + */ +function generateAssetsCatalog(targetPath: string): void { + const assetsPath = path.join(targetPath, 'Assets.xcassets') + + // Create Assets.xcassets directory + if (!fs.existsSync(assetsPath)) { + fs.mkdirSync(assetsPath, { recursive: true }) + } + + // Write root Contents.json + fs.writeFileSync(path.join(assetsPath, 'Contents.json'), JSON.stringify(ASSETS_CATALOG_CONTENTS, null, 2)) +} + +/** + * Adds an image to the Assets.xcassets catalog as an imageset. + * + * Creates: + * - {imageName}.imageset/ + * - {originalFilename} + * - Contents.json + * + * @param assetsPath - Path to Assets.xcassets directory + * @param imagePath - Path to the source image file + */ +function addImageToAssetsCatalog(assetsPath: string, imagePath: string): void { + const filename = path.basename(imagePath) + const imageName = path.basename(filename, path.extname(filename)) + const imagesetPath = path.join(assetsPath, `${imageName}.imageset`) + + // Create imageset directory + if (!fs.existsSync(imagesetPath)) { + fs.mkdirSync(imagesetPath, { recursive: true }) + } + + // Copy the image file + fs.copyFileSync(imagePath, path.join(imagesetPath, filename)) + + // Write Contents.json for the imageset + fs.writeFileSync(path.join(imagesetPath, 'Contents.json'), JSON.stringify(createImagesetContents(filename), null, 2)) +} + +// ============================================================================ +// Image Handling +// ============================================================================ + +/** + * Checks if an image file exceeds the size limit for Live Activities. + * Logs a warning if the image is too large. + * + * @param imagePath - Path to the image file + * @returns true if the image is within the size limit, false otherwise + */ +function checkImageSize(imagePath: string): boolean { + const stats = fs.statSync(imagePath) + const imageSizeInBytes = stats.size + + if (imageSizeInBytes >= MAX_IMAGE_SIZE_BYTES) { + const fileName = path.basename(imagePath) + logger.warnRed( + `Image "${fileName}" is ${imageSizeInBytes} bytes (${(imageSizeInBytes / 1024).toFixed(2)}KB). ` + + `This image will not display correctly in Live Activities as images for Live Activities need to be lower than 4KB.` + ) + return false + } + + return true +} + +/** + * Checks if a file is a supported image type. + */ +function isSupportedImage(filePath: string): boolean { + return SUPPORTED_IMAGE_EXTENSIONS.test(path.extname(filePath)) +} + +/** + * Copies user images from the source directory to the widget's Assets.xcassets. + * + * Images are validated for size (must be < 4KB for Live Activities) and + * added as imagesets to the asset catalog. + * + * @param userImagesPath - Path to directory containing user images (relative to project root) + * @param targetAssetsPath - Path to the Assets.xcassets directory in the widget target + * @returns Array of image filenames that were copied + */ +function copyUserImages(userImagesPath: string, targetAssetsPath: string): string[] { + const copiedImages: string[] = [] + + if (!fs.existsSync(userImagesPath)) { + logger.warn(`Skipping user images: directory does not exist at ${userImagesPath}`) + return copiedImages + } + + if (!fs.lstatSync(userImagesPath).isDirectory()) { + logger.warn(`Skipping user images: ${userImagesPath} is not a directory`) + return copiedImages + } + + const files = fs.readdirSync(userImagesPath) + + for (const file of files) { + const sourcePath = path.join(userImagesPath, file) + + // Skip directories and non-image files + if (fs.lstatSync(sourcePath).isDirectory()) { + continue + } + + if (!isSupportedImage(file)) { + continue + } + + // Check image size for Live Activity compatibility (warns if too large) + checkImageSize(sourcePath) + + // Add to asset catalog + addImageToAssetsCatalog(targetAssetsPath, sourcePath) + copiedImages.push(file) + } + + if (copiedImages.length > 0) { + logger.info(`Copied ${copiedImages.length} user image(s) to widget assets`) + } + + return copiedImages +} diff --git a/plugin/src/features/ios/files/entitlements.ts b/plugin/src/ios-widget/files/entitlements.ts similarity index 68% rename from plugin/src/features/ios/files/entitlements.ts rename to plugin/src/ios-widget/files/entitlements.ts index 7c88d71..3bc253d 100644 --- a/plugin/src/features/ios/files/entitlements.ts +++ b/plugin/src/ios-widget/files/entitlements.ts @@ -2,23 +2,8 @@ import plist from '@expo/plist' import * as fs from 'fs' import * as path from 'path' -import { logger } from '../../../utils' - -/** - * Adds application groups entitlement to an entitlements object. - */ -export function addApplicationGroupsEntitlement( - entitlements: Record, - groupIdentifier: string -): Record { - const existingApplicationGroups = ((entitlements['com.apple.security.application-groups'] as string[]) ?? []).filter( - Boolean - ) - - entitlements['com.apple.security.application-groups'] = [groupIdentifier, ...existingApplicationGroups] - - return entitlements -} +import { addApplicationGroupsEntitlement } from '../../utils/entitlements' +import { logger } from '../../utils/logger' /** * Gets the entitlements for the widget extension. diff --git a/plugin/src/features/ios/files/index.ts b/plugin/src/ios-widget/files/index.ts similarity index 91% rename from plugin/src/features/ios/files/index.ts rename to plugin/src/ios-widget/files/index.ts index 36780f9..95d70fb 100644 --- a/plugin/src/features/ios/files/index.ts +++ b/plugin/src/ios-widget/files/index.ts @@ -2,10 +2,10 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins' import * as fs from 'fs' import * as path from 'path' -import type { WidgetConfig } from '../../../types' +import type { WidgetConfig } from '../../types' import { generateAssets } from './assets' import { generateEntitlements } from './entitlements' -import { generateInfoPlist } from './plist' +import { generateInfoPlist } from './infoPlist' import { generateSwiftFiles } from './swift' export interface GenerateWidgetExtensionFilesProps { @@ -32,6 +32,10 @@ export const generateWidgetExtensionFiles: ConfigPlugin { + if (config.modRequest.introspect) { + return config + } + const { platformProjectRoot, projectRoot } = config.modRequest const targetPath = path.join(platformProjectRoot, targetName) diff --git a/plugin/src/features/ios/files/plist.ts b/plugin/src/ios-widget/files/infoPlist.ts similarity index 95% rename from plugin/src/features/ios/files/plist.ts rename to plugin/src/ios-widget/files/infoPlist.ts index ffcde1c..6d59c43 100644 --- a/plugin/src/features/ios/files/plist.ts +++ b/plugin/src/ios-widget/files/infoPlist.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as path from 'path' -import { logger } from '../../../utils' +import { logger } from '../../utils/logger' /** * Generates the Info.plist content for a WidgetKit extension. diff --git a/plugin/src/ios-widget/files/swift.ts b/plugin/src/ios-widget/files/swift.ts new file mode 100644 index 0000000..ff52467 --- /dev/null +++ b/plugin/src/ios-widget/files/swift.ts @@ -0,0 +1,249 @@ +import dedent from 'dedent' +import * as fs from 'fs' +import * as path from 'path' + +import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' +import type { WidgetConfig } from '../../types' +import { logger } from '../../utils/logger' +import { prerenderWidgetState } from '../../utils/prerender' + +export interface GenerateSwiftFilesOptions { + targetPath: string + projectRoot: string + widgets?: WidgetConfig[] +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Generates all Swift files for the widget extension. + * + * This creates: + * - VoltraWidgetInitialStates.swift (pre-rendered widget states) + * - VoltraWidgetBundle.swift (widget bundle definition) + */ +export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Promise { + const { targetPath, projectRoot, widgets } = options + + // Dynamic import for ESM module compatibility + // voltra/server is an ESM module, but the plugin is compiled to CommonJS + const { renderWidgetToString } = await import('voltra/server') + + // Prerender widget initial states if any widgets have initialStatePath configured + const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot, renderWidgetToString) + + // Generate the initial states Swift file + const initialStatesContent = generateInitialStatesSwift(prerenderedStates) + const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') + fs.writeFileSync(initialStatesPath, initialStatesContent) + + logger.info(`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} pre-rendered widget states`) + + // Generate the widget bundle Swift file + const widgetBundleContent = + widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift() + + const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift') + fs.writeFileSync(widgetBundlePath, widgetBundleContent) + + logger.info(`Generated VoltraWidgetBundle.swift with ${widgets?.length ?? 0} home screen widgets`) +} + +// ============================================================================ +// Widget Bundle +// ============================================================================ + +/** + * Generates Swift code for a single widget struct + */ +function generateWidgetStruct(widget: WidgetConfig): string { + const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES + const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ') + + // Sanitize the widget id for use as a Swift identifier + const structName = `VoltraWidget_${widget.id}` + + return dedent` + public struct ${structName}: Widget { + private let widgetId = "${widget.id}" + + public init() {} + + public var body: some WidgetConfiguration { + StaticConfiguration( + kind: "Voltra_Widget_${widget.id}", + provider: VoltraHomeWidgetProvider( + widgetId: widgetId, + initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) + ) + ) { entry in + VoltraHomeWidgetView(entry: entry) + } + .configurationDisplayName("${widget.displayName}") + .description("${widget.description}") + .supportedFamilies([${familiesSwift}]) + .contentMarginsDisabled() + } + } + ` +} + +/** + * Generates the VoltraWidgetBundle.swift file content with configured widgets + */ +function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { + // Generate widget structs + const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') + + // Generate widget bundle body entries + const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + + return dedent` + // + // VoltraWidgetBundle.swift + // + // Auto-generated by Voltra config plugin. + // This file defines which Voltra widgets are available in your app. + // + + import SwiftUI + import WidgetKit + import VoltraWidget + + @main + struct VoltraWidgetBundle: WidgetBundle { + var body: some Widget { + // Live Activity (with Watch/CarPlay support) + VoltraWidget() + + // Home Screen Widgets + ${widgetInstances} + } + } + + // MARK: - Home Screen Widget Definitions + + ${widgetStructs} + ` +} + +/** + * Generates the VoltraWidgetBundle.swift file content when no widgets are configured + * (only Live Activities) + */ +function generateDefaultWidgetBundleSwift(): string { + return dedent` + // + // VoltraWidgetBundle.swift + // + // This file defines which Voltra widgets are available in your app. + // You can customize which widgets to include by adding or removing them below. + // + + import SwiftUI + import WidgetKit + import VoltraWidget // Import Voltra widgets + + @main + struct VoltraWidgetBundle: WidgetBundle { + var body: some Widget { + // Live Activity (with Watch/CarPlay support) + VoltraWidget() + } + } + ` +} + +// ============================================================================ +// Initial States +// ============================================================================ + +/** + * Generates Swift code that bundles pre-rendered widget initial states. + */ +function generateInitialStatesSwift(prerenderedStates: Map): string { + if (prerenderedStates.size === 0) { + return generateEmptyInitialStatesSwift() + } + + // Generate the bundled states dictionary + const stateEntries = Array.from(prerenderedStates.entries()) + .map(([widgetId, json]) => { + const delimiter = getSwiftRawStringDelimiter(json) + return `"${widgetId}": ${delimiter}"${json}"${delimiter}` + }) + .join(',\n ') + + return dedent` + // + // VoltraWidgetInitialStates.swift + // + // Auto-generated by Voltra config plugin. + // Contains pre-rendered initial states for home screen widgets. + // + + import Foundation + + public enum VoltraWidgetInitialStates { + private static let bundledStates: [String: String] = [ + ${stateEntries} + ] + + /// Get the bundled initial state JSON for a widget. + /// Returns nil if no initial state was configured for the widget. + public static func getInitialState(for widgetId: String) -> Data? { + guard let jsonString = bundledStates[widgetId] else { return nil } + return jsonString.data(using: .utf8) + } + } + ` +} + +/** + * Generates empty Swift code when no widgets have initial states configured. + */ +function generateEmptyInitialStatesSwift(): string { + return dedent` + // + // VoltraWidgetInitialStates.swift + // + // Auto-generated by Voltra config plugin. + // No widget initial states configured. + // + + import Foundation + + public enum VoltraWidgetInitialStates { + /// Get the bundled initial state JSON for a widget. + /// Always returns nil since no initial states are configured. + public static func getInitialState(for widgetId: String) -> Data? { + return nil + } + } + ` +} + +/** + * Determines the appropriate Swift raw string delimiter for a given string. + * Counts the maximum consecutive '#' characters after a '"' in the content + * and returns a delimiter with one more '#' than that maximum. + * + * For example: + * - Content has no '"#' → returns '#' + * - Content has '"#' (1 hash) → returns '##' + * - Content has '"##' (2 hashes) → returns '###' + */ +function getSwiftRawStringDelimiter(str: string): string { + // Find all sequences of '#' that follow a '"' + const matches = str.match(/"#+/g) + + if (!matches) { + return '#' + } + + // Find the maximum number of consecutive '#' after a '"' + const maxHashes = Math.max(...matches.map((m) => m.length - 1)) // -1 to exclude the '"' + return '#'.repeat(maxHashes + 1) +} diff --git a/plugin/src/features/ios/fonts.ts b/plugin/src/ios-widget/fonts.ts similarity index 73% rename from plugin/src/features/ios/fonts.ts rename to plugin/src/ios-widget/fonts.ts index 9678040..addf713 100644 --- a/plugin/src/features/ios/fonts.ts +++ b/plugin/src/ios-widget/fonts.ts @@ -7,14 +7,14 @@ * I would love to reuse the existing expo-font infrastructure, but it's not that easy to do. I'll most likely revisit it later. */ -import { type ConfigPlugin, type InfoPlist, IOSConfig, withXcodeProject } from '@expo/config-plugins' +import { type ConfigPlugin, type InfoPlist, IOSConfig, withDangerousMod, withXcodeProject } from '@expo/config-plugins' import plist from '@expo/plist' import type { ExpoConfig } from 'expo/config' import { readFileSync, writeFileSync } from 'fs' import * as fs from 'fs/promises' import * as path from 'path' -import { logger } from '../../utils' +import { logger } from '../utils/logger' const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'] @@ -42,6 +42,10 @@ export const withFonts: ConfigPlugin<{ fonts: string[]; targetName: string }> = */ function addFontsToTarget(config: ExpoConfig, fonts: string[], targetName: string) { return withXcodeProject(config, async (config) => { + if (config.modRequest.introspect) { + return config + } + const resolvedFonts = await resolveFontPaths(fonts, config.modRequest.projectRoot) const project = config.modResults const platformProjectRoot = config.modRequest.platformProjectRoot @@ -77,35 +81,42 @@ function addFontsToTarget(config: ExpoConfig, fonts: string[], targetName: strin * This makes iOS aware of the custom fonts and allows them to be used in SwiftUI. */ function addFontsToPlist(config: ExpoConfig, fonts: string[], targetName: string) { - return withXcodeProject(config, async (config) => { - const resolvedFonts = await resolveFontPaths(fonts, config.modRequest.projectRoot) - const platformProjectRoot = config.modRequest.platformProjectRoot + return withDangerousMod(config, [ + 'ios', + async (config) => { + if (config.modRequest.introspect) { + return config + } - // Read the Live Activity extension's Info.plist directly - const targetPath = path.join(platformProjectRoot, targetName) - const infoPlistPath = path.join(targetPath, 'Info.plist') + const resolvedFonts = await resolveFontPaths(fonts, config.modRequest.projectRoot) + const platformProjectRoot = config.modRequest.platformProjectRoot - try { - const plistContent = plist.parse(readFileSync(infoPlistPath, 'utf8')) as InfoPlist + // Read the Live Activity extension's Info.plist directly + const targetPath = path.join(platformProjectRoot, targetName) + const infoPlistPath = path.join(targetPath, 'Info.plist') - // Get existing fonts or initialize empty array - const existingFonts = getUIAppFonts(plistContent) + try { + const plistContent = plist.parse(readFileSync(infoPlistPath, 'utf8')) as InfoPlist - // Add new fonts - const fontList = resolvedFonts.map((font) => path.basename(font)) - const allFonts = [...existingFonts, ...fontList] - plistContent.UIAppFonts = Array.from(new Set(allFonts)) + // Get existing fonts or initialize empty array + const existingFonts = getUIAppFonts(plistContent) - // Write back to file - writeFileSync(infoPlistPath, plist.build(plistContent)) + // Add new fonts + const fontList = resolvedFonts.map((font) => path.basename(font)) + const allFonts = [...existingFonts, ...fontList] + plistContent.UIAppFonts = Array.from(new Set(allFonts)) - logger.info(`Added ${fontList.length} font(s) to ${targetName} Info.plist`) - } catch (error) { - logger.warn(`Could not update Info.plist for fonts: ${error}`) - } + // Write back to file + writeFileSync(infoPlistPath, plist.build(plistContent)) - return config - }) + logger.info(`Added ${fontList.length} font(s) to ${targetName} Info.plist`) + } catch (error) { + logger.warn(`Could not update Info.plist for fonts: ${error}`) + } + + return config + }, + ]) } /** diff --git a/plugin/src/features/ios/index.ts b/plugin/src/ios-widget/index.ts similarity index 66% rename from plugin/src/features/ios/index.ts rename to plugin/src/ios-widget/index.ts index 262f412..8fe328e 100644 --- a/plugin/src/features/ios/index.ts +++ b/plugin/src/ios-widget/index.ts @@ -1,10 +1,10 @@ import { ConfigPlugin, withPlugins } from '@expo/config-plugins' -import type { WidgetConfig } from '../../types' +import type { WidgetConfig } from '../types' import { configureEasBuild } from './eas' import { generateWidgetExtensionFiles } from './files' import { withFonts } from './fonts' -import { configureMainAppPlist } from './plist' +import { configureWidgetExtensionPlist } from './widgetPlist' import { configurePodfile } from './podfile' import { configureXcodeProject } from './xcode' @@ -21,12 +21,12 @@ export interface WithIOSProps { * Main iOS configuration plugin. * * This orchestrates all iOS-related configuration in the correct order: - * 1. Generate widget extension files (Swift, assets, plist, entitlements) - * 2. Add custom fonts (if provided) - * 3. Configure Xcode project (targets, build phases, groups) - * 4. Configure Podfile for widget extension - * 5. Configure main app Info.plist (URL schemes) - * 6. Configure EAS build settings + * 1. Add custom fonts (if provided) + * 2. Configure Xcode project (targets, build phases, groups) + * 3. Configure Podfile for widget extension + * 4. Configure main app Info.plist (URL schemes) + * 5. Configure EAS build settings + * 6. Generate widget extension files (Swift, assets, plist, entitlements) * * NOTE: Expo mods execute in REVERSE registration order. Plugins that depend * on modifications from other plugins must be registered BEFORE their dependencies. @@ -37,23 +37,23 @@ export const withIOS: ConfigPlugin = (config, props) => { const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier, fonts } = props const plugins: [ConfigPlugin, any][] = [ - // 1. Generate widget extension files (must run first so files exist) - [generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier }], - - // 2. Add custom fonts if provided + // 1. Add custom fonts if provided ...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin, any]] : []), - // 3. Configure Xcode project (creates the target - must run before fonts mod executes) + // 2. Configure Xcode project (creates the target - must run before fonts mod executes) [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget }], - // 4. Configure Podfile for widget extension target + // 3. Configure Podfile for widget extension target [configurePodfile, { targetName }], - // 5. Configure main app Info.plist (URL schemes, widget extension plist) - [configureMainAppPlist, { targetName, groupIdentifier }], + // 4. Configure main app Info.plist (URL schemes, widget extension plist) + [configureWidgetExtensionPlist, { targetName, groupIdentifier }], - // 6. Configure EAS build settings + // 5. Configure EAS build settings [configureEasBuild, { targetName, bundleIdentifier, groupIdentifier }], + + // 6. Generate widget extension files (dangerous mod should run before plist patchers) + [generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier }], ] return withPlugins(config, plugins) diff --git a/plugin/src/ios-widget/podfile.ts b/plugin/src/ios-widget/podfile.ts new file mode 100644 index 0000000..e0daea1 --- /dev/null +++ b/plugin/src/ios-widget/podfile.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { ConfigPlugin, withPodfile as withExpoPodfile } from '@expo/config-plugins' + +import { logger } from '../utils/logger' + +/** + * Generates the Podfile target content for the widget extension. + */ +function getTargetContent(targetName: string, libraryName: string): string { + const backtick = '`' + return ` +# Voltra Widget Extension Target +# DO NOT MODIFY THIS FILE - IT IS AUTO-GENERATED BY THE VOLTRA PLUGIN +target '${targetName}' do + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + + require 'json' + require 'pathname' + project_root = "#{Pod::Config.instance.installation_root}/.." + podspec_name = 'VoltraWidget.podspec' + node_modules_podspec = File.join(project_root, 'node_modules', '${libraryName}', 'ios', podspec_name) + monorepo_podspec = File.expand_path("../ios/#{podspec_name}", project_root) + + podspec_dir_path = nil + if File.exist?(node_modules_podspec) + podspec_dir_path = File.dirname(node_modules_podspec) + elsif File.exist?(monorepo_podspec) + podspec_dir_path = File.dirname(monorepo_podspec) + else + voltra_module = JSON.parse(${backtick}npx expo-modules-autolinking search -p apple --json --project-root #{project_root}${backtick}) + podspec_dir_path = File.join(voltra_module['${libraryName}']['path'], 'ios') + end + + podspec_dir_path = Pathname.new(podspec_dir_path).relative_path_from(Pathname.new(__dir__)).to_path + + pod 'VoltraWidget', :path => podspec_dir_path +end` +} + +export interface ConfigurePodfileProps { + targetName: string +} + +function getLibraryName(): string { + const candidatePaths = [ + path.join(__dirname, '..', '..', '..', 'package.json'), + path.join(__dirname, '..', '..', '..', '..', 'package.json'), + ] + + for (const candidatePath of candidatePaths) { + if (!fs.existsSync(candidatePath)) { + continue + } + + try { + const packageJson = fs.readFileSync(candidatePath, 'utf8') + const name = JSON.parse(packageJson).name + if (typeof name === 'string' && name.trim()) { + return name + } + } catch (error) { + logger.warn(`Failed to read package name from ${candidatePath}: ${error}`) + } + } + + logger.warn('Could not resolve package name for Voltra plugin. Falling back to "voltra".') + return 'voltra' +} + +/** + * Plugin step that adds the Podfile target for the widget extension. + */ +export const configurePodfile: ConfigPlugin = (config, { targetName }) => { + return withExpoPodfile(config, (podfile) => { + if (podfile.modRequest.introspect) { + return podfile + } + + const libraryName = getLibraryName() + const targetContent = getTargetContent(targetName, libraryName) + + // Check if target already exists (avoid duplicates) + const targetMarker = "target '" + targetName + "'" + if (podfile.modResults.contents.includes(targetMarker)) { + return podfile + } + + podfile.modResults.contents = podfile.modResults.contents + '\n' + targetContent + return podfile + }) +} diff --git a/plugin/src/ios-widget/widgetPlist.ts b/plugin/src/ios-widget/widgetPlist.ts new file mode 100644 index 0000000..83e6452 --- /dev/null +++ b/plugin/src/ios-widget/widgetPlist.ts @@ -0,0 +1,79 @@ +import { ConfigPlugin, InfoPlist, withDangerousMod } from '@expo/config-plugins' +import plist from '@expo/plist' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join as joinPath } from 'path' + +import { logger } from '../utils/logger' + +export interface ConfigureMainAppPlistProps { + targetName: string + groupIdentifier?: string +} + +/** + * Plugin step that configures the Info.plist files. + * + * This: + * - Updates the widget extension's Info.plist with URL schemes + * - Removes incompatible NSExtension keys for WidgetKit + * - Adds group identifier if configured + */ +export const configureWidgetExtensionPlist: ConfigPlugin = ( + expoConfig, + { targetName, groupIdentifier } +) => + withDangerousMod(expoConfig, [ + 'ios', + async (config) => { + if (config.modRequest.introspect) { + return config + } + + const scheme = typeof expoConfig.scheme === 'string' ? expoConfig.scheme : expoConfig.ios?.bundleIdentifier + + if (scheme) { + const targetPath = joinPath(config.modRequest.platformProjectRoot, targetName) + const filePath = joinPath(targetPath, 'Info.plist') + if (!existsSync(filePath)) { + logger.warn(`Widget Info.plist not found at ${filePath}, skipping widget plist updates`) + return config + } + + const content = plist.parse(readFileSync(filePath, 'utf8')) as InfoPlist + + // WidgetKit extensions must NOT declare NSExtensionPrincipalClass/MainStoryboard. + // The @main WidgetBundle in Swift is the entry point. + const ext = (content as any).NSExtension as Record | undefined + if (ext) { + delete ext.NSExtensionPrincipalClass + delete ext.NSExtensionMainStoryboard + } + + // Keep URL schemes in the widget extension so Live Activity links can be resolved + // from relative to absolute URLs (see VoltraDeepLinkResolver.swift). + const existingTypes = (content.CFBundleURLTypes as any[]) || [] + const hasScheme = existingTypes.some( + (t) => Array.isArray(t?.CFBundleURLSchemes) && t.CFBundleURLSchemes.includes(scheme) + ) + if (!hasScheme) { + content.CFBundleURLTypes = [ + ...existingTypes, + { + CFBundleURLSchemes: [scheme], + }, + ] + } else { + content.CFBundleURLTypes = existingTypes + } + + // Only set group identifier if provided + if (groupIdentifier) { + ;(content as any)['Voltra_AppGroupIdentifier'] = groupIdentifier + } + + writeFileSync(filePath, plist.build(content)) + } + + return config + }, + ]) diff --git a/plugin/src/ios-widget/xcode/buildPhases.ts b/plugin/src/ios-widget/xcode/buildPhases.ts new file mode 100644 index 0000000..68bfd54 --- /dev/null +++ b/plugin/src/ios-widget/xcode/buildPhases.ts @@ -0,0 +1,300 @@ +import { XcodeProject } from '@expo/config-plugins' +import * as util from 'util' + +import type { WidgetFiles } from '../../types' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pbxFile = require('xcode/lib/pbxFile') + +export interface AddBuildPhasesOptions { + targetUuid: string + groupName: string + productFile: { + uuid: string + target?: string + basename: string + group: string + } + widgetFiles: WidgetFiles +} + +export interface EnsureBuildPhasesOptions extends AddBuildPhasesOptions { + mainTargetUuid?: string +} + +/** + * Adds all required build phases for the widget extension target. + */ +export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhasesOptions): void { + const { targetUuid, groupName, productFile, widgetFiles } = options + const buildPath = `""` + const folderType = 'app_extension' + + const { swiftFiles, intentFiles, assetDirectories } = widgetFiles + + // Sources build phase + xcodeProject.addBuildPhase( + [...swiftFiles, ...intentFiles], + 'PBXSourcesBuildPhase', + 'Sources', + targetUuid, + folderType, + buildPath + ) + + // Copy files build phase + xcodeProject.addBuildPhase( + [], + 'PBXCopyFilesBuildPhase', + groupName, + xcodeProject.getFirstTarget().uuid, + folderType, + buildPath + ) + + xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, productFile.target).files.push({ + value: productFile.uuid, + comment: util.format('%s in %s', productFile.basename, productFile.group), + }) + xcodeProject.addToPbxBuildFileSection(productFile) + + // Frameworks build phase + xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) + + // Resources build phase + xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid) +} + +/** + * Ensures all required build phases and files exist for the widget extension target. + */ +export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBuildPhasesOptions): void { + const { targetUuid, groupName, productFile, widgetFiles } = options + const buildPath = `""` + const folderType = 'app_extension' + const mainTargetUuid = options.mainTargetUuid ?? xcodeProject.getFirstTarget().uuid + + const { swiftFiles, intentFiles, assetDirectories } = widgetFiles + + dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') + dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') + dedupeBuildPhasesByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) + + // Sources build phase + let sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + if (!sourcesPhase) { + xcodeProject.addBuildPhase( + [...swiftFiles, ...intentFiles], + 'PBXSourcesBuildPhase', + 'Sources', + targetUuid, + folderType, + buildPath + ) + sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + } + if (sourcesPhase) { + ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles]) + } + + // Copy files build phase (embed extension into main app) + let copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + if (!copyFilesPhase) { + xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath) + copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + } + if (copyFilesPhase) { + ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile) + } + + // Frameworks build phase + const frameworksPhase = xcodeProject.buildPhaseObject('PBXFrameworksBuildPhase', 'Frameworks', targetUuid) + if (!frameworksPhase) { + xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) + } + + // Resources build phase + let resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + if (!resourcesPhase) { + xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid) + resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + } + if (resourcesPhase) { + ensureBuildPhaseFiles(xcodeProject, resourcesPhase, [...assetDirectories]) + } +} + +function dedupeBuildPhasesForTarget( + xcodeProject: XcodeProject, + targetUuid: string, + phaseType: string, + preferredComment: string +): void { + const nativeTargets = xcodeProject.pbxNativeTargetSection() + const target = nativeTargets[targetUuid] + if (!target?.buildPhases) { + return + } + + const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} + const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value]) + if (matching.length <= 1) { + return + } + + const keep = matching.find((entry: any) => entry.comment === preferredComment) ?? matching[0] + keep.comment = preferredComment + + target.buildPhases = target.buildPhases.filter((entry: any) => { + if (!phaseSection[entry.value]) { + return true + } + return entry.value === keep.value + }) +} + +function dedupeBuildPhasesByComment( + xcodeProject: XcodeProject, + targetUuid: string, + phaseType: string, + comment: string +): void { + const nativeTargets = xcodeProject.pbxNativeTargetSection() + const target = nativeTargets[targetUuid] + if (!target?.buildPhases) { + return + } + + const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} + const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value] && entry.comment === comment) + if (matching.length <= 1) { + return + } + + const keep = matching[0] + target.buildPhases = target.buildPhases.filter((entry: any) => { + if (!phaseSection[entry.value] || entry.comment !== comment) { + return true + } + return entry.value === keep.value + }) +} + +function normalizePath(value: string | undefined): string { + if (!value) { + return '' + } + return value.replace(/^"|"$/g, '') +} + +function findFileReferenceKey(xcodeProject: XcodeProject, filePath: string): string | null { + const fileReferenceSection = xcodeProject.pbxFileReferenceSection() + const normalizedPath = normalizePath(filePath) + + for (const key of Object.keys(fileReferenceSection)) { + if (/_comment$/.test(key)) { + continue + } + const entry = fileReferenceSection[key] + const entryPath = normalizePath(entry?.path) + if (entryPath === normalizedPath) { + return key + } + } + + return null +} + +function findBuildFileKeyByFileRef(xcodeProject: XcodeProject, fileRef: string): string | null { + const buildFileSection = xcodeProject.pbxBuildFileSection() + for (const key of Object.keys(buildFileSection)) { + if (/_comment$/.test(key)) { + continue + } + const entry = buildFileSection[key] + if (entry?.fileRef === fileRef) { + return key + } + } + return null +} + +function ensureFileReference(xcodeProject: XcodeProject, filePath: string) { + const existingFileRef = findFileReferenceKey(xcodeProject, filePath) + const file = new pbxFile(filePath) + + if (existingFileRef) { + return { fileRef: existingFileRef, basename: file.basename, group: file.group } + } + + file.fileRef = xcodeProject.generateUuid() + xcodeProject.addToPbxFileReferenceSection(file) + return { fileRef: file.fileRef, basename: file.basename, group: file.group } +} + +function ensureBuildFile(xcodeProject: XcodeProject, filePath: string) { + const fileReference = ensureFileReference(xcodeProject, filePath) + const existingBuildFile = findBuildFileKeyByFileRef(xcodeProject, fileReference.fileRef) + + if (existingBuildFile) { + return { uuid: existingBuildFile, ...fileReference } + } + + const file = new pbxFile(filePath) + file.uuid = xcodeProject.generateUuid() + file.fileRef = fileReference.fileRef + xcodeProject.addToPbxBuildFileSection(file) + return { uuid: file.uuid, ...fileReference, group: file.group } +} + +function buildPhaseHasFile(xcodeProject: XcodeProject, buildPhase: any, fileRef: string): boolean { + if (!buildPhase?.files) { + return false + } + const buildFileSection = xcodeProject.pbxBuildFileSection() + + return buildPhase.files.some((entry: any) => { + const buildFile = buildFileSection[entry.value] + return buildFile?.fileRef === fileRef + }) +} + +function ensureBuildPhaseFiles(xcodeProject: XcodeProject, buildPhase: any, filePaths: string[]): void { + if (!buildPhase.files) { + buildPhase.files = [] + } + + for (const filePath of filePaths) { + const fileReference = ensureFileReference(xcodeProject, filePath) + if (buildPhaseHasFile(xcodeProject, buildPhase, fileReference.fileRef)) { + continue + } + + const buildFile = ensureBuildFile(xcodeProject, filePath) + buildPhase.files.push({ + value: buildFile.uuid, + comment: util.format('%s in %s', buildFile.basename, buildFile.group), + }) + } +} + +function ensureCopyFilesPhaseProduct(xcodeProject: XcodeProject, buildPhase: any, productFile: any): void { + if (!buildPhase.files) { + buildPhase.files = [] + } + + const alreadyExists = buildPhase.files.some((entry: any) => entry.value === productFile.uuid) + if (alreadyExists) { + return + } + + const buildFileSection = xcodeProject.pbxBuildFileSection() + if (!buildFileSection[productFile.uuid]) { + xcodeProject.addToPbxBuildFileSection(productFile) + } + + buildPhase.files.push({ + value: productFile.uuid, + comment: util.format('%s in %s', productFile.basename, productFile.group), + }) +} diff --git a/plugin/src/features/ios/xcode/build/configurationList.ts b/plugin/src/ios-widget/xcode/configurationList.ts similarity index 53% rename from plugin/src/features/ios/xcode/build/configurationList.ts rename to plugin/src/ios-widget/xcode/configurationList.ts index 55b93d8..e7dd7c4 100644 --- a/plugin/src/features/ios/xcode/build/configurationList.ts +++ b/plugin/src/ios-widget/xcode/configurationList.ts @@ -1,6 +1,6 @@ import { XcodeProject } from '@expo/config-plugins' -import { IOS } from '../../../../constants' +import { IOS } from '../../constants' export interface AddConfigurationListOptions { targetName: string @@ -13,10 +13,7 @@ export interface AddConfigurationListOptions { provisioningProfileSpecifier?: string } -/** - * Adds the XCConfigurationList for the widget extension target. - */ -export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) { +function createCommonBuildSettings(options: AddConfigurationListOptions) { const { targetName, currentProjectVersion, @@ -28,21 +25,26 @@ export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddC provisioningProfileSpecifier, } = options - const commonBuildSettings: any = { + const commonBuildSettings: Record = { PRODUCT_NAME: `"$(TARGET_NAME)"`, SWIFT_VERSION: IOS.SWIFT_VERSION, TARGETED_DEVICE_FAMILY: `"${IOS.DEVICE_FAMILY}"`, INFOPLIST_FILE: `${targetName}/Info.plist`, + INFOPLIST_OUTPUT_FORMAT: `"xml"`, CURRENT_PROJECT_VERSION: `"${currentProjectVersion}"`, IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`, PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, GENERATE_INFOPLIST_FILE: `"YES"`, INFOPLIST_KEY_CFBundleDisplayName: targetName, INFOPLIST_KEY_NSHumanReadableCopyright: `""`, - MARKETING_VERSION: `"${marketingVersion}"`, SWIFT_OPTIMIZATION_LEVEL: `"-Onone"`, CODE_SIGN_ENTITLEMENTS: `"${targetName}/${targetName}.entitlements"`, APPLICATION_EXTENSION_API_ONLY: '"YES"', + ASSETCATALOG_COMPILER_APPICON_NAME: '""', + } + + if (marketingVersion) { + commonBuildSettings.MARKETING_VERSION = `"${marketingVersion}"` } // Synchronize code signing settings from main app target @@ -56,6 +58,16 @@ export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddC commonBuildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${provisioningProfileSpecifier}"` } + return commonBuildSettings +} + +/** + * Adds the XCConfigurationList for the widget extension target. + */ +export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) { + const { targetName } = options + const commonBuildSettings = createCommonBuildSettings(options) + const buildConfigurationsList = [ { name: 'Debug', @@ -81,3 +93,45 @@ export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddC return xCConfigurationList } + +/** + * Ensures an existing XCConfigurationList is updated, or adds a new one if missing. + */ +export function ensureXCConfigurationList( + xcodeProject: XcodeProject, + options: AddConfigurationListOptions, + existingConfigurationListId?: string | { value?: string } +) { + const configurationListId = + typeof existingConfigurationListId === 'string' + ? existingConfigurationListId.split(' ')[0] + : existingConfigurationListId?.value?.split(' ')[0] + const configurationLists = xcodeProject.pbxXCConfigurationList() + const configurationList = configurationListId ? configurationLists?.[configurationListId] : null + + if (!configurationList || !configurationList.buildConfigurations) { + return addXCConfigurationList(xcodeProject, options) + } + + const commonBuildSettings = createCommonBuildSettings(options) + const buildConfigurations = configurationList.buildConfigurations + const buildConfigurationSection = xcodeProject.pbxXCBuildConfigurationSection() + + for (const configRef of buildConfigurations) { + const configId = typeof configRef === 'string' ? configRef.split(' ')[0] : configRef.value?.split(' ')[0] + if (!configId) { + continue + } + const buildConfiguration = buildConfigurationSection[configId] + if (!buildConfiguration) { + continue + } + + buildConfiguration.buildSettings = { + ...(buildConfiguration.buildSettings ?? {}), + ...commonBuildSettings, + } + } + + return { uuid: configurationListId } +} diff --git a/plugin/src/ios-widget/xcode/groups.ts b/plugin/src/ios-widget/xcode/groups.ts new file mode 100644 index 0000000..e5bbd1d --- /dev/null +++ b/plugin/src/ios-widget/xcode/groups.ts @@ -0,0 +1,86 @@ +import { XcodeProject } from '@expo/config-plugins' + +import type { WidgetFiles } from '../../types' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pbxFile = require('xcode/lib/pbxFile') + +export interface AddPbxGroupOptions { + targetName: string + widgetFiles: WidgetFiles +} + +/** + * Adds a PBXGroup for the widget extension files. + */ +export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { + const { targetName, widgetFiles } = options + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles + + // Add PBX group with all widget files + const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup( + [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories], + targetName, + targetName + ) + + // Add PBXGroup to top level group + const groups = xcodeProject.hash.project.objects['PBXGroup'] + if (pbxGroupUuid) { + Object.keys(groups).forEach(function (key) { + if (groups[key].name === undefined && groups[key].path === undefined) { + xcodeProject.addToPbxGroup(pbxGroupUuid, key) + } + }) + } +} + +/** + * Ensures a PBXGroup exists for the widget extension files. + */ +export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { + const { targetName, widgetFiles } = options + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles + const allFiles = [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories] + + const existingGroup = xcodeProject.pbxGroupByName(targetName) + if (!existingGroup) { + addPbxGroup(xcodeProject, options) + return + } + + if (!existingGroup.children) { + existingGroup.children = [] + } + + for (const filePath of allFiles) { + const file = new pbxFile(filePath) + const fileRef = ensureFileReference(xcodeProject, filePath) + const alreadyInGroup = existingGroup.children.some( + (child: any) => child.value === fileRef || child.comment === file.basename + ) + if (!alreadyInGroup) { + existingGroup.children.push({ value: fileRef, comment: file.basename }) + } + } +} + +function ensureFileReference(xcodeProject: XcodeProject, filePath: string): string { + const fileReferenceSection = xcodeProject.pbxFileReferenceSection() + const file = new pbxFile(filePath) + + for (const key of Object.keys(fileReferenceSection)) { + if (/_comment$/.test(key)) { + continue + } + const entry = fileReferenceSection[key] + const entryPath = typeof entry?.path === 'string' ? entry.path.replace(/^"|"$/g, '') : '' + if (entryPath === file.path || entryPath === filePath) { + return key + } + } + + file.fileRef = xcodeProject.generateUuid() + xcodeProject.addToPbxFileReferenceSection(file) + return file.fileRef +} diff --git a/plugin/src/features/ios/xcode/index.ts b/plugin/src/ios-widget/xcode/index.ts similarity index 53% rename from plugin/src/features/ios/xcode/index.ts rename to plugin/src/ios-widget/xcode/index.ts index 8c8c3e3..1ac9fe4 100644 --- a/plugin/src/features/ios/xcode/index.ts +++ b/plugin/src/ios-widget/xcode/index.ts @@ -1,13 +1,13 @@ import { ConfigPlugin, withXcodeProject } from '@expo/config-plugins' import * as path from 'path' -import { getWidgetFiles } from '../../../utils' -import { configureBuild } from './build' -import { addXCConfigurationList } from './build/configurationList' -import { addPbxGroup } from './groups' -import { addProductFile } from './productFile' -import { configureTarget } from './target' -import { getMainAppTargetSettings } from './utils/getMainAppTargetSettings' +import { getWidgetFiles } from '../../utils/fileDiscovery' +import { addBuildPhases, ensureBuildPhases } from './buildPhases' +import { addXCConfigurationList, ensureXCConfigurationList } from './configurationList' +import { addPbxGroup, ensurePbxGroup } from './groups' +import { addProductFile, ensureProductFile } from './productFile' +import { configureTarget, ensureTargetAttributes, ensureTargetDependency } from './target' +import { getMainAppTargetSettings } from './mainAppSettings' export interface ConfigureXcodeProjectProps { targetName: string @@ -31,22 +31,22 @@ export const configureXcodeProject: ConfigPlugin = ( const { targetName, bundleIdentifier, deploymentTarget } = props return withXcodeProject(config, (config) => { + if (config.modRequest.introspect) { + return config + } + const xcodeProject = config.modResults const groupName = 'Embed Foundation Extensions' // Check if target already exists const nativeTargets = xcodeProject.pbxNativeTargetSection() - const existingTarget = Object.values(nativeTargets).find((target: any) => target.name === targetName) - - if (existingTarget) { - return config - } + const existingTargetKey = xcodeProject.findTargetKey(targetName) + const existingTarget = existingTargetKey ? nativeTargets[existingTargetKey] : null const { platformProjectRoot } = config.modRequest const targetPath = path.join(platformProjectRoot, targetName) const widgetFiles = getWidgetFiles(targetPath, targetName) - const targetUuid = xcodeProject.generateUuid() const currentProjectVersion = config.ios?.buildNumber || '1' const marketingVersion = config.version @@ -56,7 +56,62 @@ export const configureXcodeProject: ConfigPlugin = ( // Use the deploymentTarget from plugin config (or default), ignore main app's deployment target // This allows the widget extension to have its own deployment target independent of the main app + if (existingTarget && existingTargetKey) { + // Ensure configuration list is up to date + const xCConfigurationList = ensureXCConfigurationList( + xcodeProject, + { + targetName, + currentProjectVersion, + bundleIdentifier, + deploymentTarget, + marketingVersion, + codeSignStyle: mainAppSettings?.codeSignStyle, + developmentTeam: mainAppSettings?.developmentTeam, + provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, + }, + existingTarget.buildConfigurationList + ) + + // Ensure product file exists + const productFile = ensureProductFile(xcodeProject, { + targetName, + groupName, + }) + + // Update target references + existingTarget.productReference = productFile.fileRef + existingTarget.buildConfigurationList = xCConfigurationList.uuid + existingTarget.productType = `"com.apple.product-type.app-extension"` + existingTarget.name = targetName + existingTarget.productName = targetName + if (!existingTarget.buildPhases) { + existingTarget.buildPhases = [] + } + + // Ensure build phases and groups + ensureBuildPhases(xcodeProject, { + targetUuid: existingTargetKey, + groupName, + productFile, + widgetFiles, + mainTargetUuid: xcodeProject.getFirstTarget().uuid, + }) + + ensurePbxGroup(xcodeProject, { + targetName, + widgetFiles, + }) + + ensureTargetAttributes(xcodeProject, existingTargetKey) + ensureTargetDependency(xcodeProject, existingTargetKey) + + return config + } + // Add configuration list + const targetUuid = xcodeProject.generateUuid() + const xCConfigurationList = addXCConfigurationList(xcodeProject, { targetName, currentProjectVersion, @@ -82,20 +137,12 @@ export const configureXcodeProject: ConfigPlugin = ( xCConfigurationList, }) - // Configure build phases - configureBuild(xcodeProject, { - targetName, + // Add build phases + addBuildPhases(xcodeProject, { targetUuid, - bundleIdentifier, - deploymentTarget, - currentProjectVersion, - marketingVersion, groupName, productFile, widgetFiles, - codeSignStyle: mainAppSettings?.codeSignStyle, - developmentTeam: mainAppSettings?.developmentTeam, - provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, }) // Add PBX group diff --git a/plugin/src/features/ios/xcode/utils/getMainAppTargetSettings.ts b/plugin/src/ios-widget/xcode/mainAppSettings.ts similarity index 100% rename from plugin/src/features/ios/xcode/utils/getMainAppTargetSettings.ts rename to plugin/src/ios-widget/xcode/mainAppSettings.ts diff --git a/plugin/src/ios-widget/xcode/productFile.ts b/plugin/src/ios-widget/xcode/productFile.ts new file mode 100644 index 0000000..d2f9ee2 --- /dev/null +++ b/plugin/src/ios-widget/xcode/productFile.ts @@ -0,0 +1,91 @@ +import { XcodeProject } from '@expo/config-plugins' + +export interface AddProductFileOptions { + targetName: string + groupName: string +} + +function findProductFileReference(xcodeProject: XcodeProject, targetName: string): { fileRef: string } | null { + const fileReferenceSection = xcodeProject.pbxFileReferenceSection() + const targetProductName = `${targetName}.appex` + + for (const key of Object.keys(fileReferenceSection)) { + if (/_comment$/.test(key)) { + continue + } + const entry = fileReferenceSection[key] + const path = typeof entry?.path === 'string' ? entry.path.replace(/^"|"$/g, '') : '' + const name = typeof entry?.name === 'string' ? entry.name.replace(/^"|"$/g, '') : '' + + if (path === targetProductName || name === targetProductName) { + return { fileRef: key } + } + } + + return null +} + +function findBuildFileForFileRef(xcodeProject: XcodeProject, fileRef: string): { uuid: string } | null { + const buildFileSection = xcodeProject.pbxBuildFileSection() + + for (const key of Object.keys(buildFileSection)) { + if (/_comment$/.test(key)) { + continue + } + const entry = buildFileSection[key] + if (entry?.fileRef === fileRef) { + return { uuid: key } + } + } + + return null +} + +/** + * Adds the product file (.appex) for the widget extension. + */ +export function addProductFile(xcodeProject: XcodeProject, options: AddProductFileOptions) { + const { targetName, groupName } = options + + const productFileOptions = { + basename: `${targetName}.appex`, + group: groupName, + explicitFileType: 'wrapper.app-extension', + settings: { + ATTRIBUTES: ['RemoveHeadersOnCopy'], + }, + includeInIndex: 0, + path: `${targetName}.appex`, + sourceTree: 'BUILT_PRODUCTS_DIR', + } + + const productFile = xcodeProject.addProductFile(targetName, productFileOptions) + + return productFile +} + +/** + * Ensures the product file (.appex) exists and returns it. + */ +export function ensureProductFile(xcodeProject: XcodeProject, options: AddProductFileOptions) { + const { targetName, groupName } = options + const existingFile = findProductFileReference(xcodeProject, targetName) + + if (!existingFile) { + return addProductFile(xcodeProject, options) + } + + const buildFile = findBuildFileForFileRef(xcodeProject, existingFile.fileRef) + const productFile = { + uuid: buildFile?.uuid ?? xcodeProject.generateUuid(), + fileRef: existingFile.fileRef, + basename: `${targetName}.appex`, + group: groupName, + } + + if (!buildFile) { + xcodeProject.addToPbxBuildFileSection(productFile as any) + } + + return productFile +} diff --git a/plugin/src/ios-widget/xcode/target.ts b/plugin/src/ios-widget/xcode/target.ts new file mode 100644 index 0000000..33ff911 --- /dev/null +++ b/plugin/src/ios-widget/xcode/target.ts @@ -0,0 +1,148 @@ +import { XcodeProject } from '@expo/config-plugins' + +import { IOS } from '../../constants' + +// ============================================================================ +// Types +// ============================================================================ + +export interface ConfigureTargetOptions { + targetName: string + targetUuid: string + productFile: { fileRef: string } + xCConfigurationList: { uuid: string } +} + +interface AddNativeTargetOptions { + targetName: string + targetUuid: string + productFile: { fileRef: string } + xCConfigurationList: { uuid: string } +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Configures the widget extension target in the Xcode project. + * + * This: + * - Adds the target to PBXNativeTarget section + * - Adds the target to PBXProject section + * - Adds a dependency from the main app to the widget extension + */ +export function configureTarget(xcodeProject: XcodeProject, options: ConfigureTargetOptions) { + const target = addToPbxNativeTargetSection(xcodeProject, options) + addToPbxProjectSection(xcodeProject, target) + addTargetDependency(xcodeProject, target) + + return target +} + +/** + * Ensures target attributes (LastSwiftMigration) exist for the target. + */ +export function ensureTargetAttributes(xcodeProject: XcodeProject, targetUuid: string): void { + const projectSection = xcodeProject.pbxProjectSection() + const firstProject = xcodeProject.getFirstProject() + + if (!projectSection[firstProject.uuid].attributes.TargetAttributes) { + projectSection[firstProject.uuid].attributes.TargetAttributes = {} + } + + if (!projectSection[firstProject.uuid].attributes.TargetAttributes[targetUuid]) { + projectSection[firstProject.uuid].attributes.TargetAttributes[targetUuid] = { + LastSwiftMigration: IOS.LAST_SWIFT_MIGRATION, + } + } +} + +/** + * Ensures a target dependency exists so the main app depends on the widget extension. + */ +export function ensureTargetDependency(xcodeProject: XcodeProject, targetUuid: string): void { + if (!xcodeProject.hash.project.objects['PBXTargetDependency']) { + xcodeProject.hash.project.objects['PBXTargetDependency'] = {} + } + if (!xcodeProject.hash.project.objects['PBXContainerItemProxy']) { + xcodeProject.hash.project.objects['PBXContainerItemProxy'] = {} + } + + const mainTargetUuid = xcodeProject.getFirstTarget().uuid + const mainTarget = xcodeProject.pbxNativeTargetSection()[mainTargetUuid] + const existingDeps = mainTarget?.dependencies ?? [] + const targetDependencySection = xcodeProject.hash.project.objects['PBXTargetDependency'] || {} + + const alreadyExists = existingDeps.some((dep: any) => { + const dependency = targetDependencySection[dep.value] + return dependency?.target === targetUuid + }) + + if (!alreadyExists) { + xcodeProject.addTargetDependency(mainTargetUuid, [targetUuid]) + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Adds the widget extension target to the PBXNativeTarget section. + */ +function addToPbxNativeTargetSection(xcodeProject: XcodeProject, options: AddNativeTargetOptions) { + const { targetName, targetUuid, productFile, xCConfigurationList } = options + + const target = { + uuid: targetUuid, + pbxNativeTarget: { + isa: 'PBXNativeTarget', + name: targetName, + productName: targetName, + productReference: productFile.fileRef, + productType: `"com.apple.product-type.app-extension"`, + buildConfigurationList: xCConfigurationList.uuid, + buildPhases: [], + buildRules: [], + dependencies: [], + }, + } + + xcodeProject.addToPbxNativeTargetSection(target) + + return target +} + +/** + * Adds the target to the PBXProject section. + */ +function addToPbxProjectSection(xcodeProject: XcodeProject, target: { uuid: string }): void { + xcodeProject.addToPbxProjectSection(target) + + // Add target attributes to project section + const projectSection = xcodeProject.pbxProjectSection() + const firstProject = xcodeProject.getFirstProject() + + if (!projectSection[firstProject.uuid].attributes.TargetAttributes) { + projectSection[firstProject.uuid].attributes.TargetAttributes = {} + } + + projectSection[firstProject.uuid].attributes.TargetAttributes[target.uuid] = { + LastSwiftMigration: IOS.LAST_SWIFT_MIGRATION, + } +} + +/** + * Adds a target dependency so the main app depends on the widget extension. + */ +function addTargetDependency(xcodeProject: XcodeProject, target: { uuid: string }): void { + if (!xcodeProject.hash.project.objects['PBXTargetDependency']) { + xcodeProject.hash.project.objects['PBXTargetDependency'] = {} + } + if (!xcodeProject.hash.project.objects['PBXContainerItemProxy']) { + xcodeProject.hash.project.objects['PBXContainerItemProxy'] = {} + } + + xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [target.uuid]) +} diff --git a/plugin/src/ios/eas.ts b/plugin/src/ios/eas.ts new file mode 100644 index 0000000..eb8f20c --- /dev/null +++ b/plugin/src/ios/eas.ts @@ -0,0 +1,33 @@ +import { ConfigPlugin } from '@expo/config-plugins' + +import { addApplicationGroupsEntitlement } from '../utils/entitlements' + +export interface ConfigureEasProps { + groupIdentifier?: string +} + +/** + * Configures main app for EAS builds. + * + * This adds the app group entitlement to the main app's entitlements + * so that EAS can properly configure provisioning profiles. + */ +export const configureEas: ConfigPlugin = (config, props = {}) => { + if (!props.groupIdentifier) { + // No group identifier provided, skip EAS configuration + return config + } + + // Ensure ios.entitlements exists in config for EAS to detect + if (!config.ios) { + config.ios = {} + } + if (!config.ios.entitlements) { + config.ios.entitlements = {} + } + + // Add app groups entitlement for EAS + addApplicationGroupsEntitlement(config.ios.entitlements, props.groupIdentifier) + + return config +} diff --git a/plugin/src/ios/entitlements.ts b/plugin/src/ios/entitlements.ts new file mode 100644 index 0000000..baa97aa --- /dev/null +++ b/plugin/src/ios/entitlements.ts @@ -0,0 +1,31 @@ +import { ConfigPlugin } from '@expo/config-plugins' + +import { addApplicationGroupsEntitlement } from '../utils/entitlements' + +export interface ConfigureEntitlementsProps { + groupIdentifier?: string +} + +/** + * Configures main app entitlements for app groups. + * + * This adds the com.apple.security.application-groups entitlement + * to allow sharing data between the main app and widget extension. + */ +export const configureEntitlements: ConfigPlugin = (config, props = {}) => { + if (!props.groupIdentifier) { + // No group identifier provided, skip entitlements configuration + return config + } + + if (!config.ios) { + config.ios = {} + } + if (!config.ios.entitlements) { + config.ios.entitlements = {} + } + + addApplicationGroupsEntitlement(config.ios.entitlements, props.groupIdentifier) + + return config +} diff --git a/plugin/src/ios/index.ts b/plugin/src/ios/index.ts new file mode 100644 index 0000000..1961cdc --- /dev/null +++ b/plugin/src/ios/index.ts @@ -0,0 +1,41 @@ +import type { ExpoConfig } from 'expo/config' + +import { configureEntitlements } from './entitlements' +import { configureInfoPlist } from './infoPlist' +import { configureEas } from './eas' + +export interface IOSConfigProps { + groupIdentifier?: string + widgetIds?: string[] +} + +/** + * Main iOS app configuration. + * + * This configures the main app (not the widget extension) for: + * - Live Activities support (Info.plist) + * - App groups for widget communication (entitlements) + * - EAS build configuration + */ +export function withIOS(config: ExpoConfig, props: IOSConfigProps): ExpoConfig { + // Configure Info.plist + config = configureInfoPlist(config, { + groupIdentifier: props.groupIdentifier, + widgetIds: props.widgetIds, + }) + + // Configure entitlements + config = configureEntitlements(config, { + groupIdentifier: props.groupIdentifier, + }) + + // Configure EAS + config = configureEas(config, { + groupIdentifier: props.groupIdentifier, + }) + + return config +} + +// Re-export for convenience +export { withPushNotifications } from './pushNotifications' diff --git a/plugin/src/ios/infoPlist.ts b/plugin/src/ios/infoPlist.ts new file mode 100644 index 0000000..736b2be --- /dev/null +++ b/plugin/src/ios/infoPlist.ts @@ -0,0 +1,33 @@ +import { ConfigPlugin, withInfoPlist } from '@expo/config-plugins' + +export interface ConfigureInfoPlistProps { + groupIdentifier?: string + widgetIds?: string[] +} + +/** + * Configures main app Info.plist for Live Activities and widgets. + * + * This adds: + * - NSSupportsLiveActivities: Enables Live Activities support + * - Voltra_AppGroupIdentifier: App group ID for widget communication (if provided) + * - Voltra_WidgetIds: Array of widget IDs for native module access (if provided) + */ +export const configureInfoPlist: ConfigPlugin = (config, props = {}) => { + return withInfoPlist(config, (mod) => { + mod.modResults.NSSupportsLiveActivities = true + mod.modResults.NSSupportsLiveActivitiesFrequentUpdates = false + + // Only add group identifier if provided + if (props.groupIdentifier) { + mod.modResults.Voltra_AppGroupIdentifier = props.groupIdentifier + } + + // Store widget IDs in Info.plist for native module to access + if (props.widgetIds && props.widgetIds.length > 0) { + mod.modResults.Voltra_WidgetIds = props.widgetIds + } + + return mod + }) +} diff --git a/plugin/src/features/pushNotifications/index.ts b/plugin/src/ios/pushNotifications.ts similarity index 100% rename from plugin/src/features/pushNotifications/index.ts rename to plugin/src/ios/pushNotifications.ts diff --git a/plugin/src/types/plugin.ts b/plugin/src/types.ts similarity index 69% rename from plugin/src/types/plugin.ts rename to plugin/src/types.ts index 9d293cb..c3c3739 100644 --- a/plugin/src/types/plugin.ts +++ b/plugin/src/types.ts @@ -1,6 +1,69 @@ import { ConfigPlugin } from '@expo/config-plugins' -import type { WidgetConfig } from './widget' +/** + * Type definitions for the Voltra plugin + */ + +// ============================================================================ +// Widget Types +// ============================================================================ + +/** + * Supported widget size families + */ +export type WidgetFamily = + | 'systemSmall' + | 'systemMedium' + | 'systemLarge' + | 'systemExtraLarge' + | 'accessoryCircular' + | 'accessoryRectangular' + | 'accessoryInline' + +/** + * Configuration for a single home screen widget + */ +export interface WidgetConfig { + /** + * Unique identifier for the widget (used as the widget kind and in JS API) + * Must be alphanumeric with underscores only + */ + id: string + /** + * Display name shown in the widget gallery + */ + displayName: string + /** + * Description shown in the widget gallery + */ + description: string + /** + * Supported widget sizes + * @default ['systemSmall', 'systemMedium', 'systemLarge'] + */ + supportedFamilies?: WidgetFamily[] + /** + * Path to a file that default exports a WidgetVariants object for initial widget state. + * This will be pre-rendered at build time and bundled into the iOS app. + */ + initialStatePath?: string +} + +/** + * Structure describing the files in a widget extension target. + * Used for configuring Xcode build phases and groups. + */ +export interface WidgetFiles { + swiftFiles: string[] + entitlementFiles: string[] + plistFiles: string[] + assetDirectories: string[] + intentFiles: string[] +} + +// ============================================================================ +// Android Types +// ============================================================================ /** * Configuration for a single Android widget @@ -82,6 +145,10 @@ export interface AndroidPluginConfig { widgets?: AndroidWidgetConfig[] } +// ============================================================================ +// Plugin Types +// ============================================================================ + /** * Props for the Voltra config plugin */ diff --git a/plugin/src/types/index.ts b/plugin/src/types/index.ts deleted file mode 100644 index 0c1a4d0..0000000 --- a/plugin/src/types/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Public type exports for the Voltra plugin - */ - -export type { - AndroidPluginConfig, - AndroidPluginProps, - AndroidWidgetConfig, - ConfigPluginProps, - IOSPluginProps, - VoltraConfigPlugin, -} from './plugin' -export type { WidgetConfig, WidgetFamily, WidgetFiles } from './widget' diff --git a/plugin/src/types/widget.ts b/plugin/src/types/widget.ts deleted file mode 100644 index a462d82..0000000 --- a/plugin/src/types/widget.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Widget-related type definitions - */ - -/** - * Supported widget size families - */ -export type WidgetFamily = - | 'systemSmall' - | 'systemMedium' - | 'systemLarge' - | 'systemExtraLarge' - | 'accessoryCircular' - | 'accessoryRectangular' - | 'accessoryInline' - -/** - * Configuration for a single home screen widget - */ -export interface WidgetConfig { - /** - * Unique identifier for the widget (used as the widget kind and in JS API) - * Must be alphanumeric with underscores only - */ - id: string - /** - * Display name shown in the widget gallery - */ - displayName: string - /** - * Description shown in the widget gallery - */ - description: string - /** - * Supported widget sizes - * @default ['systemSmall', 'systemMedium', 'systemLarge'] - */ - supportedFamilies?: WidgetFamily[] - /** - * Path to a file that default exports a WidgetVariants object for initial widget state. - * This will be pre-rendered at build time and bundled into the iOS app. - */ - initialStatePath?: string -} - -/** - * Structure describing the files in a widget extension target. - * Used for configuring Xcode build phases and groups. - */ -export interface WidgetFiles { - swiftFiles: string[] - entitlementFiles: string[] - plistFiles: string[] - assetDirectories: string[] - intentFiles: string[] -} diff --git a/plugin/src/utils/entitlements.ts b/plugin/src/utils/entitlements.ts new file mode 100644 index 0000000..936d416 --- /dev/null +++ b/plugin/src/utils/entitlements.ts @@ -0,0 +1,26 @@ +/** + * Shared entitlements utilities. + */ + +/** + * Adds application groups entitlement to an entitlements object. + * Ensures the group list is deduped and preserves existing order. + */ +export function addApplicationGroupsEntitlement( + entitlements: Record, + groupIdentifier: string +): Record { + const existingApplicationGroups = ((entitlements['com.apple.security.application-groups'] as string[]) ?? []).filter( + Boolean + ) + + const deduped = Array.from(new Set(existingApplicationGroups)) + if (deduped.includes(groupIdentifier)) { + entitlements['com.apple.security.application-groups'] = deduped + return entitlements + } + + entitlements['com.apple.security.application-groups'] = [...deduped, groupIdentifier] + + return entitlements +} diff --git a/plugin/src/utils/index.ts b/plugin/src/utils/index.ts deleted file mode 100644 index 2cbb90f..0000000 --- a/plugin/src/utils/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Utility exports - */ - -export { getWidgetFiles } from './fileDiscovery' -export { logger } from './logger' -export { ensureURLScheme } from './urlScheme' diff --git a/plugin/src/validation.ts b/plugin/src/validation.ts new file mode 100644 index 0000000..493f982 --- /dev/null +++ b/plugin/src/validation.ts @@ -0,0 +1,236 @@ +import * as fs from 'fs' +import * as path from 'path' + +import type { AndroidWidgetConfig, ConfigPluginProps, WidgetConfig, WidgetFamily } from './types' + +/** + * Validation functions for the Voltra plugin + */ + +// ============================================================================ +// iOS Widget Validation +// ============================================================================ + +const VALID_FAMILIES: Set = new Set([ + 'systemSmall', + 'systemMedium', + 'systemLarge', + 'systemExtraLarge', + 'accessoryCircular', + 'accessoryRectangular', + 'accessoryInline', +]) + +/** + * Validates a widget configuration. + * Throws an error if validation fails. + */ +export function validateWidgetConfig(widget: WidgetConfig): void { + // Validate widget ID + if (!widget.id || typeof widget.id !== 'string') { + throw new Error('Widget ID is required and must be a string') + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { + throw new Error( + `Widget ID '${widget.id}' is invalid. ` + + 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' + ) + } + + // Validate display name + if (!widget.displayName?.trim()) { + throw new Error(`Widget '${widget.id}': displayName is required`) + } + + // Validate description + if (!widget.description?.trim()) { + throw new Error(`Widget '${widget.id}': description is required`) + } + + // Validate supported families if provided + if (widget.supportedFamilies) { + if (!Array.isArray(widget.supportedFamilies)) { + throw new Error(`Widget '${widget.id}': supportedFamilies must be an array`) + } + + for (const family of widget.supportedFamilies) { + if (!VALID_FAMILIES.has(family)) { + throw new Error( + `Widget '${widget.id}': Invalid widget family '${family}'. ` + + `Valid families are: ${Array.from(VALID_FAMILIES).join(', ')}` + ) + } + } + } +} + +// ============================================================================ +// Android Widget Validation +// ============================================================================ + +/** + * Validates an Android widget configuration. + * Throws an error if validation fails. + */ +export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, projectRoot?: string): void { + // Validate widget ID + if (!widget.id || typeof widget.id !== 'string') { + throw new Error('Widget ID is required and must be a string') + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { + throw new Error( + `Widget ID '${widget.id}' is invalid. ` + + 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' + ) + } + + // Validate display name + if (!widget.displayName?.trim()) { + throw new Error(`Widget '${widget.id}': displayName is required`) + } + + // Validate description + if (!widget.description?.trim()) { + throw new Error(`Widget '${widget.id}': description is required`) + } + + // Validate targetCellWidth + if (typeof widget.targetCellWidth !== 'number') { + throw new Error(`Widget '${widget.id}': targetCellWidth is required and must be a number`) + } + if (!Number.isInteger(widget.targetCellWidth) || widget.targetCellWidth < 1) { + throw new Error(`Widget '${widget.id}': targetCellWidth must be a positive integer (typically 1-5)`) + } + + // Validate targetCellHeight + if (typeof widget.targetCellHeight !== 'number') { + throw new Error(`Widget '${widget.id}': targetCellHeight is required and must be a number`) + } + if (!Number.isInteger(widget.targetCellHeight) || widget.targetCellHeight < 1) { + throw new Error(`Widget '${widget.id}': targetCellHeight must be a positive integer (typically 1-5)`) + } + + // Validate minCellWidth if provided + if (widget.minCellWidth !== undefined) { + if (typeof widget.minCellWidth !== 'number' || !Number.isInteger(widget.minCellWidth) || widget.minCellWidth < 1) { + throw new Error(`Widget '${widget.id}': minCellWidth must be a positive integer`) + } + } + + // Validate minCellHeight if provided + if (widget.minCellHeight !== undefined) { + if ( + typeof widget.minCellHeight !== 'number' || + !Number.isInteger(widget.minCellHeight) || + widget.minCellHeight < 1 + ) { + throw new Error(`Widget '${widget.id}': minCellHeight must be a positive integer`) + } + } + + // Validate previewImage if provided + if (widget.previewImage !== undefined) { + if (typeof widget.previewImage !== 'string' || !widget.previewImage.trim()) { + throw new Error(`Widget '${widget.id}': previewImage must be a non-empty string`) + } + + const ext = path.extname(widget.previewImage).toLowerCase() + const validImageExts = ['.png', '.jpg', '.jpeg', '.webp'] + if (!validImageExts.includes(ext)) { + throw new Error(`Widget '${widget.id}': previewImage must be a PNG, JPG, JPEG, or WebP file. Got: ${ext}`) + } + + // Check file exists if projectRoot is provided + if (projectRoot) { + const fullPath = path.join(projectRoot, widget.previewImage) + if (!fs.existsSync(fullPath)) { + throw new Error(`Widget '${widget.id}': previewImage file not found at ${widget.previewImage}`) + } + } + } + + // Validate previewLayout if provided + if (widget.previewLayout !== undefined) { + if (typeof widget.previewLayout !== 'string' || !widget.previewLayout.trim()) { + throw new Error(`Widget '${widget.id}': previewLayout must be a non-empty string`) + } + + const ext = path.extname(widget.previewLayout).toLowerCase() + if (ext !== '.xml') { + throw new Error(`Widget '${widget.id}': previewLayout must be an XML file. Got: ${ext}`) + } + + // Check file exists if projectRoot is provided + if (projectRoot) { + const fullPath = path.join(projectRoot, widget.previewLayout) + if (!fs.existsSync(fullPath)) { + throw new Error(`Widget '${widget.id}': previewLayout file not found at ${widget.previewLayout}`) + } + } + } +} + +// ============================================================================ +// Plugin Props Validation +// ============================================================================ + +/** + * Validates the plugin props at entry point. + * Throws an error if validation fails. + */ +export function validateProps(props: ConfigPluginProps): void { + // Validate group identifier format if provided + if (props.groupIdentifier !== undefined) { + if (typeof props.groupIdentifier !== 'string') { + throw new Error('groupIdentifier must be a string') + } + + if (!props.groupIdentifier.startsWith('group.')) { + throw new Error(`groupIdentifier '${props.groupIdentifier}' must start with 'group.'`) + } + } + + // Validate iOS widgets if provided + if (props.widgets !== undefined) { + if (!Array.isArray(props.widgets)) { + throw new Error('widgets must be an array') + } + + // Check for duplicate widget IDs + const seenIds = new Set() + for (const widget of props.widgets) { + validateWidgetConfig(widget) + + if (seenIds.has(widget.id)) { + throw new Error(`Duplicate widget ID: '${widget.id}'`) + } + seenIds.add(widget.id) + } + } + + // Validate Android configuration if provided + if (props.android !== undefined) { + if (typeof props.android !== 'object' || props.android === null) { + throw new Error('android configuration must be an object') + } + + if (props.android.widgets !== undefined) { + if (!Array.isArray(props.android.widgets)) { + throw new Error('android.widgets must be an array') + } + + // Check for duplicate widget IDs + const seenIds = new Set() + for (const widget of props.android.widgets) { + validateAndroidWidgetConfig(widget) + + if (seenIds.has(widget.id)) { + throw new Error(`Duplicate Android widget ID: '${widget.id}'`) + } + seenIds.add(widget.id) + } + } + } +} diff --git a/plugin/src/validation/index.ts b/plugin/src/validation/index.ts deleted file mode 100644 index 585b382..0000000 --- a/plugin/src/validation/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Validation exports - */ - -export { validateProps } from './validateProps' -export { validateWidgetConfig } from './validateWidget' diff --git a/plugin/src/validation/validateAndroidWidget.ts b/plugin/src/validation/validateAndroidWidget.ts deleted file mode 100644 index 6f556d5..0000000 --- a/plugin/src/validation/validateAndroidWidget.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import type { AndroidWidgetConfig } from '../types' - -/** - * Validates an Android widget configuration. - * Throws an error if validation fails. - */ -export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, projectRoot?: string): void { - // Validate widget ID - if (!widget.id || typeof widget.id !== 'string') { - throw new Error('Widget ID is required and must be a string') - } - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { - throw new Error( - `Widget ID '${widget.id}' is invalid. ` + - 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' - ) - } - - // Validate display name - if (!widget.displayName?.trim()) { - throw new Error(`Widget '${widget.id}': displayName is required`) - } - - // Validate description - if (!widget.description?.trim()) { - throw new Error(`Widget '${widget.id}': description is required`) - } - - // Validate targetCellWidth - if (typeof widget.targetCellWidth !== 'number') { - throw new Error(`Widget '${widget.id}': targetCellWidth is required and must be a number`) - } - if (!Number.isInteger(widget.targetCellWidth) || widget.targetCellWidth < 1) { - throw new Error(`Widget '${widget.id}': targetCellWidth must be a positive integer (typically 1-5)`) - } - - // Validate targetCellHeight - if (typeof widget.targetCellHeight !== 'number') { - throw new Error(`Widget '${widget.id}': targetCellHeight is required and must be a number`) - } - if (!Number.isInteger(widget.targetCellHeight) || widget.targetCellHeight < 1) { - throw new Error(`Widget '${widget.id}': targetCellHeight must be a positive integer (typically 1-5)`) - } - - // Validate minCellWidth if provided - if (widget.minCellWidth !== undefined) { - if (typeof widget.minCellWidth !== 'number' || !Number.isInteger(widget.minCellWidth) || widget.minCellWidth < 1) { - throw new Error(`Widget '${widget.id}': minCellWidth must be a positive integer`) - } - } - - // Validate minCellHeight if provided - if (widget.minCellHeight !== undefined) { - if ( - typeof widget.minCellHeight !== 'number' || - !Number.isInteger(widget.minCellHeight) || - widget.minCellHeight < 1 - ) { - throw new Error(`Widget '${widget.id}': minCellHeight must be a positive integer`) - } - } - - // Validate previewImage if provided - if (widget.previewImage !== undefined) { - if (typeof widget.previewImage !== 'string' || !widget.previewImage.trim()) { - throw new Error(`Widget '${widget.id}': previewImage must be a non-empty string`) - } - - const ext = path.extname(widget.previewImage).toLowerCase() - const validImageExts = ['.png', '.jpg', '.jpeg', '.webp'] - if (!validImageExts.includes(ext)) { - throw new Error(`Widget '${widget.id}': previewImage must be a PNG, JPG, JPEG, or WebP file. Got: ${ext}`) - } - - // Check file exists if projectRoot is provided - if (projectRoot) { - const fullPath = path.join(projectRoot, widget.previewImage) - if (!fs.existsSync(fullPath)) { - throw new Error(`Widget '${widget.id}': previewImage file not found at ${widget.previewImage}`) - } - } - } - - // Validate previewLayout if provided - if (widget.previewLayout !== undefined) { - if (typeof widget.previewLayout !== 'string' || !widget.previewLayout.trim()) { - throw new Error(`Widget '${widget.id}': previewLayout must be a non-empty string`) - } - - const ext = path.extname(widget.previewLayout).toLowerCase() - if (ext !== '.xml') { - throw new Error(`Widget '${widget.id}': previewLayout must be an XML file. Got: ${ext}`) - } - - // Check file exists if projectRoot is provided - if (projectRoot) { - const fullPath = path.join(projectRoot, widget.previewLayout) - if (!fs.existsSync(fullPath)) { - throw new Error(`Widget '${widget.id}': previewLayout file not found at ${widget.previewLayout}`) - } - } - } -} diff --git a/plugin/src/validation/validateProps.ts b/plugin/src/validation/validateProps.ts deleted file mode 100644 index f927c50..0000000 --- a/plugin/src/validation/validateProps.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ConfigPluginProps } from '../types' -import { validateAndroidWidgetConfig } from './validateAndroidWidget' -import { validateWidgetConfig } from './validateWidget' - -/** - * Validates the plugin props at entry point. - * Throws an error if validation fails. - */ -export function validateProps(props: ConfigPluginProps): void { - // Validate group identifier format if provided - if (props.groupIdentifier !== undefined) { - if (typeof props.groupIdentifier !== 'string') { - throw new Error('groupIdentifier must be a string') - } - - if (!props.groupIdentifier.startsWith('group.')) { - throw new Error(`groupIdentifier '${props.groupIdentifier}' must start with 'group.'`) - } - } - - // Validate iOS widgets if provided - if (props.widgets !== undefined) { - if (!Array.isArray(props.widgets)) { - throw new Error('widgets must be an array') - } - - // Check for duplicate widget IDs - const seenIds = new Set() - for (const widget of props.widgets) { - validateWidgetConfig(widget) - - if (seenIds.has(widget.id)) { - throw new Error(`Duplicate widget ID: '${widget.id}'`) - } - seenIds.add(widget.id) - } - } - - // Validate Android configuration if provided - if (props.android !== undefined) { - if (typeof props.android !== 'object' || props.android === null) { - throw new Error('android configuration must be an object') - } - - if (props.android.widgets !== undefined) { - if (!Array.isArray(props.android.widgets)) { - throw new Error('android.widgets must be an array') - } - - // Check for duplicate widget IDs - const seenIds = new Set() - for (const widget of props.android.widgets) { - validateAndroidWidgetConfig(widget) - - if (seenIds.has(widget.id)) { - throw new Error(`Duplicate Android widget ID: '${widget.id}'`) - } - seenIds.add(widget.id) - } - } - } -} diff --git a/plugin/src/validation/validateWidget.ts b/plugin/src/validation/validateWidget.ts deleted file mode 100644 index 8b90767..0000000 --- a/plugin/src/validation/validateWidget.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { WidgetConfig, WidgetFamily } from '../types' - -const VALID_FAMILIES: Set = new Set([ - 'systemSmall', - 'systemMedium', - 'systemLarge', - 'systemExtraLarge', - 'accessoryCircular', - 'accessoryRectangular', - 'accessoryInline', -]) - -/** - * Validates a widget configuration. - * Throws an error if validation fails. - */ -export function validateWidgetConfig(widget: WidgetConfig): void { - // Validate widget ID - if (!widget.id || typeof widget.id !== 'string') { - throw new Error('Widget ID is required and must be a string') - } - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { - throw new Error( - `Widget ID '${widget.id}' is invalid. ` + - 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' - ) - } - - // Validate display name - if (!widget.displayName?.trim()) { - throw new Error(`Widget '${widget.id}': displayName is required`) - } - - // Validate description - if (!widget.description?.trim()) { - throw new Error(`Widget '${widget.id}': description is required`) - } - - // Validate supported families if provided - if (widget.supportedFamilies) { - if (!Array.isArray(widget.supportedFamilies)) { - throw new Error(`Widget '${widget.id}': supportedFamilies must be an array`) - } - - for (const family of widget.supportedFamilies) { - if (!VALID_FAMILIES.has(family)) { - throw new Error( - `Widget '${widget.id}': Invalid widget family '${family}'. ` + - `Valid families are: ${Array.from(VALID_FAMILIES).join(', ')}` - ) - } - } - } -}