From 445182c7076593863bcf7dd191d2c4285d6c4b22 Mon Sep 17 00:00:00 2001 From: Gant Date: Sat, 10 Dec 2016 16:53:53 -0800 Subject: [PATCH] react-native link (aka rnpm) support for Windows Summary: Seeing as [Windows is a supported platform](https://github.com/facebook/react-native/blob/72157cf99164d00c7a14b6b9ca51b406080b5265/packager/defaults.js#L22) until platforms can better manager their own CLI and packager needs. Linking 3rd party libraries should be supported first, because then I'd like to do a follow up PR with grabbou to identify how we can effectively move RNPM functionality out of react-native core and eventually housed in each external platform's repo. The goal would be working with cpojer and hopefully andrewimm to help keep external platform needs in their respective repos, for rnpm/packager _et al._ Seeing as this is a major discussion point, I've made this PR first. Making small steps towards this goal, seems to be the approved methodology from all. Additionally, I have a merged PR that makes an excellent place for documenting the CLI when it advances, as preparatio Closes https://github.com/facebook/react-native/pull/11282 Differential Revision: D4311391 fbshipit-source-id: be9a836344be4aed6c4732b0ce4947c2a16b6dad --- local-cli/core/config/index.js | 3 + .../core/config/windows/findNamespace.js | 30 +++++ .../config/windows/findPackageClassName.js | 30 +++++ local-cli/core/config/windows/findProject.js | 27 +++++ .../config/windows/findWindowsSolution.js | 60 +++++++++ local-cli/core/config/windows/generateGUID.js | 10 ++ local-cli/core/config/windows/index.js | 114 ++++++++++++++++++ local-cli/link/link.js | 32 ++++- local-cli/link/unlink.js | 22 ++++ local-cli/link/windows/isInstalled.js | 8 ++ local-cli/link/windows/patches/applyParams.js | 23 ++++ local-cli/link/windows/patches/applyPatch.js | 11 ++ .../link/windows/patches/makePackagePatch.js | 10 ++ .../link/windows/patches/makeProjectPatch.js | 14 +++ .../link/windows/patches/makeSolutionPatch.js | 12 ++ .../link/windows/patches/makeUsingPatch.js | 6 + local-cli/link/windows/patches/revokePatch.js | 9 ++ .../link/windows/registerNativeModule.js | 26 ++++ .../link/windows/unregisterNativeModule.js | 27 +++++ 19 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 local-cli/core/config/windows/findNamespace.js create mode 100644 local-cli/core/config/windows/findPackageClassName.js create mode 100644 local-cli/core/config/windows/findProject.js create mode 100644 local-cli/core/config/windows/findWindowsSolution.js create mode 100644 local-cli/core/config/windows/generateGUID.js create mode 100644 local-cli/core/config/windows/index.js create mode 100644 local-cli/link/windows/isInstalled.js create mode 100644 local-cli/link/windows/patches/applyParams.js create mode 100644 local-cli/link/windows/patches/applyPatch.js create mode 100644 local-cli/link/windows/patches/makePackagePatch.js create mode 100644 local-cli/link/windows/patches/makeProjectPatch.js create mode 100644 local-cli/link/windows/patches/makeSolutionPatch.js create mode 100644 local-cli/link/windows/patches/makeUsingPatch.js create mode 100644 local-cli/link/windows/patches/revokePatch.js create mode 100644 local-cli/link/windows/registerNativeModule.js create mode 100644 local-cli/link/windows/unregisterNativeModule.js diff --git a/local-cli/core/config/index.js b/local-cli/core/config/index.js index a013d17ee32dfa..ccc521c94364e9 100644 --- a/local-cli/core/config/index.js +++ b/local-cli/core/config/index.js @@ -11,6 +11,7 @@ const android = require('./android'); const findAssets = require('./findAssets'); const ios = require('./ios'); +const windows = require('./windows'); const path = require('path'); const wrapCommands = require('./wrapCommands'); @@ -28,6 +29,7 @@ exports.getProjectConfig = function getProjectConfig() { return Object.assign({}, rnpm, { ios: ios.projectConfig(folder, rnpm.ios || {}), android: android.projectConfig(folder, rnpm.android || {}), + windows: windows.projectConfig(folder, rnpm.windows || {}), assets: findAssets(folder, rnpm.assets), }); }; @@ -46,6 +48,7 @@ exports.getDependencyConfig = function getDependencyConfig(packageName) { return Object.assign({}, rnpm, { ios: ios.dependencyConfig(folder, rnpm.ios || {}), android: android.dependencyConfig(folder, rnpm.android || {}), + windows: windows.dependencyConfig(folder, rnpm.windows || {}), assets: findAssets(folder, rnpm.assets), commands: wrapCommands(rnpm.commands), params: rnpm.params || [], diff --git a/local-cli/core/config/windows/findNamespace.js b/local-cli/core/config/windows/findNamespace.js new file mode 100644 index 00000000000000..4867368f1228ff --- /dev/null +++ b/local-cli/core/config/windows/findNamespace.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +/** + * Gets package's namespace + * by searching for its declaration in all C# files present in the folder + * + * @param {String} folder Folder to find C# files + */ +module.exports = function getNamespace(folder) { + const files = glob.sync('**/*.cs', { cwd: folder }); + + const packages = files + .map(filePath => fs.readFileSync(path.join(folder, filePath), 'utf8')) + .map(file => file.match(/namespace (.*)[\s\S]+IReactPackage/)) + .filter(match => match); + + return packages.length ? packages[0][1] : null; +}; diff --git a/local-cli/core/config/windows/findPackageClassName.js b/local-cli/core/config/windows/findPackageClassName.js new file mode 100644 index 00000000000000..279610eac7f7d4 --- /dev/null +++ b/local-cli/core/config/windows/findPackageClassName.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +/** + * Gets package's class name (class that implements IReactPackage) + * by searching for its declaration in all C# files present in the folder + * + * @param {String} folder Folder to find C# files + */ +module.exports = function getPackageClassName(folder) { + const files = glob.sync('**/*.cs', { cwd: folder }); + + const packages = files + .map(filePath => fs.readFileSync(path.join(folder, filePath), 'utf8')) + .map(file => file.match(/class (.*) : IReactPackage/)) + .filter(match => match); + + return packages.length ? packages[0][1] : null; +}; diff --git a/local-cli/core/config/windows/findProject.js b/local-cli/core/config/windows/findProject.js new file mode 100644 index 00000000000000..3b0121f74dea44 --- /dev/null +++ b/local-cli/core/config/windows/findProject.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const glob = require('glob'); +const path = require('path'); + +/** + * Find an C# project file + * + * @param {String} folder Name of the folder where to seek + * @return {String} + */ +module.exports = function findManifest(folder) { + const csprojPath = glob.sync(path.join('**', '*.csproj'), { + cwd: folder, + ignore: ['node_modules/**', '**/build/**', 'Examples/**', 'examples/**'], + })[0]; + + return csprojPath ? path.join(folder, csprojPath) : null; +}; diff --git a/local-cli/core/config/windows/findWindowsSolution.js b/local-cli/core/config/windows/findWindowsSolution.js new file mode 100644 index 00000000000000..fb6782633b92ae --- /dev/null +++ b/local-cli/core/config/windows/findWindowsSolution.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const glob = require('glob'); +const path = require('path'); + +/** + * Glob pattern to look for solution file + */ +const GLOB_PATTERN = '**/*.sln'; + +/** + * Regexp matching all test projects + */ +const TEST_PROJECTS = /test|example|sample/i; + +/** + * Base windows folder + */ +const WINDOWS_BASE = 'windows'; + +/** + * These folders will be excluded from search to speed it up + */ +const GLOB_EXCLUDE_PATTERN = ['**/@(node_modules)/**']; + +/** + * Finds windows project by looking for all .sln files + * in given folder. + * + * Returns first match if files are found or null + * + * Note: `./windows/*.sln` are returned regardless of the name + */ +module.exports = function findSolution(folder) { + const projects = glob + .sync(GLOB_PATTERN, { + cwd: folder, + ignore: GLOB_EXCLUDE_PATTERN, + }) + .filter(project => { + return path.dirname(project) === WINDOWS_BASE || !TEST_PROJECTS.test(project); + }) + .sort((projectA, projectB) => { + return path.dirname(projectA) === WINDOWS_BASE ? -1 : 1; + }); + + if (projects.length === 0) { + return null; + } + + return projects[0]; +}; diff --git a/local-cli/core/config/windows/generateGUID.js b/local-cli/core/config/windows/generateGUID.js new file mode 100644 index 00000000000000..0bbdcdead20ff4 --- /dev/null +++ b/local-cli/core/config/windows/generateGUID.js @@ -0,0 +1,10 @@ +const s4 = () => { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} + +module.exports = function generateGUID() { + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + + s4() + '-' + s4() + s4() + s4(); +} diff --git a/local-cli/core/config/windows/index.js b/local-cli/core/config/windows/index.js new file mode 100644 index 00000000000000..f7d81bb8e97ae2 --- /dev/null +++ b/local-cli/core/config/windows/index.js @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const findWindowsSolution = require('./findWindowsSolution'); +const findNamespace = require('./findNamespace'); +const findProject = require('./findProject'); +const findPackageClassName = require('./findPackageClassName'); +const path = require('path'); +const generateGUID = require('./generateGUID'); + +const relativeProjectPath = (fullProjPath) => { + const windowsPath = fullProjPath + .substring(fullProjPath.lastIndexOf("node_modules") - 1, fullProjPath.length) + .replace(/\//g, '\\'); + + return '..' + windowsPath; +} + +const getProjectName = (fullProjPath) => { + return fullProjPath.split('/').slice(-1)[0].replace(/\.csproj/i, ''); +} + +/** + * Gets windows project config by analyzing given folder and taking some + * defaults specified by user into consideration + */ +exports.projectConfig = function projectConfigWindows(folder, userConfig) { + + const csSolution = userConfig.csSolution || findWindowsSolution(folder); + const solutionPath = path.join(folder, csSolution); + + if (!csSolution) { + return null; + } + + // expects solutions to be named the same as project folders + const windowsAppFolder = csSolution.substring(0, csSolution.lastIndexOf(".sln")); + const src = userConfig.sourceDir || windowsAppFolder; + const sourceDir = path.join(folder, src); + const mainPage = path.join(sourceDir, 'MainPage.cs'); + const projectPath = userConfig.projectPath || findProject(folder); + + return { + sourceDir, + solutionPath, + projectPath, + mainPage, + folder, + userConfig, + }; +}; + +/** + * Same as projectConfigWindows except it returns + * different config that applies to packages only + */ +exports.dependencyConfig = function dependencyConfigWindows(folder, userConfig) { + + const csSolution = userConfig.csSolution || findWindowsSolution(folder); + + if (!csSolution) { + return null; + } + + // expects solutions to be named the same as project folders + const windowsAppFolder = csSolution.substring(0, csSolution.lastIndexOf(".sln")); + const src = userConfig.sourceDir || windowsAppFolder; + + if (!src) { + return null; + } + + const sourceDir = path.join(folder, src); + const packageClassName = findPackageClassName(sourceDir); + const namespace = userConfig.namespace || findNamespace(sourceDir); + const csProj = userConfig.csProj || findProject(folder); + + /** + * This module has no package to export or no namespace + */ + if (!packageClassName || !namespace) { + return null; + } + + const packageUsingPath = userConfig.packageUsingPath || + `using ${namespace};`; + + const packageInstance = userConfig.packageInstance || + `new ${packageClassName}()`; + + const projectGUID = generateGUID(); + const pathGUID = generateGUID(); + const projectName = getProjectName(csProj); + const relativeProjPath = relativeProjectPath(csProj); + + return { + sourceDir, + packageUsingPath, + packageInstance, + projectName, + csProj, + folder, + projectGUID, + pathGUID, + relativeProjPath, + }; +}; diff --git a/local-cli/link/link.js b/local-cli/link/link.js index cb8c07443737ce..770fa644450a1e 100644 --- a/local-cli/link/link.js +++ b/local-cli/link/link.js @@ -16,8 +16,10 @@ const chalk = require('chalk'); const isEmpty = require('lodash').isEmpty; const promiseWaterfall = require('./promiseWaterfall'); const registerDependencyAndroid = require('./android/registerNativeModule'); +const registerDependencyWindows = require('./windows/registerNativeModule'); const registerDependencyIOS = require('./ios/registerNativeModule'); const isInstalledAndroid = require('./android/isInstalled'); +const isInstalledWindows = require('./windows/isInstalled'); const isInstalledIOS = require('./ios/isInstalled'); const copyAssetsAndroid = require('./android/copyAssets'); const copyAssetsIOS = require('./ios/copyAssets'); @@ -58,6 +60,33 @@ const linkDependencyAndroid = (androidProject, dependency) => { }); }; +const linkDependencyWindows = (windowsProject, dependency) => { + + if (!windowsProject || !dependency.config.windows) { + return null; + } + + const isInstalled = isInstalledWindows(windowsProject, dependency.config.windows); + + if (isInstalled) { + log.info(chalk.grey(`Windows module ${dependency.name} is already linked`)); + return null; + } + + return pollParams(dependency.config.params).then(params => { + log.info(`Linking ${dependency.name} windows dependency`); + + registerDependencyWindows( + dependency.name, + dependency.config.windows, + params, + windowsProject + ); + + log.info(`Windows module ${dependency.name} has been successfully linked`); + }); +}; + const linkDependencyIOS = (iOSProject, dependency) => { if (!iOSProject || !dependency.config.ios) { return; @@ -96,7 +125,7 @@ const linkAssets = (project, assets) => { }; /** - * Updates project and linkes all dependencies to it + * Updates project and links all dependencies to it * * If optional argument [packageName] is provided, it's the only one that's checked */ @@ -128,6 +157,7 @@ function link(args, config) { () => promisify(dependency.config.commands.prelink || commandStub), () => linkDependencyAndroid(project.android, dependency), () => linkDependencyIOS(project.ios, dependency), + () => linkDependencyWindows(project.windows, dependency), () => promisify(dependency.config.commands.postlink || commandStub), ])); diff --git a/local-cli/link/unlink.js b/local-cli/link/unlink.js index 8910bf63a0c5a6..99b66f20da10c4 100644 --- a/local-cli/link/unlink.js +++ b/local-cli/link/unlink.js @@ -2,8 +2,10 @@ const log = require('npmlog'); const getProjectDependencies = require('./getProjectDependencies'); const unregisterDependencyAndroid = require('./android/unregisterNativeModule'); +const unregisterDependencyWindows = require('./windows/unregisterNativeModule'); const unregisterDependencyIOS = require('./ios/unregisterNativeModule'); const isInstalledAndroid = require('./android/isInstalled'); +const isInstalledWindows = require('./windows/isInstalled'); const isInstalledIOS = require('./ios/isInstalled'); const unlinkAssetsAndroid = require('./android/unlinkAssets'); const unlinkAssetsIOS = require('./ios/unlinkAssets'); @@ -39,6 +41,25 @@ const unlinkDependencyAndroid = (androidProject, dependency, packageName) => { log.info(`Android module ${packageName} has been successfully unlinked`); }; +const unlinkDependencyWindows = (windowsProject, dependency, packageName) => { + if (!windowsProject || !dependency.windows) { + return; + } + + const isInstalled = isInstalledWindows(windowsProject, dependency.windows); + + if (!isInstalled) { + log.info(`Windows module ${packageName} is not installed`); + return; + } + + log.info(`Unlinking ${packageName} windows dependency`); + + unregisterDependencyWindows(packageName, dependency.windows, windowsProject); + + log.info(`Windows module ${packageName} has been successfully unlinked`); +}; + const unlinkDependencyIOS = (iOSProject, dependency, packageName, iOSDependencies) => { if (!iOSProject || !dependency.ios) { return; @@ -99,6 +120,7 @@ function unlink(args, config) { () => promisify(thisDependency.config.commands.preunlink || commandStub), () => unlinkDependencyAndroid(project.android, dependency, packageName), () => unlinkDependencyIOS(project.ios, dependency, packageName, iOSDependencies), + () => unlinkDependencyWindows(project.windows, dependency, packageName), () => promisify(thisDependency.config.commands.postunlink || commandStub) ]; diff --git a/local-cli/link/windows/isInstalled.js b/local-cli/link/windows/isInstalled.js new file mode 100644 index 00000000000000..b96edcb21ba2f6 --- /dev/null +++ b/local-cli/link/windows/isInstalled.js @@ -0,0 +1,8 @@ +const fs = require('fs'); +const makeUsingPatch = require('./patches/makeUsingPatch'); + +module.exports = function isInstalled(config, dependencyConfig) { + return fs + .readFileSync(config.mainPage) + .indexOf(makeUsingPatch(dependencyConfig.packageUsingPath).patch) > -1; +}; diff --git a/local-cli/link/windows/patches/applyParams.js b/local-cli/link/windows/patches/applyParams.js new file mode 100644 index 00000000000000..21c1e9545bc49a --- /dev/null +++ b/local-cli/link/windows/patches/applyParams.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +const toCamelCase = require('lodash').camelCase; + +module.exports = function applyParams(str, params, prefix) { + return str.replace( + /\$\{(\w+)\}/g, + (pattern, param) => { + const name = toCamelCase(prefix) + '_' + param; + + return params[param] + ? `getResources().getString(R.string.${name})` + : null; + } + ); +}; diff --git a/local-cli/link/windows/patches/applyPatch.js b/local-cli/link/windows/patches/applyPatch.js new file mode 100644 index 00000000000000..55f84edf604bd9 --- /dev/null +++ b/local-cli/link/windows/patches/applyPatch.js @@ -0,0 +1,11 @@ +const fs = require('fs'); + +module.exports = function applyPatch(file, patch, flip = false) { + + fs.writeFileSync(file, fs + .readFileSync(file, 'utf8') + .replace(patch.pattern, match => { + return flip ? `${patch.patch}${match}` : `${match}${patch.patch}` + }) + ); +}; diff --git a/local-cli/link/windows/patches/makePackagePatch.js b/local-cli/link/windows/patches/makePackagePatch.js new file mode 100644 index 00000000000000..ef60e1e6a0cc66 --- /dev/null +++ b/local-cli/link/windows/patches/makePackagePatch.js @@ -0,0 +1,10 @@ +const applyParams = require('./applyParams'); + +module.exports = function makePackagePatch(packageInstance, params, prefix) { + const processedInstance = applyParams(packageInstance, params, prefix); + + return { + pattern: 'new MainReactPackage()', + patch: ',\n ' + processedInstance, + }; +}; diff --git a/local-cli/link/windows/patches/makeProjectPatch.js b/local-cli/link/windows/patches/makeProjectPatch.js new file mode 100644 index 00000000000000..6b3d85855d9547 --- /dev/null +++ b/local-cli/link/windows/patches/makeProjectPatch.js @@ -0,0 +1,14 @@ +module.exports = function makeProjectPatch(windowsConfig) { + + const projectInsert = ` + {${windowsConfig.pathGUID}} + ${windowsConfig.projectName} + + `; + + return { + pattern: '', + patch: projectInsert, + unpatch: new RegExp(`