From 92da7c6de867318475a9a1aa4a03677ede43ee05 Mon Sep 17 00:00:00 2001 From: Geoffrey Goh Date: Mon, 11 Apr 2016 17:12:20 -0700 Subject: [PATCH] add windows support --- windows/.gitignore | 252 ++++++++ windows/CodePush.cs | 644 +++++++++++++++++++++ windows/CodePush.csproj | 144 +++++ windows/CodePushInstallMode.cs | 6 + windows/CodePushInvalidUpdateException.cs | 12 + windows/CodePushNotInitializedException.cs | 12 + windows/CodePushPackage.cs | 348 +++++++++++ windows/CodePushUnknownException.cs | 12 + windows/CodePushUpdateUtils.cs | 49 ++ windows/CodePushUtils.cs | 50 ++ windows/FileUtils.cs | 27 + windows/Properties/AssemblyInfo.cs | 29 + windows/Properties/CodePush.rd.xml | 33 ++ windows/project.json | 17 + 14 files changed, 1635 insertions(+) create mode 100644 windows/.gitignore create mode 100644 windows/CodePush.cs create mode 100644 windows/CodePush.csproj create mode 100644 windows/CodePushInstallMode.cs create mode 100644 windows/CodePushInvalidUpdateException.cs create mode 100644 windows/CodePushNotInitializedException.cs create mode 100644 windows/CodePushPackage.cs create mode 100644 windows/CodePushUnknownException.cs create mode 100644 windows/CodePushUpdateUtils.cs create mode 100644 windows/CodePushUtils.cs create mode 100644 windows/FileUtils.cs create mode 100644 windows/Properties/AssemblyInfo.cs create mode 100644 windows/Properties/CodePush.rd.xml create mode 100644 windows/project.json diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..61fdd38 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,252 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml \ No newline at end of file diff --git a/windows/CodePush.cs b/windows/CodePush.cs new file mode 100644 index 0000000..ea40b72 --- /dev/null +++ b/windows/CodePush.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Generic; +using ReactNative.Bridge; +using ReactNative.Modules.Core; +using ReactNative.UIManager; +using Windows.UI.Xaml.Controls; +using System.Xml; +using Windows.ApplicationModel; +using Windows.Storage; +using System.IO; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Reflection; +using Windows.Web.Http; +using System.Linq; +using System.Text.RegularExpressions; +using Windows.Storage.FileProperties; + +namespace ReactNative.CodePush +{ + public class CodePush : IReactPackage + { + private static bool needToReportRollback = false; + private static bool isRunningBinaryVersion = false; + private static bool testConfigurationFlag = false; + + private bool didUpdate = false; + + private string assetsBundleFileName; + + private static readonly string ASSETS_BUNDLE_PREFIX = "ms-appx:///ReactAssets/"; + private static readonly string BINARY_MODIFIED_TIME_KEY = "binaryModifiedTime"; + private readonly string CODE_PUSH_PREFERENCES = "CodePush"; + private static readonly string DEFAULT_JS_BUNDLE_NAME = "index.windows.bundle"; + private readonly string DOWNLOAD_PROGRESS_EVENT_NAME = "CodePushDownloadProgress"; + private readonly string FAILED_UPDATES_KEY = "CODE_PUSH_FAILED_UPDATES"; + private static readonly string FILE_BUNDLE_PREFIX = "ms-appdata:///local"; + private readonly string PACKAGE_HASH_KEY = "packageHash"; + private readonly string PENDING_UPDATE_HASH_KEY = "hash"; + private readonly string PENDING_UPDATE_IS_LOADING_KEY = "isLoading"; + private readonly string PENDING_UPDATE_KEY = "CODE_PUSH_PENDING_UPDATE"; + + // This needs to be kept in sync with https://github.com/facebook/react-native/blob/master/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManager.java#L78 + private readonly string REACT_DEV_BUNDLE_CACHE_FILE_NAME = "ReactNativeDevBundle.js"; + + // Helper classes. + private CodePushNativeModule codePushNativeModule; + private CodePushPackage codePushPackage; + + // Config properties. + private string appVersion; + private string deploymentKey; + private readonly string serverUrl = "https://codepush.azurewebsites.net/"; + + private ReactPage mainPage; + + private static CodePush currentInstance; + + public CodePush(string deploymentKey, ReactPage mainPage) + { + codePushPackage = new CodePushPackage(); + // TODO implement telemetryManager + // this.codePushTelemetryManager = new CodePushTelemetryManager(this.applicationContext, CODE_PUSH_PREFERENCES); + this.deploymentKey = deploymentKey; + this.mainPage = mainPage; + appVersion = Package.Current.Id.Version.Major + "." + Package.Current.Id.Version.Minor + "." + Package.Current.Id.Version.Build; + InitializeUpdateAfterRestart(); + if (currentInstance != null) + { + CodePushUtils.log("More than one CodePush instance has been initialized. Please use the instance method codePush.getBundleUrlInternal() to get the correct bundleURL for a particular instance."); + } + + currentInstance = this; + } + + private async Task ClearReactDevBundleCache() + { + StorageFile devBundleCacheFile = null; + try + { + devBundleCacheFile = await ApplicationData.Current.LocalFolder.GetFileAsync(REACT_DEV_BUNDLE_CACHE_FILE_NAME); + } + catch (FileNotFoundException) + { + } + + if (devBundleCacheFile != null) + { + await devBundleCacheFile.DeleteAsync(); + } + } + + private async Task GetBinaryResourcesModifiedTime() + { + StorageFile assetJSBundleFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri(ASSETS_BUNDLE_PREFIX + assetsBundleFileName)); + BasicProperties fileProperties = await assetJSBundleFile.GetBasicPropertiesAsync(); + return fileProperties.DateModified.ToUnixTimeMilliseconds(); + } + + public static string GetBundleUrl() + { + return GetBundleUrl(DEFAULT_JS_BUNDLE_NAME); + } + + public static string GetBundleUrl(string assetsBundleFileName) + { + if (currentInstance == null) + { + throw new CodePushNotInitializedException("A CodePush instance has not been created yet. Have you added it to your app's list of ReactPackages?"); + } + + return currentInstance.GetBundleUrlInternal(assetsBundleFileName).Result; + } + + public async Task GetBundleUrlInternal(string assetsBundleFileName) + { + this.assetsBundleFileName = assetsBundleFileName; + string binaryJsBundleUrl = ASSETS_BUNDLE_PREFIX + assetsBundleFileName; + long binaryResourcesModifiedTime = await GetBinaryResourcesModifiedTime(); + StorageFile packageFile = await codePushPackage.GetCurrentPackageBundle(this.assetsBundleFileName); + if (packageFile == null) + { + // There has not been any downloaded updates. + CodePushUtils.logBundleUrl(binaryJsBundleUrl); + isRunningBinaryVersion = true; + return binaryJsBundleUrl; + } + + JObject packageMetadata = await codePushPackage.GetCurrentPackage(); + long? binaryModifiedDateDuringPackageInstall = null; + string binaryModifiedDateDuringPackageInstallString = (string)packageMetadata[BINARY_MODIFIED_TIME_KEY]; + if (binaryModifiedDateDuringPackageInstallString != null) + { + binaryModifiedDateDuringPackageInstall = long.Parse(binaryModifiedDateDuringPackageInstallString); + } + + string packageAppVersion = (string)packageMetadata["appVersion"]; + + // TODO: test configuration + if (binaryModifiedDateDuringPackageInstall != null && + binaryModifiedDateDuringPackageInstall == binaryResourcesModifiedTime && + (IsUsingTestConfiguration() || appVersion.Equals(packageAppVersion))) + { + CodePushUtils.logBundleUrl(packageFile.Path); + isRunningBinaryVersion = false; + return FILE_BUNDLE_PREFIX + packageFile.Path.Replace(ApplicationData.Current.LocalFolder.Path, "").Replace("\\", "/"); + } + else + { + // The binary version is newer. + didUpdate = false; + if (!mainPage.UseDeveloperSupport || !appVersion.Equals(packageAppVersion)) + { + await ClearUpdates(); + } + + CodePushUtils.logBundleUrl(binaryJsBundleUrl); + isRunningBinaryVersion = true; + return binaryJsBundleUrl; + } + } + + private ApplicationDataContainer GetCodePushSettings() + { + return ApplicationData.Current.LocalSettings.CreateContainer(CODE_PUSH_PREFERENCES, ApplicationDataCreateDisposition.Always); + } + + private JArray GetFailedUpdates() + { + ApplicationDataContainer settings = GetCodePushSettings(); + string failedUpdatesString = (string)settings.Values[FAILED_UPDATES_KEY]; + if (failedUpdatesString == null) + { + return new JArray(); + } + + try + { + return JArray.Parse(failedUpdatesString); + } + catch (Exception) + { + JArray emptyArray = new JArray(); + settings.Values[FAILED_UPDATES_KEY] = JsonConvert.SerializeObject(emptyArray); + return emptyArray; + } + } + + private JObject GetPendingUpdate() + { + ApplicationDataContainer settings = GetCodePushSettings(); + string pendingUpdateString = (string)settings.Values[PENDING_UPDATE_KEY]; + if (pendingUpdateString == null) + { + return null; + } + + try + { + return JObject.Parse(pendingUpdateString); + } + catch (Exception) + { + // Should not happen. + CodePushUtils.log("Unable to parse pending update metadata " + pendingUpdateString + + " stored in SharedPreferences"); + return null; + } + } + + private void InitializeUpdateAfterRestart() + { + JObject pendingUpdate = GetPendingUpdate(); + if (pendingUpdate != null) + { + didUpdate = true; + bool updateIsLoading = (bool)pendingUpdate[PENDING_UPDATE_IS_LOADING_KEY]; + if (updateIsLoading) + { + // Pending update was initialized, but notifyApplicationReady was not called. + // Therefore, deduce that it is a broken update and rollback. + CodePushUtils.log("Update did not finish loading the last time, rolling back to a previous version."); + needToReportRollback = true; + RollbackPackage().Wait(); + } + else + { + // Clear the React dev bundle cache so that new updates can be loaded. + if (mainPage.UseDeveloperSupport) + { + ClearReactDevBundleCache().Wait(); + } + // Mark that we tried to initialize the new update, so that if it crashes, + // we will know that we need to rollback when the app next starts. + SavePendingUpdate((string)pendingUpdate[PENDING_UPDATE_HASH_KEY], /* isLoading */true); + } + } + } + + private bool IsFailedHash(string packageHash) + { + JArray failedUpdates = GetFailedUpdates(); + if (packageHash != null) + { + foreach (JObject failedPackage in failedUpdates) + { + string failedPackageHash = (string)failedPackage[PACKAGE_HASH_KEY]; + if (packageHash.Equals(failedPackageHash)) + { + return true; + } + } + } + + return false; + } + + private bool IsPendingUpdate(string packageHash) + { + JObject pendingUpdate = GetPendingUpdate(); + return pendingUpdate != null && + !(bool)pendingUpdate[PENDING_UPDATE_IS_LOADING_KEY] && + (packageHash == null || ((string)pendingUpdate[PENDING_UPDATE_HASH_KEY]).Equals(packageHash)); + } + + private void RemoveFailedUpdates() + { + ApplicationDataContainer settings = GetCodePushSettings(); + settings.Values.Remove(FAILED_UPDATES_KEY); + } + + private void RemovePendingUpdate() + { + ApplicationDataContainer settings = GetCodePushSettings(); + settings.Values.Remove(PENDING_UPDATE_KEY); + } + + private async Task RollbackPackage() + { + JObject failedPackage = await codePushPackage.GetCurrentPackage(); + SaveFailedUpdate(failedPackage); + await codePushPackage.RollbackPackage(); + RemovePendingUpdate(); + } + + private void SaveFailedUpdate(JObject failedPackage) + { + ApplicationDataContainer settings = GetCodePushSettings(); + string failedUpdatesString = (string)settings.Values[FAILED_UPDATES_KEY]; + JArray failedUpdates; + if (failedUpdatesString == null) + { + failedUpdates = new JArray(); + } + else + { + failedUpdates = JArray.Parse(failedUpdatesString); + } + + failedUpdates.Add(failedPackage); + settings.Values[FAILED_UPDATES_KEY] = JsonConvert.SerializeObject(failedUpdates); + } + + private void SavePendingUpdate(string packageHash, bool isLoading) + { + ApplicationDataContainer settings = GetCodePushSettings(); + JObject pendingUpdate = new JObject(); + pendingUpdate[PENDING_UPDATE_HASH_KEY] = packageHash; + pendingUpdate[PENDING_UPDATE_IS_LOADING_KEY] = isLoading; + settings.Values[PENDING_UPDATE_KEY] = JsonConvert.SerializeObject(pendingUpdate); + } + + public static bool IsUsingTestConfiguration() + { + return testConfigurationFlag; + } + + public static void SetUsingTestConfiguration(bool shouldUseTestConfiguration) + { + testConfigurationFlag = shouldUseTestConfiguration; + } + + public async Task ClearUpdates() + { + await codePushPackage.ClearUpdates(); + RemovePendingUpdate(); + RemoveFailedUpdates(); + } + + private class CodePushNativeModule : ReactContextNativeModuleBase + { + private ILifecycleEventListener lifecycleEventListener = null; + private int minimumBackgroundDuration = 0; + private ReactContext reactContext; + + private class CodePushResumeListener : ILifecycleEventListener + { + private DateTime? lastPausedDate = null; + private CodePushNativeModule codePushNativeModule; + + public CodePushResumeListener(CodePushNativeModule codePushNativeModule) + { + this.codePushNativeModule = codePushNativeModule; + } + + public void OnDestroy() + { + } + + public void OnResume() + { + if (lastPausedDate != null) + { + // Determine how long the app was in the background and ensure + // that it meets the minimum duration amount of time. + double durationInBackground = (new DateTime() - (DateTime)lastPausedDate).TotalSeconds; + if (durationInBackground >= codePushNativeModule.minimumBackgroundDuration) + { + Action loadBundleAction = async () => + { + await codePushNativeModule.LoadBundle(); + }; + + codePushNativeModule.Context.RunOnNativeModulesQueueThread(loadBundleAction); + } + } + } + + public void OnSuspend() + { + // Save the current time so that when the app is later + // resumed, we can detect how long it was in the background. + lastPausedDate = new DateTime(); + } + } + + // TODO get rid of this + private CodePush codePush; + + public CodePushNativeModule(ReactContext reactContext, CodePush codePush) : base(reactContext) + { + this.reactContext = reactContext; + this.codePush = codePush; + } + + public override string Name + { + get + { + return "CodePush"; + } + } + + public override IReadOnlyDictionary Constants + { + get + { + Dictionary constants = new Dictionary(); + constants["codePushInstallModeImmediate"] = CodePushInstallMode.IMMEDIATE; + constants["codePushInstallModeOnNextRestart"] = CodePushInstallMode.ON_NEXT_RESTART; + constants["codePushInstallModeOnNextResume"] = CodePushInstallMode.ON_NEXT_RESUME; + return constants; + } + } + + public override void Initialize() + { + codePush.InitializeUpdateAfterRestart(); + } + + private async Task LoadBundle() + { + // #1) Get the private ReactInstanceManager, which is what includes + // the logic to reload the current React context. + FieldInfo info = typeof(ReactPage) + .GetField("_reactInstanceManager", BindingFlags.NonPublic | BindingFlags.Instance); + + ReactInstanceManager reactInstanceManager = (ReactInstanceManager)typeof(ReactPage) + .GetField("_reactInstanceManager", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(codePush.mainPage); + + // #2) Update the locally stored JS bundle file path + Type reactInstanceManagerType = typeof(ReactInstanceManager); + string latestJSBundleFile = await codePush.GetBundleUrlInternal(codePush.assetsBundleFileName); + reactInstanceManagerType + .GetField("_jsBundleFile", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(reactInstanceManager, latestJSBundleFile); + + // #3) Get the context creation method and fire it on the UI thread (which RN enforces) + Action recreateReactContextAction = () => + { + reactInstanceManager.RecreateReactContextInBackground(); + }; + Context.RunOnDispatcherQueueThread(recreateReactContextAction); + } + + [ReactMethod] + public void downloadUpdate(JObject updatePackage, IPromise promise) + { + Action downloadAction = async () => + { + try + { + updatePackage[BINARY_MODIFIED_TIME_KEY] = "" + await codePush.GetBinaryResourcesModifiedTime(); + await codePush.codePushPackage.DownloadPackage( + updatePackage, + codePush.assetsBundleFileName, + new Progress( + (HttpProgress progress) => + { + JObject downloadProgress = new JObject(); + downloadProgress["totalBytes"] = progress.TotalBytesToReceive; + downloadProgress["receivedBytes"] = progress.BytesReceived; + reactContext + .GetJavaScriptModule() + .emit(codePush.DOWNLOAD_PROGRESS_EVENT_NAME, downloadProgress); + } + ) + ); + + JObject newPackage = await codePush.codePushPackage.GetPackage((string)updatePackage[codePush.PACKAGE_HASH_KEY]); + promise.Resolve(newPackage); + } + catch (CodePushInvalidUpdateException e) + { + CodePushUtils.log(e.ToString()); + codePush.SaveFailedUpdate(updatePackage); + promise.Reject(e); + } + catch (Exception e) + { + CodePushUtils.log(e.ToString()); + promise.Reject(e); + } + }; + + Context.RunOnNativeModulesQueueThread(downloadAction); + } + + [ReactMethod] + public void getConfiguration(IPromise promise) + { + JObject config = new JObject(); + config["appVersion"] = codePush.appVersion; + config["deploymentKey"] = codePush.deploymentKey; + config["serverUrl"] = codePush.serverUrl; + config["clientUniqueId"] = CodePushUtils.GetDeviceId(); + // TODO generate binary hash + // string binaryHash = CodePushUpdateUtils.getHashForBinaryContents(mainActivity, isDebugMode); + /*if (binaryHash != null) + { + // binaryHash will be null if the React Native assets were not bundled into the APK + // (e.g. in Debug builds) + configMap.putString(PACKAGE_HASH_KEY, binaryHash); + }*/ + + promise.Resolve(config); + } + + [ReactMethod] + public void getCurrentPackage(IPromise promise) + { + Action getCurrentPackageAction = async () => + { + JObject currentPackage = await codePush.codePushPackage.GetCurrentPackage(); + if (currentPackage == null) + { + promise.Resolve(""); + return; + } + + if (isRunningBinaryVersion) + { + currentPackage["_isDebugOnly"] = true; + } + + bool isPendingUpdate = false; + string currentHash = (string)currentPackage[codePush.PACKAGE_HASH_KEY]; + if (currentHash != null) + { + isPendingUpdate = codePush.IsPendingUpdate(currentHash); + } + + currentPackage["isPending"] = isPendingUpdate; + promise.Resolve(currentPackage); + }; + + Context.RunOnNativeModulesQueueThread(getCurrentPackageAction); + } + + + [ReactMethod] + public void getNewStatusReport(IPromise promise) + { + // TODO implement this + promise.Resolve(""); + } + + [ReactMethod] + public void installUpdate(JObject updatePackage, int installMode, int minimumBackgroundDuration, IPromise promise) + { + Action installUpdateAction = async () => + { + await codePush.codePushPackage.InstallPackage(updatePackage, codePush.IsPendingUpdate(null)); + string pendingHash = (string)updatePackage[codePush.PACKAGE_HASH_KEY]; + codePush.SavePendingUpdate(pendingHash, /* isLoading */false); + if (installMode == (int)CodePushInstallMode.ON_NEXT_RESUME) + { + // Store the minimum duration on the native module as an instance + // variable instead of relying on a closure below, so that any + // subsequent resume-based installs could override it. + codePush.codePushNativeModule.minimumBackgroundDuration = minimumBackgroundDuration; + + if (lifecycleEventListener == null) + { + // Ensure we do not add the listener twice. + lifecycleEventListener = new CodePushResumeListener(this); + reactContext.AddLifecycleEventListener(lifecycleEventListener); + } + } + + promise.Resolve(""); + }; + + Context.RunOnNativeModulesQueueThread(installUpdateAction); + } + + [ReactMethod] + public void isFailedUpdate(string packageHash, IPromise promise) + { + promise.Resolve(codePush.IsFailedHash(packageHash)); + } + + [ReactMethod] + public void isFirstRun(string packageHash, IPromise promise) + { + Action isFirstRunAction = async () => + { + bool isFirstRun = codePush.didUpdate + && packageHash != null + && packageHash.Length > 0 + && packageHash.Equals(await codePush.codePushPackage.GetCurrentPackageHash()); + promise.Resolve(isFirstRun); + }; + + Context.RunOnNativeModulesQueueThread(isFirstRunAction); + } + + [ReactMethod] + public void notifyApplicationReady(IPromise promise) + { + codePush.RemovePendingUpdate(); + promise.Resolve(""); + } + + [ReactMethod] + public void restartApp(bool onlyIfUpdateIsPending) + { + Action restartAppAction = async () => + { + // If this is an unconditional restart request, or there + // is current pending update, then reload the app. + if (!onlyIfUpdateIsPending || codePush.IsPendingUpdate(null)) + { + await LoadBundle(); + } + }; + + Context.RunOnNativeModulesQueueThread(restartAppAction); + } + + [ReactMethod] + // Replaces the current bundle with the one downloaded from removeBundleUrl. + // It is only to be used during tests. No-ops if the test configuration flag is not set. + public void downloadAndReplaceCurrentBundle(String remoteBundleUrl) + { + // TODO implement this + } + } + + public IReadOnlyList CreateJavaScriptModulesConfig() + { + return new List(); + } + + public IReadOnlyList CreateNativeModules(ReactContext reactContext) + { + List nativeModules = new List(); + codePushNativeModule = new CodePushNativeModule(reactContext, this); + //CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext, mainActivity); + + nativeModules.Add(codePushNativeModule); + //nativeModules.add(dialogModule); + + return nativeModules; + } + + public IReadOnlyList CreateViewManagers(ReactContext reactContext) + { + return new List(); + } + } +} \ No newline at end of file diff --git a/windows/CodePush.csproj b/windows/CodePush.csproj new file mode 100644 index 0000000..4fa4353 --- /dev/null +++ b/windows/CodePush.csproj @@ -0,0 +1,144 @@ + + + + + Debug + AnyCPU + {446A85D9-55EB-4C7D-8B9D-448306C833D6} + Library + Properties + CodePush + CodePush + en-US + UAP + 10.0.10586.0 + 10.0.10240.0 + 14 + 512 + {A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE;NETFX_CORE;WINDOWS_UWP + prompt + 4 + + + x86 + true + bin\x86\Debug\ + DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP + ;2008 + full + x86 + false + prompt + + + x86 + bin\x86\Release\ + TRACE;NETFX_CORE;WINDOWS_UWP + true + ;2008 + pdbonly + x86 + false + prompt + + + ARM + true + bin\ARM\Debug\ + DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP + ;2008 + full + ARM + false + prompt + + + ARM + bin\ARM\Release\ + TRACE;NETFX_CORE;WINDOWS_UWP + true + ;2008 + pdbonly + ARM + false + prompt + + + x64 + true + bin\x64\Debug\ + DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP + ;2008 + full + x64 + false + prompt + + + x64 + bin\x64\Release\ + TRACE;NETFX_CORE;WINDOWS_UWP + true + ;2008 + pdbonly + x64 + false + prompt + + + + + + + + + + + + + + + + + + + + + Windows Mobile Extensions for the UWP + + + + + {c7673ad5-e3aa-468c-a5fd-fa38154e205c} + ReactNative + + + + 14.0 + + + + \ No newline at end of file diff --git a/windows/CodePushInstallMode.cs b/windows/CodePushInstallMode.cs new file mode 100644 index 0000000..d074736 --- /dev/null +++ b/windows/CodePushInstallMode.cs @@ -0,0 +1,6 @@ +enum CodePushInstallMode +{ + IMMEDIATE = 0, + ON_NEXT_RESTART = 1, + ON_NEXT_RESUME = 2 +} \ No newline at end of file diff --git a/windows/CodePushInvalidUpdateException.cs b/windows/CodePushInvalidUpdateException.cs new file mode 100644 index 0000000..5f757f5 --- /dev/null +++ b/windows/CodePushInvalidUpdateException.cs @@ -0,0 +1,12 @@ +using System; + +namespace ReactNative.CodePush +{ + class CodePushInvalidUpdateException : Exception + { + public CodePushInvalidUpdateException(string message) + : base(message) + { + } + } +} diff --git a/windows/CodePushNotInitializedException.cs b/windows/CodePushNotInitializedException.cs new file mode 100644 index 0000000..2f16ec2 --- /dev/null +++ b/windows/CodePushNotInitializedException.cs @@ -0,0 +1,12 @@ +using System; + +namespace ReactNative.CodePush +{ + public class CodePushNotInitializedException : Exception + { + public CodePushNotInitializedException(string message) + : base(message) + { + } + } +} \ No newline at end of file diff --git a/windows/CodePushPackage.cs b/windows/CodePushPackage.cs new file mode 100644 index 0000000..4c5f25e --- /dev/null +++ b/windows/CodePushPackage.cs @@ -0,0 +1,348 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage; +using Windows.Storage.Streams; +using Windows.Web.Http; + +namespace ReactNative.CodePush +{ + class CodePushPackage + { + private readonly string CODE_PUSH_FOLDER_PREFIX = "CodePush"; + private readonly string CURRENT_PACKAGE_KEY = "currentPackage"; + private readonly string DIFF_MANIFEST_FILE_NAME = "hotcodepush.json"; + private readonly string DOWNLOAD_FILE_NAME = "download.zip"; + private readonly string DOWNLOAD_URL_KEY = "downloadUrl"; + private readonly string PACKAGE_FILE_NAME = "app.json"; + private readonly string PACKAGE_HASH_KEY = "packageHash"; + private readonly string PREVIOUS_PACKAGE_KEY = "previousPackage"; + private readonly string RELATIVE_BUNDLE_PATH_KEY = "bundlePath"; + private readonly string STATUS_FILE = "codepush.json"; + private readonly string UNZIPPED_FOLDER_NAME = "unzipped"; + + public CodePushPackage() + { + } + + private async Task GetDownloadFile() + { + StorageFolder codePushFolder = await GetCodePushFolder(); + return await codePushFolder.CreateFileAsync(DOWNLOAD_FILE_NAME, CreationCollisionOption.OpenIfExists); + } + + private async Task GetUnzippedFolder() + { + StorageFolder codePushFolder = await GetCodePushFolder(); + return await codePushFolder.CreateFolderAsync(UNZIPPED_FOLDER_NAME, CreationCollisionOption.OpenIfExists); + } + + private async Task GetCodePushFolder() + { + return await ApplicationData.Current.LocalFolder.CreateFolderAsync(CODE_PUSH_FOLDER_PREFIX, CreationCollisionOption.OpenIfExists); + } + + private async Task GetStatusFile() + { + StorageFolder codePushFolder = await GetCodePushFolder(); + return await codePushFolder.CreateFileAsync(STATUS_FILE, CreationCollisionOption.OpenIfExists); + } + + public async Task GetCurrentPackageInfo() + { + StorageFile statusFile = await GetStatusFile(); + try + { + return await CodePushUtils.GetJObjectFromFile(statusFile); + } + catch (Exception e) + { + throw new CodePushUnknownException("Error getting current package info", e); + } + } + + public async Task UpdateCurrentPackageInfo(JObject packageInfo) + { + try + { + await FileIO.WriteTextAsync(await GetStatusFile(), JsonConvert.SerializeObject(packageInfo)); + } + catch (IOException e) + { + throw new CodePushUnknownException("Error updating current package info", e); + } + } + + public async Task GetCurrentPackageFolder() + { + JObject info = await GetCurrentPackageInfo(); + string packageHash = (string)info[CURRENT_PACKAGE_KEY]; + if (packageHash == null) + { + return null; + } + + return await GetPackageFolder(packageHash, false); + } + + + public async Task GetCurrentPackageBundle(string bundleFileName) + { + StorageFolder packageFolder = await GetCurrentPackageFolder(); + if (packageFolder == null) + { + return null; + } + + JObject currentPackage = await GetCurrentPackage(); + string relativeBundlePath = (string)currentPackage[RELATIVE_BUNDLE_PATH_KEY]; + if (relativeBundlePath == null) + { + return await packageFolder.GetFileAsync(bundleFileName); + } + else + { + return await packageFolder.GetFileAsync(relativeBundlePath); + } + } + + public async Task GetPackageFolder(string packageHash, bool createIfNotExists) + { + StorageFolder codePushFolder = await GetCodePushFolder(); + try + { + packageHash = shortenPackageHash(packageHash); + if (createIfNotExists) + { + return await codePushFolder.CreateFolderAsync(packageHash, CreationCollisionOption.OpenIfExists); + } + else + { + return await codePushFolder.GetFolderAsync(packageHash); + } + } + catch (FileNotFoundException) + { + return null; + } + } + + public async Task GetCurrentPackageHash() + { + JObject metadata = await GetCurrentPackage(); + return (string)metadata[PACKAGE_HASH_KEY]; + } + + public async Task GetPreviousPackageHash() + { + JObject info = await GetCurrentPackageInfo(); + string previousPackageShortHash = (string)info[PREVIOUS_PACKAGE_KEY]; + if (previousPackageShortHash == null) + { + return null; + } + + JObject previousPackageMetadata = await GetPackage(previousPackageShortHash); + if (previousPackageMetadata == null) + { + return null; + } + else + { + return (string)previousPackageMetadata[PACKAGE_HASH_KEY]; + } + } + + public async Task GetCurrentPackage() + { + StorageFolder currentPackageFolder = await GetCurrentPackageFolder(); + if (currentPackageFolder == null) + { + return null; + } + + try + { + StorageFile packageFile = await currentPackageFolder.GetFileAsync(PACKAGE_FILE_NAME); + return await CodePushUtils.GetJObjectFromFile(packageFile); + } + catch (IOException) + { + // Should not happen unless the update metadata was somehow deleted. + return null; + } + } + + public async Task GetPackage(string packageHash) + { + StorageFolder packageFolder = await GetPackageFolder(packageHash, false); + if (packageFolder == null) + { + return null; + } + + try + { + StorageFile packageFile = await packageFolder.GetFileAsync(PACKAGE_FILE_NAME); + return await CodePushUtils.GetJObjectFromFile(packageFile); + } + catch (IOException) + { + return null; + } + } + + public string shortenPackageHash(string packageHash) + { + return packageHash.Substring(0, 8); + } + + public async Task DownloadPackage(JObject updatePackage, string expectedBundleFileName, Progress downloadProgress) + { + StorageFolder codePushFolder = await GetCodePushFolder(); + string newUpdateHash = (string)updatePackage[PACKAGE_HASH_KEY]; + StorageFolder newUpdateFolder = await GetPackageFolder(newUpdateHash, false); + if (newUpdateFolder != null) + { + // This removes any stale data in newPackageFolderPath that could have been left + // uncleared due to a crash or error during the download or install process. + await newUpdateFolder.DeleteAsync(); + } + + newUpdateFolder = await GetPackageFolder(newUpdateHash, true); + StorageFile newUpdateMetadataFile = await newUpdateFolder.CreateFileAsync(PACKAGE_FILE_NAME); + string downloadUrlString = (string)updatePackage[DOWNLOAD_URL_KEY]; + StorageFile downloadFile = await GetDownloadFile(); + Uri downloadUri = new Uri(downloadUrlString); + + // Download the file and send progress event asynchronously + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, downloadUri); + HttpClient client = new HttpClient(); + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + HttpResponseMessage response = await client.SendRequestAsync(request).AsTask(cancellationTokenSource.Token, downloadProgress); + IInputStream inputStream = await response.Content.ReadAsInputStreamAsync(); + Stream downloadFileStream = await downloadFile.OpenStreamForWriteAsync(); + await RandomAccessStream.CopyAndCloseAsync(inputStream, downloadFileStream.AsOutputStream()); + try + { + // Unzip the downloaded file and then delete the zip + StorageFolder unzippedFolder = await GetUnzippedFolder(); + ZipFile.ExtractToDirectory(downloadFile.Path, unzippedFolder.Path); + await downloadFile.DeleteAsync(); + + // Merge contents with current update based on the manifest + StorageFile diffManifestFile = null; + try + { + diffManifestFile = await unzippedFolder.GetFileAsync(DIFF_MANIFEST_FILE_NAME); + } + catch (FileNotFoundException) + { + // There is no diff manifest, so this is not a diff update. + } + + if (diffManifestFile != null) + { + StorageFolder currentPackageFolder = await GetCurrentPackageFolder(); + if (currentPackageFolder == null) + { + throw new CodePushInvalidUpdateException("Received a diff update, but there is no current version to diff against."); + } + + await CodePushUpdateUtils.CopyNecessaryFilesFromCurrentPackage(diffManifestFile, currentPackageFolder, newUpdateFolder); + await diffManifestFile.DeleteAsync(); + } + + await FileUtils.MergeDirectories(unzippedFolder, newUpdateFolder); + await unzippedFolder.DeleteAsync(); + + // For zip updates, we need to find the relative path to the jsBundle and save it in the + // metadata so that we can find and run it easily the next time. + string relativeBundlePath = await CodePushUpdateUtils.FindJSBundleInUpdateContents(newUpdateFolder, expectedBundleFileName); + if (relativeBundlePath == null) + { + throw new CodePushInvalidUpdateException("Update is invalid - A JS bundle file named \"" + expectedBundleFileName + "\" could not be found within the downloaded contents. Please check that you are releasing your CodePush updates using the exact same JS bundle file name that was shipped with your app's binary."); + } + else + { + if (diffManifestFile != null) + { + // TODO verify hash for diff update + // CodePushUpdateUtils.verifyHashForDiffUpdate(newUpdateFolderPath, newUpdateHash); + } + + updatePackage[RELATIVE_BUNDLE_PATH_KEY] = relativeBundlePath; + } + } + catch (InvalidDataException) + { + // Downloaded file is not a zip, assume it is a jsbundle + await downloadFile.RenameAsync(expectedBundleFileName); + await downloadFile.MoveAsync(newUpdateFolder); + } + + // Save metadata to the folder + await FileIO.WriteTextAsync(newUpdateMetadataFile, JsonConvert.SerializeObject(updatePackage)); + } + + public async Task InstallPackage(JObject updatePackage, bool removePendingUpdate) + { + string packageHash = (string)updatePackage[PACKAGE_HASH_KEY]; + JObject info = await GetCurrentPackageInfo(); + if (removePendingUpdate) + { + StorageFolder currentPackageFolder = await GetCurrentPackageFolder(); + if (currentPackageFolder != null) + { + await currentPackageFolder.DeleteAsync(); + } + } + else + { + string previousPackageHash = await GetPreviousPackageHash(); + if (previousPackageHash != null && !previousPackageHash.Equals(packageHash)) + { + StorageFolder previousPackageFolder = await GetPackageFolder(previousPackageHash, false); + if (previousPackageFolder != null) + { + await previousPackageFolder.DeleteAsync(); + } + } + + info[PREVIOUS_PACKAGE_KEY] = info[CURRENT_PACKAGE_KEY]; + } + + info[CURRENT_PACKAGE_KEY] = packageHash; + await UpdateCurrentPackageInfo(info); + } + + public async Task RollbackPackage() + { + JObject info = await GetCurrentPackageInfo(); + StorageFolder currentPackageFolder = await GetCurrentPackageFolder(); + if (currentPackageFolder != null) + { + await currentPackageFolder.DeleteAsync(); + } + + info[CURRENT_PACKAGE_KEY] = info[PREVIOUS_PACKAGE_KEY]; + info[PREVIOUS_PACKAGE_KEY] = null; + await UpdateCurrentPackageInfo(info); + } + + public void DownloadAndReplaceCurrentBundle(string remoteBundleUrl, string bundleFileName) + { + // TODO implement this method (only used in tests) + } + + public async Task ClearUpdates() + { + await (await GetStatusFile()).DeleteAsync(); + await (await GetCodePushFolder()).DeleteAsync(); + } + } +} diff --git a/windows/CodePushUnknownException.cs b/windows/CodePushUnknownException.cs new file mode 100644 index 0000000..aafa0a8 --- /dev/null +++ b/windows/CodePushUnknownException.cs @@ -0,0 +1,12 @@ +using System; + +namespace ReactNative.CodePush +{ + class CodePushUnknownException : Exception + { + public CodePushUnknownException(string message, Exception inner) + : base(message, inner) + { + } + } +} \ No newline at end of file diff --git a/windows/CodePushUpdateUtils.cs b/windows/CodePushUpdateUtils.cs new file mode 100644 index 0000000..858007a --- /dev/null +++ b/windows/CodePushUpdateUtils.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Threading.Tasks; +using Windows.Storage; + +namespace ReactNative.CodePush +{ + class CodePushUpdateUtils + { + // TODO: Generate binary hash + // private static readonly String CODE_PUSH_HASH_FILE_NAME = "CodePushHash.json"; + + public async static Task CopyNecessaryFilesFromCurrentPackage(StorageFile diffManifestFile, StorageFolder currentPackageFolder, StorageFolder newPackageFolder) + { + await FileUtils.MergeDirectories(currentPackageFolder, newPackageFolder); + JObject diffManifest = await CodePushUtils.GetJObjectFromFile(diffManifestFile); + JArray deletedFiles = (JArray)diffManifest["deletedFiles"]; + foreach (string fileNameToDelete in deletedFiles) + { + StorageFile fileToDelete = await newPackageFolder.GetFileAsync(fileNameToDelete); + await fileToDelete.DeleteAsync(); + } + } + + public async static Task FindJSBundleInUpdateContents(StorageFolder updateFolder, string expectedFileName) + { + foreach (StorageFile file in await updateFolder.GetFilesAsync()) + { + string fileName = file.Name; + if (fileName.Equals(expectedFileName)) + { + return fileName; + } + } + + foreach (StorageFolder folder in await updateFolder.GetFoldersAsync()) + { + string mainBundlePathInSubFolder = await FindJSBundleInUpdateContents(folder, expectedFileName); + if (mainBundlePathInSubFolder != null) + { + return Path.Combine(folder.Name, mainBundlePathInSubFolder); + } + } + + return null; + } + } +} diff --git a/windows/CodePushUtils.cs b/windows/CodePushUtils.cs new file mode 100644 index 0000000..f080b94 --- /dev/null +++ b/windows/CodePushUtils.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Windows.Storage; +using Windows.Storage.Streams; +using Windows.System.Profile; + +namespace ReactNative.CodePush +{ + class CodePushUtils + { + public static readonly string REACT_NATIVE_LOG_CATEGORY = "ReactNative"; + + public async static Task GetJObjectFromFile(StorageFile file) + { + string jsonString = await FileIO.ReadTextAsync(file); + if (jsonString.Length == 0) + { + return new JObject(); + } + + return JObject.Parse(jsonString); + } + + public static void log(string message) + { + Debug.WriteLine("[CodePush] " + message, REACT_NATIVE_LOG_CATEGORY); + } + + public static void logBundleUrl(string path) + { + log("Loading JS bundle from \"" + path + "\""); + } + + public static string GetDeviceId() + { + HardwareToken token = Windows.System.Profile.HardwareIdentification.GetPackageSpecificToken(null); + IBuffer hardwareId = token.Id; + DataReader dataReader = DataReader.FromBuffer(hardwareId); + + byte[] bytes = new byte[hardwareId.Length]; + dataReader.ReadBytes(bytes); + + return BitConverter.ToString(bytes); + } + } +} diff --git a/windows/FileUtils.cs b/windows/FileUtils.cs new file mode 100644 index 0000000..cccb1da --- /dev/null +++ b/windows/FileUtils.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace ReactNative.CodePush +{ + class FileUtils + { + public async static Task MergeDirectories(StorageFolder source, StorageFolder target) + { + foreach (StorageFile sourceFile in await source.GetFilesAsync()) + { + await sourceFile.CopyAndReplaceAsync(await target.CreateFileAsync(sourceFile.Name, CreationCollisionOption.OpenIfExists)); + } + + foreach (StorageFolder sourceDirectory in await source.GetFoldersAsync()) + { + StorageFolder nextTargetSubDir = await target.CreateFolderAsync(sourceDirectory.Name, CreationCollisionOption.OpenIfExists); + await MergeDirectories(sourceDirectory, nextTargetSubDir); + } + } + } +} diff --git a/windows/Properties/AssemblyInfo.cs b/windows/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4fe02fc --- /dev/null +++ b/windows/Properties/AssemblyInfo.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("CodePush")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("CodePush")] +[assembly: AssemblyCopyright("Copyright © 2016")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: ComVisible(false)] \ No newline at end of file diff --git a/windows/Properties/CodePush.rd.xml b/windows/Properties/CodePush.rd.xml new file mode 100644 index 0000000..2796b5b --- /dev/null +++ b/windows/Properties/CodePush.rd.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/windows/project.json b/windows/project.json new file mode 100644 index 0000000..aeeb9ef --- /dev/null +++ b/windows/project.json @@ -0,0 +1,17 @@ +{ + "dependencies": { + "Microsoft.NETCore.UniversalWindowsPlatform": "5.0.0", + "Newtonsoft.Json": "8.0.3" + }, + "frameworks": { + "uap10.0": {} + }, + "runtimes": { + "win10-arm": {}, + "win10-arm-aot": {}, + "win10-x86": {}, + "win10-x86-aot": {}, + "win10-x64": {}, + "win10-x64-aot": {} + } +} \ No newline at end of file