diff --git a/change/@react-native-windows-cli-add-module-windows-setup-command.json b/change/@react-native-windows-cli-add-module-windows-setup-command.json new file mode 100644 index 00000000000..703abfc6555 --- /dev/null +++ b/change/@react-native-windows-cli-add-module-windows-setup-command.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Add intelligent module-windows-setup command with API discovery and cross-platform mapping", + "packageName": "@react-native-windows/cli", + "email": "copilot@github.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/change/@react-native-windows-cli-improve-naming-consistency.json b/change/@react-native-windows-cli-improve-naming-consistency.json new file mode 100644 index 00000000000..f0edf8af8bb --- /dev/null +++ b/change/@react-native-windows-cli-improve-naming-consistency.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Improve module-windows-setup naming consistency for existing spec files", + "packageName": "@react-native-windows/cli", + "email": "copilot@github.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/change/react-native-windows-3a5b9550-7073-46fb-a007-484b0d3542a4.json b/change/react-native-windows-3a5b9550-7073-46fb-a007-484b0d3542a4.json new file mode 100644 index 00000000000..6f9a2505fda --- /dev/null +++ b/change/react-native-windows-3a5b9550-7073-46fb-a007-484b0d3542a4.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Implement module-windows-setup command for streamlined RNW community module integration", + "packageName": "react-native-windows", + "email": "54227869+anupriya13@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/cli/package.json b/packages/@react-native-windows/cli/package.json index 1ece9eee4f1..561e52b7e63 100644 --- a/packages/@react-native-windows/cli/package.json +++ b/packages/@react-native-windows/cli/package.json @@ -86,6 +86,6 @@ "promoteRelease": true, "windowsOnly": true, "engines": { - "node": ">= 22" + "node": ">= 20" } } diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/EXAMPLE.md b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/EXAMPLE.md new file mode 100644 index 00000000000..30174a23940 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/EXAMPLE.md @@ -0,0 +1,242 @@ +# Complete Example: Setting up Windows support for react-native-webview + +This example demonstrates how to use the `module-windows-setup` command to add Windows support to the popular `react-native-webview` community module. + +## Starting Point + +You have cloned or created a React Native module project with this structure: +``` +react-native-webview/ +├── package.json +├── index.js +├── src/ +│ └── WebViewNativeComponent.ts +└── README.md +``` + +## Step 1: Run the Setup Command + +```bash +cd react-native-webview +yarn react-native module-windows-setup --logging +``` + +## Step 2: Command Output + +``` +✔ Setting up Windows support for React Native module... +[ModuleWindowsSetup] Validating environment... +[ModuleWindowsSetup] Project name: react-native-webview +[ModuleWindowsSetup] Yarn found +[ModuleWindowsSetup] Checking for TurboModule spec file... +[ModuleWindowsSetup] No spec file found, creating default TurboModule spec... +[ModuleWindowsSetup] Created spec file: /path/to/NativeReactNativeWebview.ts +[ModuleWindowsSetup] Checking and updating package.json codegen configuration... +[ModuleWindowsSetup] Added codegenConfig to package.json +[ModuleWindowsSetup] Cleaning node_modules and reinstalling dependencies... +[ModuleWindowsSetup] Removed node_modules +[ModuleWindowsSetup] Dependencies installed +[ModuleWindowsSetup] Upgrading React Native and React Native Windows to latest versions... +[ModuleWindowsSetup] Latest RN version: 0.73.0 +[ModuleWindowsSetup] Latest RNW version: 0.73.0 +[ModuleWindowsSetup] Updated dependency versions in package.json +[ModuleWindowsSetup] Running init-windows with cpp-lib template... +[ModuleWindowsSetup] init-windows completed successfully +[ModuleWindowsSetup] Running codegen-windows... +[ModuleWindowsSetup] codegen-windows completed successfully +[ModuleWindowsSetup] Generating C++ stub files... +[ModuleWindowsSetup] Generated header stub: /path/to/windows/ReactNativeWebview.h +[ModuleWindowsSetup] Generated cpp stub: /path/to/windows/ReactNativeWebview.cpp +[ModuleWindowsSetup] Verifying build setup... +[ModuleWindowsSetup] MSBuild found, project should be buildable + +🎉 Your React Native module now supports Windows! + +Files created/updated: +📄 package.json - Added codegen configuration +🏗️ NativeReactNativeWebview.ts - TurboModule spec file (edit with your API) +💻 windows/ReactNativeWebview.h - C++ header file (implement your methods here) +⚙️ windows/ReactNativeWebview.cpp - C++ implementation file (add your logic here) + +Next steps: +1. 📝 Update the generated spec file with your module's interface +2. 🔧 Implement the methods in the generated C++ stub files +3. 🏗️ Build your project to verify everything works +4. 📚 See the documentation for more details on TurboModule development + +For help, visit: https://microsoft.github.io/react-native-windows/ +``` + +## Step 3: Final Project Structure + +After running the command, your project will have this structure: + +``` +react-native-webview/ +├── package.json (updated with codegenConfig) +├── index.js +├── src/ +│ └── WebViewNativeComponent.ts +├── NativeReactNativeWebview.ts (generated) +├── windows/ +│ ├── ReactNativeWebview.h (generated) +│ ├── ReactNativeWebview.cpp (generated) +│ └── ReactNativeWebview.sln (from init-windows) +├── codegen/ +│ └── ReactNativeWebviewSpec.g.h (from codegen-windows) +└── README.md +``` + +## Step 4: Customize the Generated Files + +### Update the Spec File (NativeReactNativeWebview.ts) + +```typescript +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import {TurboModuleRegistry} from 'react-native'; + +export interface Spec extends TurboModule { + goBack(): void; + goForward(): void; + reload(): void; + stopLoading(): void; + loadUrl(url: string): void; + evaluateJavaScript(script: string): Promise; +} + +export default TurboModuleRegistry.getEnforcing('ReactNativeWebview'); +``` + +### Update the Header File (windows/ReactNativeWebview.h) + +```cpp +#pragma once + +#include +#include + +namespace ReactNativeWebviewSpecs { + +REACT_MODULE(ReactNativeWebview) +struct ReactNativeWebview { + using ModuleSpec = ReactNativeWebviewSpec; + + REACT_INIT(Initialize) + void Initialize(React::ReactContext const &reactContext) noexcept; + + REACT_METHOD(goBack) + void goBack() noexcept; + + REACT_METHOD(goForward) + void goForward() noexcept; + + REACT_METHOD(reload) + void reload() noexcept; + + REACT_METHOD(stopLoading) + void stopLoading() noexcept; + + REACT_METHOD(loadUrl) + void loadUrl(std::string url) noexcept; + + REACT_METHOD(evaluateJavaScript) + void evaluateJavaScript(std::string script, React::ReactPromise promise) noexcept; + +private: + React::ReactContext m_reactContext{nullptr}; +}; + +} // namespace ReactNativeWebviewSpecs +``` + +### Update the Implementation File (windows/ReactNativeWebview.cpp) + +```cpp +#include "ReactNativeWebview.h" + +namespace ReactNativeWebviewSpecs { + +void ReactNativeWebview::Initialize(React::ReactContext const &reactContext) noexcept { + m_reactContext = reactContext; +} + +void ReactNativeWebview::goBack() noexcept { + // TODO: Implement WebView go back functionality +} + +void ReactNativeWebview::goForward() noexcept { + // TODO: Implement WebView go forward functionality +} + +void ReactNativeWebview::reload() noexcept { + // TODO: Implement WebView reload functionality +} + +void ReactNativeWebview::stopLoading() noexcept { + // TODO: Implement WebView stop loading functionality +} + +void ReactNativeWebview::loadUrl(std::string url) noexcept { + // TODO: Implement WebView load URL functionality +} + +void ReactNativeWebview::evaluateJavaScript(std::string script, React::ReactPromise promise) noexcept { + try { + // TODO: Implement JavaScript evaluation + promise.Resolve("JavaScript evaluation result"); + } catch (const std::exception& e) { + promise.Reject(React::ReactError{e.what()}); + } +} + +} // namespace ReactNativeWebviewSpecs +``` + +## Step 5: Build and Test + +```bash +# Build the Windows project +cd windows +msbuild ReactNativeWebview.sln + +# Or use Visual Studio to open and build the solution +``` + +## Benefits of Using module-windows-setup + +1. **Automation**: All setup steps are automated - no manual file creation or configuration +2. **Best Practices**: Generated files follow RNW coding standards and patterns +3. **Completeness**: Creates the entire Windows integration structure in one command +4. **Consistency**: Ensures consistent naming and structure across all Windows modules +5. **Error Prevention**: Validates environment and handles common setup issues +6. **Documentation**: Provides clear next steps and examples for implementation + +## Alternative Usage Patterns + +### Skip Dependency Upgrades +```bash +yarn react-native module-windows-setup --skip-deps +``` + +### Skip Build Verification +```bash +yarn react-native module-windows-setup --skip-build +``` + +### Verbose Logging +```bash +yarn react-native module-windows-setup --logging +``` + +### Minimal Setup (Skip upgrades and build) +```bash +yarn react-native module-windows-setup --skip-deps --skip-build +``` + +This command transforms any React Native community module into a fully Windows-compatible module with minimal effort and maximum reliability. \ No newline at end of file diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/README.md b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/README.md new file mode 100644 index 00000000000..fd8c14f7c4d --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/README.md @@ -0,0 +1,212 @@ +# module-windows-setup Command + +The `module-windows-setup` command streamlines the process of adding Windows support to React Native community modules. It automates the complete workflow from spec file creation to C++ stub generation. + +## Usage + +```bash +yarn react-native module-windows-setup [options] +``` + +## Options + +- `--logging`: Enable verbose output logging +- `--no-telemetry`: Disable telemetry tracking +- `--skip-deps`: Skip dependency upgrades (use current versions) +- `--skip-build`: Skip final build verification step + +## What it does + +The command performs the following steps automatically: + +1. **Intelligent API Discovery**: Analyzes existing APIs from multiple sources: + - Existing JavaScript/TypeScript files (main entry point, index files) + - Android native implementation (`@ReactMethod` annotations in Java/Kotlin files) + - iOS native implementation (`RCT_EXPORT_METHOD` in Objective-C files) + - README documentation (method signatures in code blocks) + +2. **Smart Spec File Creation**: Creates TurboModule spec files with actual method signatures discovered from analysis, or generates a template if no APIs are found. + +3. **Package.json Updates**: Adds or updates the `codegenConfig` section in package.json with proper Windows codegen configuration. + +4. **Dependency Management**: + - Removes node_modules directory + - Installs dependencies + - Upgrades React Native and React Native Windows to latest versions (unless `--skip-deps` is used) + - Reinstalls dependencies + +5. **Windows Library Setup**: Runs `init-windows --template cpp-lib` to create the Windows-specific project structure. + +6. **Code Generation**: Runs `codegen-windows` to generate C++ spec files from TypeScript/JavaScript specs. + +7. **Intelligent C++ Stub Generation**: Creates C++ header (.h) and implementation (.cpp) stub files with: + - Proper method signatures matching the TypeScript spec + - Correct parameter types mapped from TypeScript to C++ + - Promise-based async methods or synchronous methods as appropriate + - Example implementations and proper return value handling + +8. **Build Verification**: Checks if the project can be built (unless `--skip-build` is used). + +## Generated Files + +### Spec File (NativeModuleName.ts) +Generated based on API analysis from existing implementations: + +```typescript +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import {TurboModuleRegistry} from 'react-native'; + +export interface Spec extends TurboModule { + // Methods discovered from existing Android/iOS/JS implementations + getString(value: string): Promise; + getNumber(count: number): Promise; + getBoolValue(): Promise; + performAction(config: object): Promise; +} + +export default TurboModuleRegistry.getEnforcing('ModuleName'); +``` + +### C++ Header (ModuleName.h) +Generated with actual method signatures matching the spec: + +```cpp +#pragma once + +#include +#include + +namespace ModuleNameSpecs { + +REACT_MODULE(ModuleName) +struct ModuleName { + using ModuleSpec = ModuleNameSpec; + + REACT_INIT(Initialize) + void Initialize(React::ReactContext const &reactContext) noexcept; + + REACT_METHOD(getString) + void getString(std::string value, React::ReactPromise promise) noexcept; + + REACT_METHOD(getNumber) + void getNumber(double count, React::ReactPromise promise) noexcept; + + REACT_METHOD(getBoolValue) + void getBoolValue(React::ReactPromise promise) noexcept; + + REACT_METHOD(performAction) + void performAction(React::JSValue config, React::ReactPromise promise) noexcept; +}; + +} // namespace ModuleNameSpecs +``` + +### C++ Implementation (ModuleName.cpp) +Generated with method stubs that match the spec: + +```cpp +#include "ModuleName.h" + +namespace ModuleNameSpecs { + +void ModuleName::Initialize(React::ReactContext const &reactContext) noexcept { + // TODO: Initialize your module +} + +void ModuleName::getString(std::string value, React::ReactPromise promise) noexcept { + // TODO: Implement getString + promise.Resolve("example"); +} + +void ModuleName::getNumber(double count, React::ReactPromise promise) noexcept { + // TODO: Implement getNumber + promise.Resolve(42); +} + +void ModuleName::getBoolValue(React::ReactPromise promise) noexcept { + // TODO: Implement getBoolValue + promise.Resolve(true); +} + +void ModuleName::performAction(React::JSValue config, React::ReactPromise promise) noexcept { + // TODO: Implement performAction + promise.Resolve(); +} + +} // namespace ModuleNameSpecs +``` + +## Package.json Configuration + +The command adds this configuration to your package.json: + +```json +{ + "codegenConfig": { + "name": "ModuleName", + "type": "modules", + "jsSrcsDir": ".", + "windows": { + "namespace": "ModuleNameSpecs", + "outputDirectory": "codegen", + "generators": ["modulesWindows"] + } + } +} +``` + +## Next Steps + +After running the command: + +1. **Review the generated spec file** (`NativeModuleName.ts`) - The command analyzes existing Android/iOS implementations and README documentation to discover APIs, but you should verify the generated method signatures match your intended interface +2. **Implement the methods** in the generated C++ stub files - The stubs are generated with proper signatures matching the spec file +3. **Build your project** to verify everything works correctly +4. **Add any missing methods** that weren't discovered during analysis + +## API Discovery Process + +The command intelligently discovers APIs from multiple sources: + +### 1. JavaScript/TypeScript Analysis +- Main entry point specified in package.json +- Common file locations (index.js, index.ts, src/index.js, src/index.ts) +- Exported functions and method definitions + +### 2. Android Implementation Analysis +- Scans `android/` directory for Java/Kotlin files +- Looks for `@ReactMethod` annotations +- Extracts method names and basic type information + +### 3. iOS Implementation Analysis +- Scans `ios/` directory for Objective-C files +- Looks for `RCT_EXPORT_METHOD` declarations +- Extracts method names + +### 4. Documentation Analysis +- Parses README.md for method signatures in code blocks +- Extracts function-like patterns + +The discovered methods are combined and deduplicated to create a comprehensive API specification. + +## Examples + +Basic usage: +```bash +yarn react-native module-windows-setup +``` + +With verbose logging: +```bash +yarn react-native module-windows-setup --logging +``` + +Skip dependency upgrades: +```bash +yarn react-native module-windows-setup --skip-deps +``` + +Skip build verification: +```bash +yarn react-native module-windows-setup --skip-build +``` \ No newline at end of file diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/TESTING.md b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/TESTING.md new file mode 100644 index 00000000000..a5c91a8e968 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/TESTING.md @@ -0,0 +1,326 @@ +# Testing the module-windows-setup Command + +This guide explains how to test the `module-windows-setup` command locally with real React Native community modules. + +## Prerequisites + +1. **Build the CLI package locally**: + ```bash + cd /path/to/react-native-windows + yarn install + yarn build + ``` + +2. **Ensure you have the required tools**: + - Node.js (v16+) + - Yarn package manager + - Git + - Windows SDK (for building C++ components) + +## Test Scenarios + +### Scenario 1: Test with react-native-webview + +This module has extensive iOS and Android implementations that the command can analyze. + +```bash +# Clone the repository +git clone https://github.com/react-native-webview/react-native-webview.git +cd react-native-webview + +# Link to your local CLI build +yarn add file:/absolute/path/to/react-native-windows/packages/@react-native-windows/cli + +# Run the setup command with verbose logging +yarn react-native module-windows-setup --logging +``` + +**Expected behavior**: +- Should discover APIs from Android Java files (e.g., `goBack()`, `reload()`, `postMessage()`) +- Should discover APIs from iOS Objective-C files +- Should create proper TypeScript spec with discovered methods +- Should generate C++ stubs with matching signatures + +### Scenario 2: Test with notifee + +This module has both native implementations and good documentation. + +```bash +# Clone the repository +git clone https://github.com/invertase/notifee.git +cd notifee + +# Link to your local CLI build +yarn add file:/absolute/path/to/react-native-windows/packages/@react-native-windows/cli + +# Run the setup command +yarn react-native module-windows-setup --logging +``` + +**Expected behavior**: +- Should discover notification-related APIs +- Should analyze the main TypeScript files for exported functions +- Should generate appropriate Windows-specific implementations + +### Scenario 3: Test with a minimal custom module + +Create a test module from scratch to verify all functionality: + +```bash +# Create test directory +mkdir test-react-native-module +cd test-react-native-module + +# Initialize package.json +npm init -y + +# Update package.json with proper React Native module structure +cat > package.json << 'EOF' +{ + "name": "test-react-native-module", + "version": "1.0.0", + "description": "Test module for module-windows-setup", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/test/test-react-native-module.git" + }, + "keywords": ["react-native"], + "author": "Test Author", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } +} +EOF + +# Create main entry point with TypeScript APIs +cat > index.ts << 'EOF' +export interface TestModuleType { + getValue(key: string): Promise; + setValue(key: string, value: string): Promise; + getNumber(defaultValue: number): Promise; + getBooleanFlag(): Promise; + performAction(config: object): Promise; +} + +declare const TestModule: TestModuleType; +export default TestModule; +EOF + +# Create Android implementation for API discovery +mkdir -p android/src/main/java/com/testmodule +cat > android/src/main/java/com/testmodule/TestModule.java << 'EOF' +package com.testmodule; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; + +public class TestModule extends ReactContextBaseJavaModule { + TestModule(ReactApplicationContext context) { + super(context); + } + + @Override + public String getName() { + return "TestModule"; + } + + @ReactMethod + public void getValue(String key, Promise promise) { + promise.resolve("test-value"); + } + + @ReactMethod + public void setValue(String key, String value, Promise promise) { + promise.resolve(null); + } + + @ReactMethod + public void getNumber(Double defaultValue, Promise promise) { + promise.resolve(42.0); + } + + @ReactMethod + public void getBooleanFlag(Promise promise) { + promise.resolve(true); + } + + @ReactMethod + public void performAction(ReadableMap config, Promise promise) { + promise.resolve(null); + } +} +EOF + +# Create iOS implementation for API discovery +mkdir -p ios +cat > ios/TestModule.m << 'EOF' +#import "TestModule.h" + +@implementation TestModule + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(getValue:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(@"test-value"); +} + +RCT_EXPORT_METHOD(setValue:(NSString *)key + value:(NSString *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(nil); +} + +RCT_EXPORT_METHOD(getNumber:(NSNumber *)defaultValue + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(@42); +} + +RCT_EXPORT_METHOD(getBooleanFlag:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(@YES); +} + +RCT_EXPORT_METHOD(performAction:(NSDictionary *)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(nil); +} + +@end +EOF + +# Create README with documented API +cat > README.md << 'EOF' +# Test React Native Module + +## API + +```javascript +import TestModule from 'test-react-native-module'; + +// Get a string value +const value = await TestModule.getValue('key'); + +// Set a string value +await TestModule.setValue('key', 'value'); + +// Get a number +const number = await TestModule.getNumber(10); + +// Get a boolean flag +const flag = await TestModule.getBooleanFlag(); + +// Perform an action +await TestModule.performAction({option: 'value'}); +``` +EOF + +# Link to local CLI build +yarn add file:/absolute/path/to/react-native-windows/packages/@react-native-windows/cli + +# Run the setup command +yarn react-native module-windows-setup --logging +``` + +**Expected behavior**: +- Should discover all 5 methods from TypeScript, Android, and iOS sources +- Should create a comprehensive spec file with proper types +- Should generate C++ header and implementation files with correct signatures +- Should map types correctly (string → std::string, number → double, etc.) + +## Validation Steps + +After running the command, verify: + +1. **Generated files exist**: + ```bash + ls -la Native*.ts # TypeScript spec file + ls -la windows/*.h windows/*.cpp # C++ implementation files + ``` + +2. **Package.json updated**: + ```bash + cat package.json | grep -A 10 "codegenConfig" + ``` + +3. **API discovery worked**: + - Check the TypeScript spec file for discovered methods + - Verify C++ files have matching method signatures + - Confirm type mappings are correct + +4. **Project builds** (optional): + ```bash + yarn react-native module-windows-setup --skip-build + # Then manually build to test + cd windows && msbuild *.sln + ``` + +## Command Line Options + +Test different options to verify functionality: + +```bash +# Basic setup +yarn react-native module-windows-setup + +# With verbose logging +yarn react-native module-windows-setup --logging + +# Skip dependency upgrades (faster for testing) +yarn react-native module-windows-setup --skip-deps + +# Skip build verification +yarn react-native module-windows-setup --skip-build + +# Minimal setup (fastest for iteration) +yarn react-native module-windows-setup --skip-deps --skip-build --logging +``` + +## Troubleshooting + +### Command not found +If you get "command not found": +```bash +# Verify the CLI package is linked +ls node_modules/@react-native-windows/cli + +# Try using npx directly +npx @react-native-windows/cli module-windows-setup +``` + +### No APIs discovered +If no APIs are discovered: +- Check that files exist in expected locations (`android/`, `ios/`, main entry point) +- Use `--logging` to see what files are being analyzed +- Verify file patterns match (`.java`, `.kt`, `.m`, `.mm` files) + +### Build failures +If builds fail: +- Use `--skip-build` to focus on file generation +- Check Windows SDK installation +- Verify project structure matches expectations + +## Contributing + +When making changes to the command: + +1. Test with multiple real-world modules +2. Verify API discovery from all sources (JS/TS, Android, iOS, README) +3. Check type mapping accuracy +4. Ensure generated files follow RNW conventions +5. Test error handling with malformed inputs + +This comprehensive testing ensures the `module-windows-setup` command works reliably across different module structures and API patterns. \ No newline at end of file diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/__tests__/moduleWindowsSetup.test.ts b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/__tests__/moduleWindowsSetup.test.ts new file mode 100644 index 00000000000..403743e41a9 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/__tests__/moduleWindowsSetup.test.ts @@ -0,0 +1,253 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import {ModuleWindowsSetup} from '../moduleWindowsSetup'; +import type {ModuleWindowsSetupOptions} from '../moduleWindowsSetupOptions'; + +// Mock dependencies +jest.mock('@react-native-windows/fs'); +jest.mock('glob'); +jest.mock('child_process'); + +describe('ModuleWindowsSetup', () => { + const mockOptions: ModuleWindowsSetupOptions = { + logging: false, + telemetry: false, + skipDeps: false, + skipBuild: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getModuleName', () => { + it('should convert package names to PascalCase', () => { + const setup = new ModuleWindowsSetup('/test', mockOptions); + // Access private method for testing + const getModuleName = (setup as any).getModuleName.bind(setup); + + expect(getModuleName('react-native-webview')).toBe('ReactNativeWebview'); + expect(getModuleName('@react-native-community/slider')).toBe( + 'ReactNativeCommunitySlider', + ); + expect(getModuleName('simple-module')).toBe('SimpleModule'); + expect(getModuleName('@scope/complex-package-name')).toBe( + 'ScopeComplexPackageName', + ); + }); + + it('should handle edge cases', () => { + const setup = new ModuleWindowsSetup('/test', mockOptions); + const getModuleName = (setup as any).getModuleName.bind(setup); + + expect(getModuleName('')).toBe(''); + expect(getModuleName('single')).toBe('Single'); + expect(getModuleName('---multiple---dashes---')).toBe('MultipleDashes'); + }); + }); + + describe('extractMethodsFromSpecInterface', () => { + it('should parse method signatures from TypeScript interface', () => { + const setup = new ModuleWindowsSetup('/test', mockOptions); + const extractMethods = ( + setup as any + ).extractMethodsFromSpecInterface.bind(setup); + + const specContent = ` +export interface Spec extends TurboModule { + getString(value: string): Promise; + getNumber(count: number): Promise; + getBool(): Promise; + syncMethod(data: object): void; +}`; + + const methods = extractMethods(specContent); + + expect(methods).toHaveLength(4); + expect(methods[0]).toEqual({ + name: 'getString', + returnType: 'Promise', + parameters: [{name: 'value', type: 'string'}], + }); + expect(methods[1]).toEqual({ + name: 'getNumber', + returnType: 'Promise', + parameters: [{name: 'count', type: 'number'}], + }); + expect(methods[2]).toEqual({ + name: 'getBool', + returnType: 'Promise', + parameters: [], + }); + expect(methods[3]).toEqual({ + name: 'syncMethod', + returnType: 'void', + parameters: [{name: 'data', type: 'object'}], + }); + }); + + it('should handle complex parameter types', () => { + const setup = new ModuleWindowsSetup('/test', mockOptions); + const extractMethods = ( + setup as any + ).extractMethodsFromSpecInterface.bind(setup); + + const specContent = ` +export interface Spec extends TurboModule { + complexMethod( + config: { + url: string; + timeout?: number; + }, + callback: (result: any) => void + ): Promise; +}`; + + const methods = extractMethods(specContent); + + expect(methods).toHaveLength(1); + expect(methods[0].name).toBe('complexMethod'); + expect(methods[0].parameters).toHaveLength(2); + }); + }); + + describe('mapTSToCppType', () => { + it('should map TypeScript types to C++ types correctly', () => { + const setup = new ModuleWindowsSetup('/test', mockOptions); + const mapTsToCpp = (setup as any).mapTSToCppType.bind(setup); + + expect(mapTsToCpp('string')).toBe('std::string'); + expect(mapTsToCpp('number')).toBe('double'); + expect(mapTsToCpp('boolean')).toBe('bool'); + expect(mapTsToCpp('object')).toBe('React::JSValue'); + expect(mapTsToCpp('any')).toBe('React::JSValue'); + expect(mapTsToCpp('string[]')).toBe('std::vector'); + expect(mapTsToCpp('number[]')).toBe('std::vector'); + }); + }); + + describe('constructor', () => { + it('should create instance with correct root and options', () => { + const root = '/test/project'; + const options = { + logging: true, + telemetry: true, + skipDeps: true, + skipBuild: true, + }; + + const setup = new ModuleWindowsSetup(root, options); + + expect(setup.root).toBe(root); + expect(setup.options).toBe(options); + }); + }); + + describe('verboseMessage', () => { + it('should log when logging is enabled', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const setup = new ModuleWindowsSetup('/test', {logging: true}); + + (setup as any).verboseMessage('test message'); + + expect(consoleSpy).toHaveBeenCalledWith( + '[ModuleWindowsSetup] test message', + ); + consoleSpy.mockRestore(); + }); + + it('should not log when logging is disabled', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const setup = new ModuleWindowsSetup('/test', {logging: false}); + + (setup as any).verboseMessage('test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('removeOldArchWindowsDirectory', () => { + const fs = require('@react-native-windows/fs'); + const glob = require('glob'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should skip removal if windows directory does not exist', async () => { + fs.exists.mockResolvedValue(false); + const setup = new ModuleWindowsSetup('/test', {logging: true}); + + await (setup as any).removeOldArchWindowsDirectory(); + + expect(fs.exists).toHaveBeenCalledWith('/test/windows'); + expect(glob.sync).not.toHaveBeenCalled(); + expect(fs.rmdir).not.toHaveBeenCalled(); + }); + + it('should keep new architecture windows directory', async () => { + fs.exists.mockResolvedValue(true); + glob.sync.mockReturnValue([]); // No old arch files found + const setup = new ModuleWindowsSetup('/test', {logging: true}); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await (setup as any).removeOldArchWindowsDirectory(); + + expect(fs.exists).toHaveBeenCalledWith('/test/windows'); + expect(glob.sync).toHaveBeenCalled(); + expect(fs.rmdir).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Windows directory appears to be new architecture', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should remove old architecture windows directory', async () => { + fs.exists.mockResolvedValue(true); + glob.sync.mockReturnValueOnce(['ReactNativeModule.cpp']); // Found old arch file + fs.rmdir.mockResolvedValue(undefined); + const setup = new ModuleWindowsSetup('/test', {logging: true}); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await (setup as any).removeOldArchWindowsDirectory(); + + expect(fs.exists).toHaveBeenCalledWith('/test/windows'); + expect(glob.sync).toHaveBeenCalled(); + expect(fs.rmdir).toHaveBeenCalledWith('/test/windows', {recursive: true}); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Removing old architecture windows directory'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Old architecture windows directory removed successfully', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should handle removal errors gracefully', async () => { + fs.exists.mockResolvedValue(true); + glob.sync.mockReturnValueOnce(['pch.h']); // Found old arch file + fs.rmdir.mockRejectedValue(new Error('Permission denied')); + const setup = new ModuleWindowsSetup('/test', {logging: true}); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await (setup as any).removeOldArchWindowsDirectory(); + + expect(fs.rmdir).toHaveBeenCalledWith('/test/windows', {recursive: true}); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Warning: Could not remove old windows directory', + ), + ); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/index.ts b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/index.ts new file mode 100644 index 00000000000..53069e51093 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +export { + moduleWindowsSetupCommand, + moduleWindowsSetupInternal, +} from './moduleWindowsSetup'; +export type {ModuleWindowsSetupOptions} from './moduleWindowsSetupOptions'; diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetup.ts b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetup.ts new file mode 100644 index 00000000000..55ddf73d371 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetup.ts @@ -0,0 +1,1945 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import path from 'path'; +import chalk from 'chalk'; +import {performance} from 'perf_hooks'; +import {Ora} from 'ora'; +import {execSync} from 'child_process'; +import glob from 'glob'; + +import fs from '@react-native-windows/fs'; +import type {Command, Config} from '@react-native-community/cli-types'; +import {Telemetry, CodedError} from '@react-native-windows/telemetry'; + +import { + newSpinner, + setExitProcessWithError, +} from '../../utils/commandWithProgress'; +import { + getDefaultOptions, + startTelemetrySession, + endTelemetrySession, +} from '../../utils/telemetryHelpers'; +import {initWindowsInternal} from '../initWindows/initWindows'; +import {codegenWindowsInternal} from '../codegenWindows/codegenWindows'; +import type {ModuleWindowsSetupOptions} from './moduleWindowsSetupOptions'; +import {moduleWindowsSetupOptions} from './moduleWindowsSetupOptions'; + +interface Parameter { + name: string; + type: string; +} + +interface MethodSignature { + name: string; + returnType: string; + parameters: Parameter[]; +} + +export class ModuleWindowsSetup { + private actualModuleName?: string; + private discoveredSpecFiles: string[] = []; + private actualProjectPath?: string; + public root: string; + public options: ModuleWindowsSetupOptions; + + constructor(root: string, options: ModuleWindowsSetupOptions) { + this.root = root; + this.options = options; + } + + private async validateEnvironment(): Promise { + this.verboseMessage('Validating environment...'); + + // Check if package.json exists + const packageJsonPath = path.join(this.root, 'package.json'); + if (!(await fs.exists(packageJsonPath))) { + throw new CodedError( + 'NoPackageJson', + 'No package.json found. Make sure you are in a React Native project directory.', + ); + } + + // Check if it's a valid npm package + try { + const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + if (!pkgJson.name) { + throw new CodedError( + 'NoProjectName', + 'package.json must have a "name" field.', + ); + } + this.verboseMessage(`Project name: ${pkgJson.name}`); + } catch (error: any) { + if (error.code === 'NoProjectName') { + throw error; + } + throw new CodedError('NoPackageJson', 'package.json is not valid JSON.'); + } + + // Check if yarn is available + try { + execSync('yarn --version', {stdio: 'ignore'}); + this.verboseMessage('Yarn found'); + } catch { + throw new CodedError( + 'Unknown', + 'Yarn is required but not found. Please install Yarn first.', + ); + } + } + + private async extractModuleNameFromExistingSpec( + specFilePath: string, + ): Promise { + try { + const fullPath = path.join(this.root, specFilePath); + const content = await fs.readFile(fullPath, 'utf8'); + + // Extract the module name from TurboModuleRegistry.getEnforcing('ModuleName') + const exportMatch = content.match( + /TurboModuleRegistry\.getEnforcing\(['"`]([^'"`]+)['"`]\)/, + ); + if (exportMatch) { + this.actualModuleName = exportMatch[1]; + this.verboseMessage( + `Extracted actual module name: ${this.actualModuleName}`, + ); + } else { + this.verboseMessage( + 'Could not extract module name from spec file, using package name conversion', + ); + } + } catch (error) { + this.verboseMessage(`Error reading spec file: ${error}`); + } + } + + private getActualModuleName(packageName: string): string { + // If we extracted the actual module name from an existing spec, use that + if (this.actualModuleName) { + return this.actualModuleName; + } + + // Otherwise, fall back to the package name conversion + return this.getModuleName(packageName); + } + + public async getFinalModuleName(): Promise { + try { + const packageJsonPath = path.join(this.root, 'package.json'); + const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + return this.getActualModuleName(pkgJson.name || 'SampleModule'); + } catch { + return 'SampleModule'; + } + } + + public getActualProjectPaths(): {headerPath: string; cppPath: string} { + if (this.actualProjectPath) { + return { + headerPath: `${this.actualProjectPath}.h`, + cppPath: `${this.actualProjectPath}.cpp`, + }; + } + + // Fallback to getFinalModuleName for backward compatibility + const moduleName = this.actualModuleName || 'SampleModule'; + return { + headerPath: `windows/${moduleName}/${moduleName}.h`, + cppPath: `windows/${moduleName}/${moduleName}.cpp`, + }; + } + + private verboseMessage(message: any) { + if (this.options.logging) { + console.log(`[ModuleWindowsSetup] ${message}`); + } + } + + private async removeDirectoryRecursively(dirPath: string): Promise { + try { + const stats = await fs.stat(dirPath); + if (stats.isDirectory()) { + const files = await fs.readdir(dirPath); + await Promise.all( + files.map(async file => { + const filePath = path.join(dirPath, file); + await this.removeDirectoryRecursively(filePath); + }), + ); + await fs.rmdir(dirPath); + } else { + await fs.unlink(dirPath); + } + } catch (error: any) { + // Ignore errors if file/directory doesn't exist + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + private getModuleName(packageName: string): string { + // Convert package name to PascalCase module name + // e.g., "react-native-webview" -> "ReactNativeWebview" + // e.g., "@react-native-community/slider" -> "ReactNativeCommunitySlider" + return packageName + .replace(/[@/-]/g, ' ') + .split(' ') + .filter(word => word.length > 0) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); + } + + private async checkAndCreateSpecFile(): Promise { + this.verboseMessage('Checking for TurboModule spec file...'); + + // Look for spec files in common locations, excluding node_modules + const specPatterns = [ + 'Native*.[jt]s', + 'src/**/Native*.[jt]s', + 'lib/**/Native*.[jt]s', + 'js/**/Native*.[jt]s', + 'ts/**/Native*.[jt]s', + ]; + + const specFiles: string[] = []; + for (const pattern of specPatterns) { + const matches = glob.sync(pattern, { + cwd: this.root, + ignore: ['**/node_modules/**', '**/build/**', '**/dist/**'], + }); + specFiles.push(...matches); + } + + // Remove duplicates and filter for actual TurboModule specs + const uniqueSpecFiles = Array.from(new Set(specFiles)); + const validSpecFiles = await this.filterValidSpecFiles(uniqueSpecFiles); + + if (validSpecFiles.length === 0) { + this.verboseMessage( + 'No valid TurboModule spec file found, analyzing existing APIs...', + ); + await this.analyzeAndCreateSpecFile(); + } else { + this.verboseMessage( + `Found valid spec file(s): ${validSpecFiles.join(', ')}`, + ); + // Store the discovered spec files for later use + this.discoveredSpecFiles = validSpecFiles; + // Extract the actual module name from the existing spec file + await this.extractModuleNameFromExistingSpec(validSpecFiles[0]); + } + } + + private async filterValidSpecFiles(specFiles: string[]): Promise { + const validFiles: string[] = []; + + for (const file of specFiles) { + try { + const filePath = path.join(this.root, file); + if (await fs.exists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + + // Check if it's a valid TurboModule spec file + if (this.isValidTurboModuleSpec(content)) { + validFiles.push(file); + } + } + } catch (error) { + this.verboseMessage(`Could not read spec file ${file}: ${error}`); + } + } + + return validFiles; + } + + private isValidTurboModuleSpec(content: string): boolean { + // Check for TurboModule indicators + return ( + content.includes('TurboModule') && + (content.includes('export interface Spec') || + content.includes('extends TurboModule') || + content.includes('TurboModuleRegistry')) + ); + } + + private async analyzeAndCreateSpecFile(): Promise { + const pkgJson = JSON.parse( + await fs.readFile(path.join(this.root, 'package.json'), 'utf8'), + ); + const moduleName = this.getActualModuleName(pkgJson.name || 'SampleModule'); + + // Try to analyze existing API from multiple sources + const apiMethods = await this.discoverApiMethods(); + + const specContent = this.generateSpecFileContent(moduleName, apiMethods); + const specPath = path.join(this.root, `Native${moduleName}.ts`); + await fs.writeFile(specPath, specContent); + this.verboseMessage(`Created spec file: ${specPath}`); + } + + private async discoverApiMethods(): Promise { + const methods: MethodSignature[] = []; + + // 1. Check for existing JavaScript/TypeScript API files + methods.push(...(await this.analyzeJavaScriptApi())); + + // 2. Check Android native implementation for reference + methods.push(...(await this.analyzeAndroidApi())); + + // 3. Check iOS native implementation for reference + methods.push(...(await this.analyzeIosApi())); + + // 4. Check README for documented API + methods.push(...(await this.analyzeReadmeApi())); + + // Deduplicate methods by name + const uniqueMethods = methods.reduce((acc, method) => { + if (!acc.find(m => m.name === method.name)) { + acc.push(method); + } + return acc; + }, [] as MethodSignature[]); + + this.verboseMessage( + `Discovered ${uniqueMethods.length} API methods from various sources`, + ); + return uniqueMethods; + } + + private async analyzeJavaScriptApi(): Promise { + const methods: MethodSignature[] = []; + + try { + // Look for index.js, index.ts, or main entry point + const packageJson = JSON.parse( + await fs.readFile(path.join(this.root, 'package.json'), 'utf8'), + ); + const mainFile = packageJson.main || 'index.js'; + + const possibleFiles = [ + mainFile, + 'index.js', + 'index.ts', + 'src/index.js', + 'src/index.ts', + ]; + + for (const file of possibleFiles) { + const filePath = path.join(this.root, file); + if (await fs.exists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + methods.push(...this.parseJavaScriptMethods(content)); + this.verboseMessage(`Analyzed JavaScript API from ${file}`); + break; + } + } + } catch (error) { + this.verboseMessage(`Could not analyze JavaScript API: ${error}`); + } + + return methods; + } + + private async analyzeAndroidApi(): Promise { + const methods: MethodSignature[] = []; + + try { + const androidDir = path.join(this.root, 'android'); + if (await fs.exists(androidDir)) { + const javaFiles = glob.sync('**/*.java', {cwd: androidDir}); + const kotlinFiles = glob.sync('**/*.kt', {cwd: androidDir}); + + for (const file of [...javaFiles, ...kotlinFiles]) { + const content = await fs.readFile( + path.join(androidDir, file), + 'utf8', + ); + if (content.includes('@ReactMethod')) { + methods.push(...this.parseAndroidMethods(content)); + this.verboseMessage(`Analyzed Android API from ${file}`); + } + } + } + } catch (error) { + this.verboseMessage(`Could not analyze Android API: ${error}`); + } + + return methods; + } + + private async analyzeIosApi(): Promise { + const methods: MethodSignature[] = []; + + try { + const iosDir = path.join(this.root, 'ios'); + if (await fs.exists(iosDir)) { + const objcFiles = glob.sync('**/*.{m,mm}', {cwd: iosDir}); + + for (const file of objcFiles) { + const content = await fs.readFile(path.join(iosDir, file), 'utf8'); + if (content.includes('RCT_EXPORT_METHOD')) { + methods.push(...this.parseIosMethods(content)); + this.verboseMessage(`Analyzed iOS API from ${file}`); + } + } + } + } catch (error) { + this.verboseMessage(`Could not analyze iOS API: ${error}`); + } + + return methods; + } + + private async analyzeReadmeApi(): Promise { + const methods: MethodSignature[] = []; + + try { + const readmeFiles = ['README.md', 'readme.md', 'README.txt']; + for (const file of readmeFiles) { + const filePath = path.join(this.root, file); + if (await fs.exists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + methods.push(...this.parseReadmeMethods(content)); + this.verboseMessage(`Analyzed API documentation from ${file}`); + break; + } + } + } catch (error) { + this.verboseMessage(`Could not analyze README API: ${error}`); + } + + return methods; + } + + private parseJavaScriptMethods(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Look for method exports and function definitions + const patterns = [ + /export\s+(?:const|function)\s+(\w+)\s*[:=]\s*(?:async\s+)?\([^)]*\)(?:\s*:\s*([^{;]+))?/g, + /(\w+)\s*:\s*(?:async\s+)?\([^)]*\)(?:\s*=>\s*([^,}]+))?/g, + /function\s+(\w+)\s*\([^)]*\)(?:\s*:\s*([^{]+))?/g, + ]; + + patterns.forEach(pattern => { + let match; + while ((match = pattern.exec(content)) !== null) { + methods.push({ + name: match[1], + returnType: this.parseReturnType(match[2] || 'void'), + parameters: this.parseParameters(match[0]), + }); + } + }); + + return methods; + } + + private parseAndroidMethods(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Look for @ReactMethod annotations + const reactMethodPattern = + /@ReactMethod[\s\S]*?(?:public|private)\s+(\w+)\s+(\w+)\s*\([^)]*\)/g; + let match; + + while ((match = reactMethodPattern.exec(content)) !== null) { + methods.push({ + name: match[2], + returnType: this.mapJavaTypeToTS(match[1]), + parameters: [], + }); + } + + return methods; + } + + private parseIosMethods(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Look for RCT_EXPORT_METHOD + const exportMethodPattern = /RCT_EXPORT_METHOD\s*\(\s*(\w+)/g; + let match; + + while ((match = exportMethodPattern.exec(content)) !== null) { + methods.push({ + name: match[1], + returnType: 'void', + parameters: [], + }); + } + + return methods; + } + + private parseReadmeMethods(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Look for method signatures in markdown code blocks + const codeBlockPattern = /```[\w]*\n([\s\S]*?)\n```/g; + let match; + + while ((match = codeBlockPattern.exec(content)) !== null) { + const code = match[1]; + // Look for function-like patterns + const functionPattern = /(\w+)\s*\([^)]*\)/g; + let funcMatch; + + while ((funcMatch = functionPattern.exec(code)) !== null) { + if ( + !funcMatch[1].includes('import') && + !funcMatch[1].includes('require') + ) { + methods.push({ + name: funcMatch[1], + returnType: 'Promise', + parameters: [], + }); + } + } + } + + return methods; + } + + private parseReturnType(type: string): string { + if (!type || type === 'void') return 'void'; + if (type.includes('Promise')) return type; + return `Promise<${type}>`; + } + + private parseParameters(methodSignature: string): Parameter[] { + // Extract parameters from method signature + const paramMatch = methodSignature.match(/\(([^)]*)\)/); + if (!paramMatch) return []; + + const params = paramMatch[1] + .split(',') + .map(p => p.trim()) + .filter(p => p); + return params.map(param => { + const [name, type] = param.split(':').map(s => s.trim()); + return { + name: name || 'param', + type: type || 'any', + }; + }); + } + + private mapJavaTypeToTS(javaType: string): string { + const typeMap: {[key: string]: string} = { + void: 'void', + boolean: 'boolean', + int: 'number', + float: 'number', + double: 'number', + String: 'string', + ReadableMap: 'object', + ReadableArray: 'any[]', + }; + + return typeMap[javaType] || 'any'; + } + + private generateSpecFileContent( + moduleName: string, + methods: MethodSignature[], + ): string { + const methodSignatures = methods + .map(method => { + const params = method.parameters + .map(p => `${p.name}: ${p.type}`) + .join(', '); + return ` ${method.name}(${params}): ${method.returnType};`; + }) + .join('\n'); + + const defaultMethods = + methods.length === 0 + ? ` // Add your module methods here + // Example: + // getString(value: string): Promise; + // getNumber(value: number): Promise; + // getBoolean(value: boolean): Promise;` + : methodSignatures; + + return `/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import {TurboModuleRegistry} from 'react-native'; + +export interface Spec extends TurboModule { +${defaultMethods} +} + +export default TurboModuleRegistry.getEnforcing('${moduleName}'); +`; + } + + private async updatePackageJsonCodegen(): Promise { + this.verboseMessage( + 'Checking and updating package.json codegen configuration...', + ); + + const packageJsonPath = path.join(this.root, 'package.json'); + const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + + if (!pkgJson.codegenConfig) { + const moduleName = this.getActualModuleName( + pkgJson.name || 'SampleModule', + ); + + pkgJson.codegenConfig = { + name: moduleName, + type: 'modules', + jsSrcsDir: '.', + windows: { + namespace: `${moduleName}Specs`, + outputDirectory: 'codegen', + generators: ['modulesWindows'], + }, + }; + + await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2)); + this.verboseMessage( + `Added codegenConfig to package.json with module name: ${moduleName}`, + ); + } else { + this.verboseMessage('codegenConfig already exists in package.json'); + } + } + + private async upgradeDependencies(): Promise { + if (this.options.skipDeps) { + this.verboseMessage('Skipping dependency upgrades'); + return; + } + + // this.verboseMessage( + // 'Upgrading React Native and React Native Windows to latest versions...', + // ); + + try { + // Suppress deprecation warnings to clean up output + const env = { + ...process.env, + NODE_NO_WARNINGS: '1', + }; + // Get latest versions with timeout to avoid hanging + const rnLatest = execSync('npm view react-native version', { + encoding: 'utf8', + timeout: 30000, + env, + }).trim(); + const rnwLatest = execSync('npm view react-native-windows version', { + encoding: 'utf8', + timeout: 30000, + env, + }).trim(); + + this.verboseMessage(`Latest RN version: ${rnLatest}`); + this.verboseMessage(`Latest RNW version: ${rnwLatest}`); + + // Update package.json + // const packageJsonPath = path.join(this.root, 'package.json'); + // const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + + // if (!pkgJson.peerDependencies) { + // pkgJson.peerDependencies = {}; + // } + // if (!pkgJson.devDependencies) { + // pkgJson.devDependencies = {}; + // } + + // pkgJson.peerDependencies['react-native'] = `^${rnLatest}`; + // pkgJson.devDependencies['react-native-windows'] = `^${rnwLatest}`; + + // await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2)); + // this.verboseMessage('Updated dependency versions in package.json'); + + // // Install updated dependencies with timeout + // execSync('yarn install', { + // cwd: this.root, + // stdio: 'inherit', + // timeout: 120000, + // }); + } catch (error: any) { + this.verboseMessage( + `Warning: Could not upgrade dependencies: ${error.message}`, + ); + // Don't fail the entire process if dependency upgrade fails + } + } + + private async handleExistingWindowsDirectory(): Promise { + const windowsDir = path.join(this.root, 'windows'); + const windowsOldDir = path.join(this.root, 'windows_old'); + + if (await fs.exists(windowsDir)) { + this.verboseMessage( + 'Found existing windows directory, renaming to windows_old...', + ); + + try { + // If windows_old already exists, remove it first + if (await fs.exists(windowsOldDir)) { + this.verboseMessage('Removing existing windows_old directory...'); + await this.removeDirectoryRecursively(windowsOldDir); + } + + // Rename windows to windows_old + await fs.rename(windowsDir, windowsOldDir); + this.verboseMessage( + 'Successfully renamed windows directory to windows_old', + ); + } catch (error: any) { + this.verboseMessage( + `Warning: Could not rename windows directory: ${error.message}`, + ); + // Don't fail the entire process if we can't rename the directory + // The overwrite flag in init-windows should handle most conflicts + } + } + } + + private async runInitWindows(config: Config): Promise { + this.verboseMessage('Running init-windows with cpp-lib template...'); + + try { + // Create a modified config with the correct root directory + // The original config might point to an example directory instead of the package root + const modifiedConfig: Config = { + ...config, + root: this.root, // Use our validated package root instead of config.root + }; + + await initWindowsInternal([], modifiedConfig, { + template: 'cpp-lib', + overwrite: true, + logging: this.options.logging, + telemetry: this.options.telemetry, + }); + this.verboseMessage('init-windows completed successfully'); + } catch (error: any) { + // Check if windows directory was created even with errors + const windowsDir = path.join(this.root, 'windows'); + if (await fs.exists(windowsDir)) { + this.verboseMessage( + 'Windows directory exists, continuing despite init-windows warnings', + ); + } else { + throw new CodedError( + 'Unknown', + `Failed to run init-windows: ${error.message}`, + ); + } + } + } + + private async runCodegenWindows(config: Config): Promise { + this.verboseMessage('Running codegen-windows...'); + + try { + // Create a modified config with the correct root directory + // The original config might point to an example directory instead of the package root + const modifiedConfig: Config = { + ...config, + root: this.root, // Use our validated package root instead of config.root + }; + + await codegenWindowsInternal([], modifiedConfig, { + logging: this.options.logging, + telemetry: this.options.telemetry, + }); + this.verboseMessage('codegen-windows completed successfully'); + } catch (error: any) { + // Check if codegen directory was created even with errors + const codegenDir = path.join(this.root, 'codegen'); + if (await fs.exists(codegenDir)) { + this.verboseMessage( + 'Codegen directory exists, continuing despite codegen-windows warnings', + ); + } else { + throw new CodedError( + 'InvalidCodegenConfig', + `Failed to run codegen-windows: ${error.message}`, + ); + } + } + } + + private async getProjectDirectoryFromCodegenConfig(): Promise { + try { + const packageJsonPath = path.join(this.root, 'package.json'); + const pkgJson: any = JSON.parse( + await fs.readFile(packageJsonPath, 'utf8'), + ); + + if (pkgJson.codegenConfig?.windows?.outputDirectory) { + const outputDirectory = pkgJson.codegenConfig.windows.outputDirectory; + this.verboseMessage( + `Found codegenConfig.windows.outputDirectory: ${outputDirectory}`, + ); + + // Extract project directory from outputDirectory path + // E.g., "windows/ReactNativeWebview/codegen" -> "ReactNativeWebview" + const parts = outputDirectory.split('/'); + if (parts.length >= 2 && parts[0] === 'windows') { + const projectDir = parts[1]; + this.verboseMessage( + `Extracted project directory from outputDirectory: ${projectDir}`, + ); + return projectDir; + } + } + } catch (error) { + this.verboseMessage(`Error reading package.json codegenConfig: ${error}`); + } + + return null; + } + + private async generateStubFiles(): Promise { + this.verboseMessage('Generating C++ stub files...'); + + // Look for codegen directory in multiple possible locations + let codegenDir = path.join(this.root, 'codegen'); + let codegenLocation = 'root'; + + this.verboseMessage(`Searching for codegen directory at: ${codegenDir}`); + + if (!(await fs.exists(codegenDir))) { + this.verboseMessage( + 'Codegen directory not found in root, checking windows subdirectories...', + ); + + // Try looking in windows directory + const windowsDir = path.join(this.root, 'windows'); + this.verboseMessage(`Checking windows directory: ${windowsDir}`); + + if (await fs.exists(windowsDir)) { + const windowsSubdirs = await fs.readdir(windowsDir); + this.verboseMessage( + `Found windows subdirectories: ${windowsSubdirs.join(', ')}`, + ); + + // Look for subdirectories that might contain codegen + for (const subdir of windowsSubdirs) { + const subdirPath = path.join(windowsDir, subdir); + try { + const stats = await fs.stat(subdirPath); + if (stats.isDirectory()) { + const possibleCodegenDir = path.join(subdirPath, 'codegen'); + this.verboseMessage( + `Checking possible codegen directory: ${possibleCodegenDir}`, + ); + + if (await fs.exists(possibleCodegenDir)) { + codegenDir = possibleCodegenDir; + codegenLocation = `windows/${subdir}`; + this.verboseMessage( + `Found codegen directory at: ${codegenDir}`, + ); + break; + } + } + } catch (error) { + this.verboseMessage( + `Error checking subdirectory ${subdirPath}: ${error}`, + ); + continue; + } + } + } else { + this.verboseMessage('Windows directory does not exist'); + } + } else { + this.verboseMessage(`Found codegen directory at: ${codegenDir}`); + } + + if (!(await fs.exists(codegenDir))) { + this.verboseMessage( + 'No codegen directory found in root or windows subdirectories, skipping stub generation', + ); + return; + } + + const files = await fs.readdir(codegenDir); + const specFiles = files.filter(file => file.endsWith('Spec.g.h')); + + if (specFiles.length === 0) { + this.verboseMessage( + 'No Spec.g.h files found in codegen directory. This typically means the TurboModule spec file needs to be created or codegen needs to be run.', + ); + return; + } + + this.verboseMessage( + `Found ${ + specFiles.length + } codegen spec file(s) in ${codegenLocation}: ${specFiles.join(', ')}`, + ); + + // Read directory contents for debugging + try { + const allFiles = await fs.readdir(codegenDir); + this.verboseMessage( + `All files in codegen directory: ${allFiles.join(', ')}`, + ); + } catch (error) { + this.verboseMessage(`Error reading codegen directory contents: ${error}`); + } + + // First try to get project directory from package.json codegenConfig + const windowsDir = path.join(this.root, 'windows'); + let projectName = await this.getProjectDirectoryFromCodegenConfig(); + let moduleDir: string; + moduleDir = windowsDir; + + if (projectName) { + moduleDir = path.join(windowsDir, projectName); + this.verboseMessage( + `Using project directory from codegenConfig: ${moduleDir}`, + ); + + // Verify the directory exists + if (!(await fs.exists(moduleDir))) { + this.verboseMessage( + `Project directory from codegenConfig does not exist: ${moduleDir}`, + ); + projectName = null; // Fall back to search + } + } + + // If no project directory from codegenConfig or it doesn't exist, search for existing directory + if (!projectName) { + this.verboseMessage( + 'Searching for existing Windows project directory...', + ); + const actualModuleName = await this.getFinalModuleName(); + moduleDir = path.join(windowsDir, actualModuleName); + + // If the expected directory doesn't exist, find any existing project directory + if (!(await fs.exists(moduleDir))) { + this.verboseMessage( + `Expected directory ${moduleDir} not found, searching for existing Windows project directory...`, + ); + + try { + const windowsDirContents = await fs.readdir(windowsDir); + const projectDirs = []; + + for (const item of windowsDirContents) { + const itemPath = path.join(windowsDir, item); + const stats = await fs.stat(itemPath); + + if ( + stats.isDirectory() && + !item.startsWith('.') && + item !== 'ExperimentalFeatures.props' && + !item.endsWith('.sln') + ) { + // Check if this directory contains typical project files + const possibleHeaderFile = path.join(itemPath, `${item}.h`); + const possibleCppFile = path.join(itemPath, `${item}.cpp`); + if ( + (await fs.exists(possibleHeaderFile)) || + (await fs.exists(possibleCppFile)) + ) { + projectDirs.push(item); + } + } + } + + if (projectDirs.length > 0) { + projectName = projectDirs[0]; + moduleDir = path.join(windowsDir, projectName); + this.verboseMessage( + `Found existing Windows project directory: ${moduleDir}`, + ); + } else { + this.verboseMessage( + `No existing project directory found, using actualModuleName: ${actualModuleName}`, + ); + projectName = actualModuleName; + moduleDir = path.join(windowsDir, projectName); + await fs.mkdir(moduleDir, {recursive: true}); + } + } catch (error) { + this.verboseMessage( + `Error searching for Windows project directory: ${error}`, + ); + projectName = actualModuleName; + moduleDir = path.join(windowsDir, projectName); + await fs.mkdir(moduleDir, {recursive: true}); + } + } else { + projectName = actualModuleName; + } + } + + // Use the determined project directory name for file names + if (!projectName) { + projectName = path.basename(moduleDir); + } + + // Store the actual project path for the success message + this.actualProjectPath = path.join('windows', projectName, projectName); + + for (const specFile of specFiles) { + const headerPath = path.join(moduleDir, `${projectName}.h`); + const cppPath = path.join(moduleDir, `${projectName}.cpp`); + + // Parse method signatures from codegen files first, then fallback to TypeScript spec files + const specName = specFile.replace('Spec.g.h', ''); + const methods = await this.parseSpecFileForMethods(specName, codegenDir); + + if (methods.length === 0) { + this.verboseMessage( + `No methods found for ${specName}. The generated files will contain empty stubs with TODO comments.`, + ); + } else { + this.verboseMessage( + `Found ${methods.length} methods from ${specName}: ${methods + .map(m => m.name) + .join(', ')}`, + ); + } + + // Generate header file with parsed methods using the project name + const headerContent = await this.generateHeaderStub(projectName, methods); + // Always write the header file to ensure it has the correct methods from the spec + await fs.writeFile(headerPath, headerContent); + this.verboseMessage( + `Generated header stub: ${headerPath} with ${methods.length} methods`, + ); + + // Generate cpp file with parsed methods using the project name + const cppContent = await this.generateCppStub(projectName, methods); + // Always write the cpp file to ensure it has the correct methods from the spec + await fs.writeFile(cppPath, cppContent); + this.verboseMessage( + `Generated cpp stub: ${cppPath} with ${methods.length} methods`, + ); + } + } + + private async parseSpecFileForMethods( + moduleName: string, + codegenDir?: string, + ): Promise { + try { + // First, try to read from codegen C++ header files + const actualCodegenDir = codegenDir || path.join(this.root, 'codegen'); + if (await fs.exists(actualCodegenDir)) { + const methods = await this.parseCodegenHeaderFiles( + actualCodegenDir, + moduleName, + ); + if (methods.length > 0) { + this.verboseMessage( + `Extracted ${methods.length} methods from codegen files: ${methods + .map(m => m.name) + .join(', ')}`, + ); + return methods; + } + } + + // Fallback to TypeScript spec files if no codegen files found + let specFiles = this.discoveredSpecFiles; + + // If no discovered spec files, try to find them again with broader patterns + if (specFiles.length === 0) { + this.verboseMessage( + `Searching for spec files for module: ${moduleName}`, + ); + + // Try multiple patterns to find the spec file + const patterns = [ + `**/Native${moduleName}.[jt]s`, + `**/Native*${moduleName}*.[jt]s`, + `**/Native*.[jt]s`, + `src/**/Native*.[jt]s`, + `lib/**/Native*.[jt]s`, + ]; + + for (const pattern of patterns) { + const matches = glob.sync(pattern, { + cwd: this.root, + ignore: ['**/node_modules/**', '**/build/**', '**/dist/**'], + }); + if (matches.length > 0) { + specFiles = await this.filterValidSpecFiles(matches); + if (specFiles.length > 0) { + this.verboseMessage( + `Found spec files with pattern "${pattern}": ${specFiles.join( + ', ', + )}`, + ); + break; + } + } + } + } + + if (specFiles.length > 0) { + // Use the first valid spec file + const specPath = path.join(this.root, specFiles[0]); + this.verboseMessage(`Reading spec file: ${specPath}`); + const specContent = await fs.readFile(specPath, 'utf8'); + + // Parse method signatures from the Spec interface + const methods = this.extractMethodsFromSpecInterface(specContent); + if (methods.length > 0) { + this.verboseMessage( + `Extracted ${methods.length} methods from spec file: ${methods + .map(m => m.name) + .join(', ')}`, + ); + return methods; + } + } + + // If no methods found from any source, return empty array + this.verboseMessage( + `No spec file found for ${moduleName}, no methods will be generated`, + ); + return []; + } catch (error) { + this.verboseMessage( + `Could not parse spec file for ${moduleName}: ${error}`, + ); + return []; + } + } + + private async parseCodegenHeaderFiles( + codegenDir: string, + moduleName: string, + ): Promise { + try { + this.verboseMessage(`Looking for codegen files in: ${codegenDir}`); + + const files = await fs.readdir(codegenDir); + const specFiles = files.filter(file => file.endsWith('Spec.g.h')); + + this.verboseMessage(`Found codegen spec files: ${specFiles.join(', ')}`); + + for (const specFile of specFiles) { + const specPath = path.join(codegenDir, specFile); + this.verboseMessage(`Reading codegen file: ${specPath}`); + + try { + const content = await fs.readFile(specPath, 'utf8'); + this.verboseMessage( + `Successfully read ${content.length} characters from ${specFile}`, + ); + + // Show first few lines for debugging + const firstLines = content.split('\n').slice(0, 10).join('\n'); + this.verboseMessage(`First 10 lines of ${specFile}:\n${firstLines}`); + + // Extract methods from the codegen C++ header + const methods = this.extractMethodsFromCodegenHeader(content); + if (methods.length > 0) { + this.verboseMessage( + `Parsed ${methods.length} methods from ${specFile}: ${methods + .map(m => m.name) + .join(', ')}`, + ); + return methods; + } else { + this.verboseMessage(`No methods extracted from ${specFile}`); + } + } catch (readError) { + this.verboseMessage(`Error reading file ${specPath}: ${readError}`); + } + } + + this.verboseMessage('No methods found in any codegen files'); + return []; + } catch (error) { + this.verboseMessage(`Error parsing codegen files: ${error}`); + return []; + } + } + + private extractMethodsFromCodegenHeader(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Parse from REACT_SHOW_METHOD_SPEC_ERRORS sections which contain the exact method signatures + const errorSectionPattern = + /REACT_SHOW_METHOD_SPEC_ERRORS\s*\(\s*\d+,\s*"([^"]+)",\s*"[^"]*REACT_METHOD\(([^)]+)\)\s+(?:static\s+)?void\s+(\w+)\s*\(([^)]*)\)[^"]*"/g; + + let match; + while ((match = errorSectionPattern.exec(content)) !== null) { + const methodName = match[1]; // Method name from first parameter + const parameters = this.parseCodegenParameters(match[4]); // Parameters from method signature + + methods.push({ + name: methodName, + returnType: 'void', // Codegen methods are typically void with callbacks or promises + parameters: parameters, + }); + } + + // Also try to parse from the methods tuple for additional methods + if (methods.length === 0) { + const methodsTuplePattern = + /Method<([^>]+)>\{\s*(\d+),\s*L"([^"]+)"\s*\}/g; + + while ((match = methodsTuplePattern.exec(content)) !== null) { + const signature = match[1]; // Method signature type + const methodName = match[3]; // Method name + const parameters = this.parseCodegenSignature(signature); + + methods.push({ + name: methodName, + returnType: 'void', + parameters: parameters, + }); + } + } + + // Try parsing directly from C++ method declarations in the header + if (methods.length === 0) { + this.verboseMessage( + 'Trying to parse C++ method declarations directly...', + ); + + // Look for virtual method declarations like: + // virtual void MethodName(parameters) = 0; + const virtualMethodPattern = + /virtual\s+(\w+(?:\s*\*)?)\s+(\w+)\s*\(([^)]*)\)\s*(?:const\s*)?=\s*0\s*;/g; + + while ((match = virtualMethodPattern.exec(content)) !== null) { + const returnType = match[1].trim(); + const methodName = match[2]; + const paramString = match[3]; + const parameters = this.parseCodegenParameters(paramString); + + methods.push({ + name: methodName, + returnType: returnType === 'void' ? 'void' : returnType, + parameters: parameters, + }); + } + } + + // Try parsing from JSI method declarations + if (methods.length === 0) { + this.verboseMessage('Trying to parse JSI method declarations...'); + + // Look for JSI-style method declarations + const jsiMethodPattern = + /static\s+jsi::Value\s+__hostFunction_(\w+)\s*\([^)]*\)\s*{/g; + + while ((match = jsiMethodPattern.exec(content)) !== null) { + const methodName = match[1]; + + methods.push({ + name: methodName, + returnType: 'void', + parameters: [], // JSI methods will be parsed separately for parameters + }); + } + } + + // Try parsing from struct member methods + if (methods.length === 0) { + this.verboseMessage('Trying to parse struct member methods...'); + + // Look for struct methods like: + // bool MessagingEnabled() const; + // void MessagingEnabled(bool enabled); + const structMethodPattern = + /^\s*(?:virtual\s+)?(\w+(?:\s*&)?)\s+(\w+)\s*\(([^)]*)\)\s*(?:const\s*)?(?:noexcept\s*)?(?:=\s*0\s*)?;/gm; + + while ((match = structMethodPattern.exec(content)) !== null) { + const returnType = match[1].trim(); + const methodName = match[2]; + const paramString = match[3]; + const parameters = this.parseCodegenParameters(paramString); + + // Skip common non-API methods + if ( + !methodName.startsWith('~') && + !methodName.includes('Destructor') && + !methodName.includes('Constructor') && + methodName !== 'getContext' && + methodName !== 'invalidate' + ) { + methods.push({ + name: methodName, + returnType: returnType === 'void' ? 'void' : returnType, + parameters: parameters, + }); + } + } + } + + this.verboseMessage( + `Extracted ${methods.length} methods from codegen header using multiple parsing strategies`, + ); + return methods; + } + + private parseCodegenParameters(paramString: string): Parameter[] { + if (!paramString || paramString.trim() === '') { + return []; + } + + const params: Parameter[] = []; + + // Split parameters carefully, handling nested templates like std::function + const cleanParamString = paramString.trim(); + let current = ''; + let depth = 0; + let inString = false; + + for (let i = 0; i < cleanParamString.length; i++) { + const char = cleanParamString[i]; + + if (char === '"' && cleanParamString[i - 1] !== '\\') { + inString = !inString; + } else if (!inString) { + if (char === '<' || char === '(') { + depth++; + } else if (char === '>' || char === ')') { + depth--; + } else if (char === ',' && depth === 0) { + if (current.trim()) { + params.push(this.parseCodegenParameter(current.trim())); + } + current = ''; + continue; + } + } + + current += char; + } + + if (current.trim()) { + params.push(this.parseCodegenParameter(current.trim())); + } + + return params; + } + + private parseCodegenParameter(param: string): Parameter { + // Handle common codegen parameter patterns + param = param.trim(); + + // std::function const & callback -> callback parameter + if (param.includes('std::function')) { + if (param.includes('onSuccess') || param.includes('callback')) { + return {name: 'callback', type: 'function'}; + } else if (param.includes('onError')) { + return {name: 'onError', type: 'function'}; + } else { + return {name: 'callback', type: 'function'}; + } + } + + // Extract parameter name from the end + const parts = param.split(/\s+/); + let name = parts[parts.length - 1].replace(/[&*]/g, ''); // Remove references/pointers + + // Handle winrt types and const references + if (name.includes('const')) { + name = parts[parts.length - 2] || 'param'; + } + + // Map common codegen types + let type = 'any'; + if (param.includes('std::string') || param.includes('winrt::hstring')) { + type = 'string'; + } else if (param.includes('double') || param.includes('float')) { + type = 'number'; + } else if (param.includes('bool')) { + type = 'boolean'; + } else if ( + param.includes('int32_t') || + param.includes('int64_t') || + param.includes('int') + ) { + type = 'number'; + } else if (param.includes('JSValue')) { + type = 'any'; + } else if (param.includes('winrt::')) { + type = 'string'; // Most winrt types are strings or can be treated as such + } + + return {name: name || 'param', type}; + } + + private parseCodegenSignature(signature: string): Parameter[] { + // Parse Method signature to extract parameters + const paramMatch = signature.match(/void\s*\(([^)]*)\)/); + if (!paramMatch) { + return []; + } + + return this.parseCodegenParameters(paramMatch[1]); + } + + private extractMethodsFromSpecInterface(content: string): MethodSignature[] { + const methods: MethodSignature[] = []; + + // Find the Spec interface definition + const interfaceMatch = content.match( + /export\s+interface\s+Spec\s+extends\s+TurboModule\s*\{([\s\S]*?)\}/, + ); + if (!interfaceMatch) { + return methods; + } + + const interfaceBody = interfaceMatch[1]; + + // Parse method signatures from the interface + const methodPattern = /(\w+)\s*\(\s*([^)]*)\s*\)\s*:\s*([^;]+);/g; + let match; + + while ((match = methodPattern.exec(interfaceBody)) !== null) { + const methodName = match[1]; + const paramString = match[2].trim(); + const returnType = match[3].trim(); + + // Skip comments and empty lines + if (methodName.startsWith('//') || !methodName) { + continue; + } + + const parameters = this.parseParameterString(paramString); + + methods.push({ + name: methodName, + returnType: returnType, + parameters: parameters, + }); + } + + return methods; + } + + private parseParameterString(paramString: string): Parameter[] { + if (!paramString || paramString.trim() === '') { + return []; + } + + const params = paramString + .split(',') + .map(p => p.trim()) + .filter(p => p); + return params.map(param => { + const colonIndex = param.lastIndexOf(':'); + if (colonIndex === -1) { + return {name: param, type: 'any'}; + } + + const name = param.substring(0, colonIndex).trim(); + const type = param.substring(colonIndex + 1).trim(); + + return {name, type}; + }); + } + + private async getNamespaceInfo(): Promise<{ + namespace: string; + codegenNamespace: string; + }> { + try { + const packageJsonPath = path.join(this.root, 'package.json'); + const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + const actualModuleName = this.getActualModuleName( + pkgJson.name || 'SampleModule', + ); + + // Create reasonable namespace from package name + const namespace = this.getModuleName(pkgJson.name || 'SampleModule'); + const codegenNamespace = `${actualModuleName}Codegen`; + + return {namespace, codegenNamespace}; + } catch (error) { + // Fallback + return { + namespace: 'ReactNativeWebview', + codegenNamespace: 'ReactNativeWebviewCodegen', + }; + } + } + + private async generateHeaderStub( + moduleName: string, + methods: MethodSignature[], + ): Promise { + const {namespace, codegenNamespace} = await this.getNamespaceInfo(); + const methodDeclarations = methods + .map(method => { + const cppParams = method.parameters + .map(p => { + if (p.type === 'function') { + // Handle callback functions from codegen + if (p.name.includes('onSuccess') || p.name === 'callback') { + return `std::function const & ${p.name}`; + } else if (p.name.includes('onError')) { + return `std::function const & ${p.name}`; + } else { + return `std::function const & ${p.name}`; + } + } else { + let cppType = this.mapTSToCppType(p.type); + // Use winrt::hstring for string parameters to match Windows conventions + if (p.type === 'string') { + cppType = 'winrt::hstring const&'; + } + return `${cppType} ${p.name}`; + } + }) + .join(', '); + + // Determine if this is a getter method (no parameters and non-void return type) + const isGetter = + method.parameters.length === 0 && method.returnType !== 'void'; + const returnType = isGetter + ? this.mapTSToCppType(method.returnType) + : 'void'; + const constModifier = isGetter ? ' const' : ''; + + return ` REACT_METHOD(${method.name}) + ${returnType} ${method.name}(${cppParams}) noexcept${constModifier};`; + }) + .join('\n\n'); + + const defaultMethods = + methods.length === 0 + ? ` // TODO: Add your method implementations here + // Example: + // REACT_METHOD(getString) + // void getString(std::string value, std::function const & callback) noexcept;` + : methodDeclarations; + + return `#pragma once + +#include "pch.h" +#include "resource.h" + +#if __has_include("codegen/Native${moduleName}DataTypes.g.h") + #include "codegen/Native${moduleName}DataTypes.g.h" +#endif +#include "codegen/Native${moduleName}Spec.g.h" + +#include "NativeModules.h" + +namespace winrt::${namespace} +{ + +// See https://microsoft.github.io/react-native-windows/docs/native-platform for help writing native modules + +REACT_MODULE(${moduleName}) +struct ${moduleName} +{ + using ModuleSpec = ${codegenNamespace}::${moduleName}Spec; + + REACT_INIT(Initialize) + void Initialize(React::ReactContext const &reactContext) noexcept; + +${defaultMethods} + +private: + React::ReactContext m_context; +}; + +} // namespace winrt::${namespace}`; + } + + private async generateCppStub( + moduleName: string, + methods: MethodSignature[], + ): Promise { + const {namespace} = await this.getNamespaceInfo(); + const methodImplementations = methods + .map(method => { + const cppParams = method.parameters + .map(p => { + if (p.type === 'function') { + // Handle callback functions from codegen + if (p.name.includes('onSuccess') || p.name === 'callback') { + return `std::function const & ${p.name}`; + } else if (p.name.includes('onError')) { + return `std::function const & ${p.name}`; + } else { + return `std::function const & ${p.name}`; + } + } else { + let cppType = this.mapTSToCppType(p.type); + // Use winrt::hstring for string parameters to match Windows conventions + if (p.type === 'string') { + cppType = 'winrt::hstring const&'; + } + return `${cppType} ${p.name}`; + } + }) + .join(', '); + + // Determine if this is a getter method (no parameters and non-void return type) + const isGetter = + method.parameters.length === 0 && method.returnType !== 'void'; + const returnType = isGetter + ? this.mapTSToCppType(method.returnType) + : 'void'; + const constModifier = isGetter ? ' const' : ''; + + // Generate implementation based on method type + const hasCallback = method.parameters.some( + p => + p.type === 'function' && + (p.name.includes('onSuccess') || p.name === 'callback'), + ); + const hasErrorCallback = method.parameters.some( + p => p.type === 'function' && p.name.includes('onError'), + ); + + let implementation = ` // TODO: Implement ${method.name}`; + + if (isGetter) { + // Getter method - return default value + const defaultValue = this.generateDefaultValue(method.returnType); + implementation += `\n return ${defaultValue};`; + } else if (hasCallback && hasErrorCallback) { + // Method with success and error callbacks + implementation += `\n // Example: callback(); // Call on success\n // Example: onError(React::JSValue{"Error message"}); // Call on error`; + } else if (hasCallback) { + // Method with just callback + implementation += `\n // Example: callback(); // Call when complete`; + } + + return `${returnType} ${moduleName}::${method.name}(${cppParams}) noexcept${constModifier} { +${implementation} +}`; + }) + .join('\n\n'); + + const defaultImplementations = + methods.length === 0 + ? `// TODO: Implement your methods here +// Example: +// void ${moduleName}::getString(std::string value, std::function const & callback) noexcept { +// callback("Hello " + value); +// }` + : methodImplementations; + + return `#include "${moduleName}.h" + +namespace winrt::${namespace} { + +void ${moduleName}::Initialize(React::ReactContext const &reactContext) noexcept { + m_context = reactContext; +} + +${defaultImplementations} + +} // namespace winrt::${namespace} +`; + } + + private mapTSToCppType(tsType: string): string { + const typeMap: {[key: string]: string} = { + string: 'std::string', + number: 'double', + boolean: 'bool', + object: 'React::JSValue', + any: 'React::JSValue', + 'any[]': 'React::JSValueArray', + void: 'void', + Double: 'double', // React Native codegen type + Int32: 'int32_t', // React Native codegen type + Float: 'float', // React Native codegen type + }; + + // Handle array types + if (tsType.endsWith('[]')) { + const baseType = tsType.slice(0, -2); + const cppBaseType = typeMap[baseType] || 'React::JSValue'; + return `std::vector<${cppBaseType}>`; + } + + return typeMap[tsType] || 'React::JSValue'; + } + + private generateDefaultValue(returnType: string): string { + if (returnType === 'string') { + return '"example"'; + } else if (returnType === 'number') { + return '0'; + } else if (returnType === 'boolean') { + return 'false'; + } else { + return 'React::JSValue{}'; + } + } + + private async verifyBuild(): Promise { + if (this.options.skipBuild) { + this.verboseMessage('Skipping build verification'); + return; + } + + this.verboseMessage('Verifying build...'); + + const windowsDir = path.join(this.root, 'windows'); + if (!(await fs.exists(windowsDir))) { + this.verboseMessage( + 'No windows directory found, skipping build verification', + ); + return; + } + + const files = await fs.readdir(windowsDir); + const slnFiles = files.filter(file => file.endsWith('.sln')); + if (slnFiles.length === 0) { + this.verboseMessage('No .sln file found, skipping build verification'); + return; + } + + try { + // Just check if MSBuild is available, don't actually build to keep setup fast + execSync('where msbuild', {stdio: 'ignore'}); + this.verboseMessage('MSBuild found, project should be buildable'); + } catch { + this.verboseMessage( + 'Warning: MSBuild not found, cannot verify build capability', + ); + } + } + + private async cleanAndInstallDeps(): Promise { + this.verboseMessage('Installing dependencies...'); + + // Skip node_modules cleaning as it can cause permission issues on Windows + // and yarn install will handle dependency updates anyway + try { + // Suppress deprecation warnings to clean up output + const env = { + ...process.env, + NODE_NO_WARNINGS: '1', + }; + execSync('yarn install', {cwd: this.root, stdio: 'inherit', env}); + this.verboseMessage('Dependencies installed'); + } catch (error: any) { + throw new CodedError( + 'Unknown', + `Failed to install dependencies: ${error.message}`, + ); + } + } + + public async run(spinner: Ora, config: Config): Promise { + await this.validateEnvironment(); + spinner.text = 'Checking and creating spec file...'; + + await this.checkAndCreateSpecFile(); + spinner.text = 'Updating package.json...'; + + await this.updatePackageJsonCodegen(); + spinner.text = 'Installing dependencies...'; + + await this.cleanAndInstallDeps(); + spinner.text = 'Upgrading dependencies...'; + + await this.upgradeDependencies(); + spinner.text = 'Handling existing Windows directory...'; + + await this.handleExistingWindowsDirectory(); + spinner.text = 'Setting up Windows library...'; + + await this.runInitWindows(config); + spinner.text = 'Running Windows codegen...'; + + await this.runCodegenWindows(config); + + // Wait a bit for codegen files to be fully written to disk + this.verboseMessage('Waiting for codegen files to be written...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + spinner.text = 'Generating C++ stub files...'; + + await this.generateStubFiles(); + spinner.text = 'Verifying build setup...'; + + await this.verifyBuild(); + + spinner.succeed(); + } +} + +/** + * Sanitizes the given option for telemetry. + * @param key The key of the option. + * @param value The unsanitized value of the option. + * @returns The sanitized value of the option. + */ +function optionSanitizer( + key: keyof ModuleWindowsSetupOptions, + value: any, +): any { + switch (key) { + case 'logging': + case 'telemetry': + case 'skipDeps': + case 'skipBuild': + return value === undefined ? false : value; + } +} + +/** + * Get the extra props to add to the `module-windows-setup` telemetry event. + * @returns The extra props. + */ +async function getExtraProps(): Promise> { + const extraProps: Record = {}; + return extraProps; +} + +/** + * The function run when calling `npx @react-native-community/cli module-windows-setup`. + * @param args Unprocessed args passed from react-native CLI. + * @param config Config passed from react-native CLI. + * @param options Options passed from react-native CLI. + */ +async function moduleWindowsSetup( + args: string[], + config: Config, + options: ModuleWindowsSetupOptions, +) { + await startTelemetrySession( + 'module-windows-setup', + config, + options, + getDefaultOptions(config, moduleWindowsSetupOptions), + optionSanitizer, + ); + + let moduleWindowsSetupError: Error | undefined; + try { + await moduleWindowsSetupInternal(args, config, options); + } catch (ex) { + moduleWindowsSetupError = + ex instanceof Error ? (ex as Error) : new Error(String(ex)); + Telemetry.trackException(moduleWindowsSetupError); + } + + await endTelemetrySession(moduleWindowsSetupError, getExtraProps); + setExitProcessWithError(options.logging, moduleWindowsSetupError); +} + +/** + * Sets up Windows support for React Native community modules. + * @param args Unprocessed args passed from react-native CLI. + * @param config Config passed from react-native CLI. + * @param options Options passed from react-native CLI. + */ +export async function moduleWindowsSetupInternal( + args: string[], + config: Config, + options: ModuleWindowsSetupOptions, +) { + // Suppress Node.js deprecation warnings to clean up output + const originalNoWarnings = process.env.NODE_NO_WARNINGS; + if (!options.logging) { + process.env.NODE_NO_WARNINGS = '1'; + } + + const startTime = performance.now(); + const spinner = newSpinner( + 'Setting up Windows support for React Native module...', + ); + + try { + const setup = new ModuleWindowsSetup(config.root, options); + await setup.run(spinner, config); + const endTime = performance.now(); + + // Get the actual module name and project paths for display + const moduleName = await setup.getFinalModuleName(); + const projectPaths = setup.getActualProjectPaths(); + + console.log( + `${chalk.green('Success:')} Windows module setup completed! (${Math.round( + endTime - startTime, + )}ms)`, + ); + console.log(''); + console.log( + chalk.bold('🎉 Your React Native module now supports Windows!'), + ); + console.log(''); + console.log(chalk.bold('Files created/updated:')); + console.log(`📄 package.json - Added codegen configuration`); + console.log( + `🏗️ Native${moduleName}.ts - TurboModule spec file (edit with your API)`, + ); + console.log( + `💻 ${projectPaths.headerPath} - C++ header file (implement your methods here)`, + ); + console.log( + `⚙️ ${projectPaths.cppPath} - C++ implementation file (add your logic here)`, + ); + console.log(''); + console.log(chalk.bold('Next steps:')); + console.log( + "1. 📝 Update the generated spec file with your module's interface", + ); + console.log('2. 🔧 Implement the methods in the generated C++ stub files'); + console.log('3. 🏗️ Build your project to verify everything works'); + console.log( + '4. 📚 See the documentation for more details on TurboModule development', + ); + console.log(''); + console.log( + chalk.dim( + 'For help, visit: https://microsoft.github.io/react-native-windows/', + ), + ); + console.log(''); + } catch (e) { + spinner.fail(); + const endTime = performance.now(); + console.log( + `${chalk.red('Error:')} ${(e as any).toString()}. (${Math.round( + endTime - startTime, + )}ms)`, + ); + throw e; + } finally { + // Restore original NODE_NO_WARNINGS setting + if (originalNoWarnings !== undefined) { + process.env.NODE_NO_WARNINGS = originalNoWarnings; + } else { + delete process.env.NODE_NO_WARNINGS; + } + } +} + +/** + * Sets up Windows support for React Native community modules. + */ +export const moduleWindowsSetupCommand: Command = { + name: 'module-windows-setup', + description: + 'Streamlined setup of Windows support for React Native community modules', + func: moduleWindowsSetup, + options: moduleWindowsSetupOptions, +}; diff --git a/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetupOptions.ts b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetupOptions.ts new file mode 100644 index 00000000000..2c18610e5e4 --- /dev/null +++ b/packages/@react-native-windows/cli/src/commands/moduleWindowsSetup/moduleWindowsSetupOptions.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import type {CommandOption} from '@react-native-community/cli-types'; + +export interface ModuleWindowsSetupOptions { + logging?: boolean; + telemetry?: boolean; + skipDeps?: boolean; + skipBuild?: boolean; +} + +export const moduleWindowsSetupOptions: CommandOption[] = [ + { + name: '--logging', + description: 'Verbose output logging', + }, + { + name: '--no-telemetry', + description: + 'Disables sending telemetry that allows analysis of usage and failures of the react-native-windows CLI', + }, + { + name: '--skip-deps', + description: 'Skip dependency upgrades (use current versions)', + }, + { + name: '--skip-build', + description: 'Skip final build verification step', + }, +]; diff --git a/packages/@react-native-windows/cli/src/index.ts b/packages/@react-native-windows/cli/src/index.ts index 7cae7a0e285..40b7b248a5f 100644 --- a/packages/@react-native-windows/cli/src/index.ts +++ b/packages/@react-native-windows/cli/src/index.ts @@ -17,6 +17,7 @@ import * as pathHelpers from './utils/pathHelpers'; import {autolinkCommand} from './commands/autolinkWindows/autolinkWindows'; import {codegenCommand} from './commands/codegenWindows/codegenWindows'; import {initCommand} from './commands/initWindows/initWindows'; +import {moduleWindowsSetupCommand} from './commands/moduleWindowsSetup/moduleWindowsSetup'; import {runWindowsCommand} from './commands/runWindows/runWindows'; import {dependencyConfigWindows} from './commands/config/dependencyConfig'; import {projectConfigWindows} from './commands/config/projectConfig'; @@ -98,6 +99,7 @@ export const commands = [ autolinkCommand, codegenCommand, initCommand, + moduleWindowsSetupCommand, runWindowsCommand, ]; export const dependencyConfig = dependencyConfigWindows; diff --git a/packages/@react-native-windows/cli/tsconfig.json b/packages/@react-native-windows/cli/tsconfig.json index 9b9d9b6ff47..2b0ff8507b1 100644 --- a/packages/@react-native-windows/cli/tsconfig.json +++ b/packages/@react-native-windows/cli/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "@rnw-scripts/ts-config", "compilerOptions": { - // Change the default to allow ES2019.String "trimEnd" + // Change the default to allow ES2019.String "trimEnd" and ES2017 for Object.entries "lib": [ "DOM", "ES6", + "ES2017.Object", "DOM.Iterable", "ES2019.String" ], diff --git a/packages/@rnw-scripts/beachball-config/package.json b/packages/@rnw-scripts/beachball-config/package.json index 3e85ad948e7..c74bd6bf5f7 100644 --- a/packages/@rnw-scripts/beachball-config/package.json +++ b/packages/@rnw-scripts/beachball-config/package.json @@ -38,6 +38,6 @@ "lib-commonjs" ], "engines": { - "node": ">= 22.14.0" + "node": ">= 20" } }