From 1c661bc68e2fe49f3311c390de2c93d0587363db Mon Sep 17 00:00:00 2001 From: Ermolay Romanov Date: Mon, 27 Jul 2020 13:21:37 -0400 Subject: [PATCH] Switch to cordova-plugin-advanced-http for update downloads (#513) * Update plugin.xml * patch XCode 10 usage with `build.json` flags - add build.json to pass additional flags to cordova - use build.json to pass `UseModernBuildSystem=0` avoiding build failure * switch from plugin-file-transfer to to raw XHR * rollback config.xml tweak * remove plugin-file-transfer from project * Eliminated file-transfer from workaround hook * fix ProgressEvent typings, TS defs, commit JS * add cordova plugin file as an explicit dependency * CORS: drop xhr in favor of advanced-http plugin * fix: dependency minor version * switch from promises to callbacks * add dependency on plugin-zip * fix typo * update advanced-http dependency * use plugin-advanced-http for all http requests * fix body serialization * added getDataDirectory call to ensure path - as discussed in #513, with move to advanced-http, the update payload began to fail - The call to getDataDirectory triggers path creation, if it doesn't exist yet Co-authored-by: Alexander Goncharov Co-authored-by: David Pfeffer Co-authored-by: unknown Co-authored-by: Alexander Goncharov --- bin/www/fileUtil.js | 2 +- bin/www/httpRequester.js | 85 ++++++++++++++++++++++++--------------- bin/www/remotePackage.js | 44 ++++++++++---------- hooks/afterPluginAdd.js | 7 ---- plugin.xml | 5 ++- test/template/build.json | 1 + typings/codePush.d.ts | 86 +++++++++++++++++++++++++++++++++++++++- www/codePush.ts | 1 - www/fileUtil.ts | 4 +- www/httpRequester.ts | 80 +++++++++++++++++++------------------ www/remotePackage.ts | 73 ++++++++++++++++++++-------------- www/sdk.ts | 1 - 12 files changed, 256 insertions(+), 133 deletions(-) create mode 100644 test/template/build.json diff --git a/bin/www/fileUtil.js b/bin/www/fileUtil.js index 08efc9cd..b96ae209 100644 --- a/bin/www/fileUtil.js +++ b/bin/www/fileUtil.js @@ -228,7 +228,7 @@ var FileUtil = (function () { fileEntry.file(function (file) { var fileReader = new FileReader(); fileReader.onloadend = function (ev) { - callback(null, ev.target.result); + callback(null, fileReader.result); }; fileReader.onerror = function () { callback(new Error("Could not get file. Error: " + fileReader.error.message), null); diff --git a/bin/www/httpRequester.js b/bin/www/httpRequester.js index e0b2258d..6fb31b83 100644 --- a/bin/www/httpRequester.js +++ b/bin/www/httpRequester.js @@ -8,58 +8,79 @@ "use strict"; +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var CodePushUtil = require("./codePushUtil"); var HttpRequester = (function () { function HttpRequester(contentType) { - this.contentType = contentType; + cordova.plugin.http.setHeader("X-CodePush-Plugin-Name", "cordova-plugin-code-push"); + cordova.plugin.http.setHeader("X-CodePush-Plugin-Version", cordova.require("cordova/plugin_list").metadata["cordova-plugin-code-push"]); + cordova.plugin.http.setHeader("X-CodePush-SDK-Version", cordova.require("cordova/plugin_list").metadata["code-push"]); + if (contentType) { + cordova.plugin.http.setHeader("Content-Type", contentType); + } } HttpRequester.prototype.request = function (verb, url, callbackOrRequestBody, callback) { - var requestBody; var requestCallback = callback; + var options = HttpRequester.getInitialOptionsForVerb(verb); + if (options instanceof Error) { + CodePushUtil.logError("Could not make the HTTP request", options); + requestCallback && requestCallback(options, undefined); + return; + } if (!requestCallback && typeof callbackOrRequestBody === "function") { requestCallback = callbackOrRequestBody; } if (typeof callbackOrRequestBody === "string") { - requestBody = callbackOrRequestBody; - } - var xhr = new XMLHttpRequest(); - var methodName = this.getHttpMethodName(verb); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - var response = { statusCode: xhr.status, body: xhr.responseText }; - requestCallback && requestCallback(null, response); - } - }; - xhr.open(methodName, url, true); - if (this.contentType) { - xhr.setRequestHeader("Content-Type", this.contentType); + options.serializer = "utf8"; + options.data = callbackOrRequestBody; } - xhr.setRequestHeader("X-CodePush-Plugin-Name", "cordova-plugin-code-push"); - xhr.setRequestHeader("X-CodePush-Plugin-Version", cordova.require("cordova/plugin_list").metadata["cordova-plugin-code-push"]); - xhr.setRequestHeader("X-CodePush-SDK-Version", cordova.require("cordova/plugin_list").metadata["code-push"]); - xhr.send(requestBody); + options.responseType = "text"; + cordova.plugin.http.sendRequest(url, options, function (success) { + requestCallback && requestCallback(null, { + body: success.data, + statusCode: success.status, + }); + }, function (failure) { + requestCallback && requestCallback(new Error(failure.error), null); + }); }; - HttpRequester.prototype.getHttpMethodName = function (verb) { + HttpRequester.getInitialOptionsForVerb = function (verb) { switch (verb) { case 0: - return "GET"; - case 7: - return "CONNECT"; + return { method: "get" }; case 4: - return "DELETE"; + return { method: "delete" }; case 1: - return "HEAD"; - case 6: - return "OPTIONS"; + return { method: "head" }; case 8: - return "PATCH"; + return { method: "patch" }; case 2: - return "POST"; + return { method: "post" }; case 3: - return "PUT"; + return { method: "put" }; case 5: - return "TRACE"; + case 6: + case 7: default: - return null; + return new ((function (_super) { + __extends(UnsupportedMethodError, _super); + function UnsupportedMethodError() { + return _super !== null && _super.apply(this, arguments) || this; + } + return UnsupportedMethodError; + }(Error)))("Unsupported HTTP method code [" + verb + "]"); } }; return HttpRequester; diff --git a/bin/www/remotePackage.js b/bin/www/remotePackage.js index 88837ec7..14dede4f 100644 --- a/bin/www/remotePackage.js +++ b/bin/www/remotePackage.js @@ -23,13 +23,21 @@ var __extends = (this && this.__extends) || (function () { })(); var LocalPackage = require("./localPackage"); var Package = require("./package"); +var FileUtil = require("./fileUtil"); var NativeAppInfo = require("./nativeAppInfo"); var CodePushUtil = require("./codePushUtil"); var Sdk = require("./sdk"); var RemotePackage = (function (_super) { __extends(RemotePackage, _super); function RemotePackage() { - return _super !== null && _super.apply(this, arguments) || this; + var _this = _super.call(this) || this; + _this.isDownloading = false; + FileUtil.getDataDirectory(LocalPackage.DownloadDir, true, function (error, _) { + if (error) { + CodePushUtil.logError("Can't create directory for download update.", error); + } + }); + return _this; } RemotePackage.prototype.download = function (successCallback, errorCallback, downloadProgress) { var _this = this; @@ -39,9 +47,15 @@ var RemotePackage = (function (_super) { CodePushUtil.invokeErrorCallback(new Error("The remote package does not contain a download URL."), errorCallback); } else { - this.currentFileTransfer = new FileTransfer(); - var downloadSuccess = function (fileEntry) { - _this.currentFileTransfer = null; + this.isDownloading = true; + var onFileError_1 = function (fileError, stage) { + var error = new Error("Could not access local package. Stage:" + stage + "Error code: " + fileError.code); + CodePushUtil.invokeErrorCallback(error, errorCallback); + CodePushUtil.logMessage(stage + ":" + fileError); + _this.isDownloading = false; + }; + var onFileReady = function (fileEntry) { + _this.isDownloading = false; fileEntry.file(function (file) { NativeAppInfo.isFailedUpdate(_this.packageHash, function (installFailed) { var localPackage = new LocalPackage(); @@ -58,21 +72,11 @@ var RemotePackage = (function (_super) { successCallback && successCallback(localPackage); Sdk.reportStatusDownload(localPackage, localPackage.deploymentKey); }); - }, function (fileError) { - CodePushUtil.invokeErrorCallback(new Error("Could not access local package. Error code: " + fileError.code), errorCallback); - }); - }; - var downloadError = function (error) { - _this.currentFileTransfer = null; - CodePushUtil.invokeErrorCallback(new Error(error.body), errorCallback); - }; - this.currentFileTransfer.onprogress = function (progressEvent) { - if (downloadProgress) { - var dp = { receivedBytes: progressEvent.loaded, totalBytes: progressEvent.total }; - downloadProgress(dp); - } + }, function (fileError) { return onFileError_1(fileError, "READ_FILE"); }); }; - this.currentFileTransfer.download(this.downloadUrl, cordova.file.dataDirectory + LocalPackage.DownloadDir + "/" + LocalPackage.PackageUpdateFileName, downloadSuccess, downloadError); + var filedir = cordova.file.dataDirectory + LocalPackage.DownloadDir + "/"; + var filename = LocalPackage.PackageUpdateFileName; + cordova.plugin.http.downloadFile(this.downloadUrl, {}, {}, filedir + filename, onFileReady, onFileError_1); } } catch (e) { @@ -81,8 +85,8 @@ var RemotePackage = (function (_super) { }; RemotePackage.prototype.abortDownload = function (abortSuccess, abortError) { try { - if (this.currentFileTransfer) { - this.currentFileTransfer.abort(); + if (this.isDownloading) { + this.isDownloading = false; abortSuccess && abortSuccess(); } } diff --git a/hooks/afterPluginAdd.js b/hooks/afterPluginAdd.js index 98fdd361..d98a94b3 100644 --- a/hooks/afterPluginAdd.js +++ b/hooks/afterPluginAdd.js @@ -46,13 +46,6 @@ module.exports = function (ctx) { plugins = execSync(cordovaCLI + ' plugin').toString(); } - if (!isPluginInListOrInXmlConfig("cordova-plugin-file-transfer", plugins)) { - console.log("Adding the cordova-plugin-file-transfer@1.6.3... "); - var output = execSync(cordovaCLI + ' plugin add cordova-plugin-file-transfer@1.6.3').toString(); - console.log(output); - plugins = execSync(cordovaCLI + ' plugin').toString(); - } - if (!isPluginInListOrInXmlConfig("cordova-plugin-zip", plugins)) { console.log("Adding the cordova-plugin-zip@3.1.0... "); var output = execSync(cordovaCLI + ' plugin add cordova-plugin-zip@3.1.0').toString(); diff --git a/plugin.xml b/plugin.xml index 2068d04a..e79e51b9 100644 --- a/plugin.xml +++ b/plugin.xml @@ -5,10 +5,13 @@ MIT cordova,code,push https://github.com/Microsoft/cordova-plugin-code-push.git - + + + + diff --git a/test/template/build.json b/test/template/build.json new file mode 100644 index 00000000..119070f2 --- /dev/null +++ b/test/template/build.json @@ -0,0 +1 @@ +{ "ios": { "debug": { "buildFlag": [ "-UseModernBuildSystem=0" ] }, "release": { "buildFlag": [ "-UseModernBuildSystem=0" ] } } } diff --git a/typings/codePush.d.ts b/typings/codePush.d.ts index 71b652e5..65199c94 100644 --- a/typings/codePush.d.ts +++ b/typings/codePush.d.ts @@ -4,8 +4,92 @@ // Copyright (c) Microsoft Corporation // All rights reserved. // Licensed under the MIT license. +/// + +// Types used in file handling +/** + * A function which accepts @type {FileEntry} containing a file + */ +type FileSaverCompletionHandler = (entry: FileEntry) => void; +/** + * A function which is called if file handling has failed + */ +type FileSaverErrorHandler = (error: FileError, at: string) => void; + +/** + * @namespace AdvancedHttp describes the select types from cordova-plugin-advanced-http + * The plugin authors do not provide typescript typings, relying on ionic-native typings, + * which require the project to use ionic promisify-style plugin versions. + * + * @see https://github.com/silkimen/cordova-plugin-advanced-http/issues/32 + * + * For additional type documentation: + * - @see README for cordova-plugin-advanced-http and the + * - @see https://github.com/ionic-team/ionic-native/blob/master/src/%40ionic-native/plugins/http/index.ts + * for partial typings provided by ionic + */ +declare namespace AdvancedHttp { + + /** + * Response is passed to @method sendRequest callbacks. + */ + export interface Response { + /** + * The status number of the response + */ + status: number; + /** + * The headers of the response + */ + headers: any; + /** + * The URL of the response. This property will be the final URL obtained after any redirects. + */ + url: string; + /** + * The data that is in the response. This property usually exists when a promise returned by a request method resolves. + */ + data?: any; + /** + * Error response from the server. This property usually exists when a promise returned by a request method rejects. + */ + error?: string; + } + + /** + * Options object configures @method sendRequest calls + */ + export interface Options { + method: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' | 'upload' | 'download'; + data?: { [index: string]: any }; + params?: { [index: string]: string | number }; + serializer?: 'json' | 'urlencoded' | 'utf8'; + timeout?: number; + headers?: { [index: string]: string }; + filePath?: string | string[]; + name?: string | string[]; + responseType?: 'text' | 'arraybuffer' | 'blob' | 'json'; + } + + export class Plugin { + /** + * setHeader sets global headers for cordova-plugin-advanced-http calls + */ + setHeader(arg1: string, arg2: string, arg3?: string): void; + /** + * sendRequest handles the lifetime of an HTTP call + */ + sendRequest(url: string, options: Options, onSuccess: (r: Response) => void, onError: (r: Response) => void): void; + /** + * downloadFile wraps @method sendRequest to provide an easy interface for working with files + */ + downloadFile(url: string, body: object, headers: object, filePath: string, onSuccess: FileSaverCompletionHandler, onFailure: FileSaverErrorHandler): void; + } + +} declare module Http { + // Integer based verbs that will be passed by Acquisition SDK export const enum Verb { GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH } @@ -405,4 +489,4 @@ interface DownloadProgress { interface DeploymentResult { deployDir: DirectoryEntry, isDiffUpdate: boolean -} \ No newline at end of file +} diff --git a/www/codePush.ts b/www/codePush.ts index ff62797c..4062c1f4 100644 --- a/www/codePush.ts +++ b/www/codePush.ts @@ -1,6 +1,5 @@ /// /// -/// /// /// diff --git a/www/fileUtil.ts b/www/fileUtil.ts index 0dd2cf31..cc4a3c8e 100644 --- a/www/fileUtil.ts +++ b/www/fileUtil.ts @@ -281,8 +281,8 @@ class FileUtil { public static readFileEntry(fileEntry: FileEntry, callback: Callback): void { fileEntry.file((file: File) => { var fileReader = new FileReader(); - fileReader.onloadend = (ev: any) => { - callback(null, ev.target.result); + fileReader.onloadend = (ev: ProgressEvent) => { + callback(null, fileReader.result as string); }; fileReader.onerror = () => { diff --git a/www/httpRequester.ts b/www/httpRequester.ts index 5f492ec4..3bca96e2 100644 --- a/www/httpRequester.ts +++ b/www/httpRequester.ts @@ -2,78 +2,82 @@ "use strict"; -declare var cordova: Cordova; +import CodePushUtil = require("./codePushUtil"); + +declare var cordova: Cordova & { plugin: { http: AdvancedHttp.Plugin } }; /** * XMLHttpRequest-based implementation of Http.Requester. */ class HttpRequester implements Http.Requester { - private contentType: string; - constructor(contentType?: string) { - this.contentType = contentType; + // Set headers for all requests + cordova.plugin.http.setHeader("X-CodePush-Plugin-Name", "cordova-plugin-code-push"); + cordova.plugin.http.setHeader("X-CodePush-Plugin-Version", cordova.require("cordova/plugin_list").metadata["cordova-plugin-code-push"]); + cordova.plugin.http.setHeader("X-CodePush-SDK-Version", cordova.require("cordova/plugin_list").metadata["code-push"]); + if (contentType) { + cordova.plugin.http.setHeader("Content-Type", contentType); + } } public request(verb: Http.Verb, url: string, callbackOrRequestBody: Callback | string, callback?: Callback): void { - var requestBody: string; var requestCallback: Callback = callback; + var options = HttpRequester.getInitialOptionsForVerb(verb); + if (options instanceof Error) { + CodePushUtil.logError("Could not make the HTTP request", options); + requestCallback && requestCallback(options, undefined); + return; + } + if (!requestCallback && typeof callbackOrRequestBody === "function") { requestCallback = >callbackOrRequestBody; } if (typeof callbackOrRequestBody === "string") { - requestBody = callbackOrRequestBody; + // should be already JSON.stringify-ied, using plaintext serializer + options.serializer = "utf8"; + options.data = callbackOrRequestBody; } - var xhr = new XMLHttpRequest(); - var methodName = this.getHttpMethodName(verb); - xhr.onreadystatechange = function(): void { - if (xhr.readyState === 4) { - var response: Http.Response = { statusCode: xhr.status, body: xhr.responseText }; - requestCallback && requestCallback(null, response); - } - }; - xhr.open(methodName, url, true); - if (this.contentType) { - xhr.setRequestHeader("Content-Type", this.contentType); - } + options.responseType = "text"; // Backward compatibility to xhr.responseText - xhr.setRequestHeader("X-CodePush-Plugin-Name", "cordova-plugin-code-push"); - xhr.setRequestHeader("X-CodePush-Plugin-Version", cordova.require("cordova/plugin_list").metadata["cordova-plugin-code-push"]); - xhr.setRequestHeader("X-CodePush-SDK-Version", cordova.require("cordova/plugin_list").metadata["code-push"]); - xhr.send(requestBody); + cordova.plugin.http.sendRequest(url, options, function(success) { + requestCallback && requestCallback(null, { + body: success.data, // this should be plaintext + statusCode: success.status, + }); + }, function(failure) { + requestCallback && requestCallback(new Error(failure.error), null); + }); } /** - * Gets the HTTP method name as a string. - * The reason for which this is needed is because the Http.Verb enum is defined as a constant => Verb[Verb.METHOD_NAME] is not defined in the compiled JS. + * Builds the initial options object for the advanced-http plugin, if the HTTP method is supported. + * The reason for which this is needed is because the Http.Verb enum corresponds to integer values from native runtime. */ - private getHttpMethodName(verb: Http.Verb): string { + private static getInitialOptionsForVerb(verb: Http.Verb): AdvancedHttp.Options | Error { switch (verb) { case Http.Verb.GET: - return "GET"; - case Http.Verb.CONNECT: - return "CONNECT"; + return { method: "get" }; case Http.Verb.DELETE: - return "DELETE"; + return { method: "delete" }; case Http.Verb.HEAD: - return "HEAD"; - case Http.Verb.OPTIONS: - return "OPTIONS"; + return { method: "head" }; case Http.Verb.PATCH: - return "PATCH"; + return { method: "patch" }; case Http.Verb.POST: - return "POST"; + return { method: "post" }; case Http.Verb.PUT: - return "PUT"; + return { method: "put" }; case Http.Verb.TRACE: - return "TRACE"; + case Http.Verb.OPTIONS: + case Http.Verb.CONNECT: default: - return null; + return new(class UnsupportedMethodError extends Error {})(`Unsupported HTTP method code [${verb}]`); } } } -export = HttpRequester; \ No newline at end of file +export = HttpRequester; diff --git a/www/remotePackage.ts b/www/remotePackage.ts index da7b345f..b29dc979 100644 --- a/www/remotePackage.ts +++ b/www/remotePackage.ts @@ -1,12 +1,13 @@ /// -/// +/// "use strict"; -declare var cordova: Cordova; +declare var cordova: Cordova & { plugin: { http: AdvancedHttp.Plugin }}; import LocalPackage = require("./localPackage"); import Package = require("./package"); +import FileUtil = require("./fileUtil"); import NativeAppInfo = require("./nativeAppInfo"); import CodePushUtil = require("./codePushUtil"); import Sdk = require("./sdk"); @@ -16,16 +17,34 @@ import Sdk = require("./sdk"); */ class RemotePackage extends Package implements IRemotePackage { - private currentFileTransfer: FileTransfer; + constructor() { + super(); + + /** + * @see https://github.com/microsoft/cordova-plugin-code-push/pull/513#pullrequestreview-449368983 + */ + FileUtil.getDataDirectory(LocalPackage.DownloadDir, true, (error: Error, _: DirectoryEntry) => { + /* + * TODO: errors must be strongly checked, via named subclassing & instanceof + * or common (const) enum property of error payload i.e.: + * if error.kind === ErrorKind.PermissionDeniedError + */ + if (error) { + CodePushUtil.logError("Can't create directory for download update.", error); + } + }); + } + + private isDownloading: boolean = false; /** * The URL at which the package is available for download. */ public downloadUrl: string; - + /** * Downloads the package update from the CodePush service. - * + * * @param downloadSuccess Called with one parameter, the downloaded package information, once the download completed successfully. * @param downloadError Optional callback invoked in case of an error. * @param downloadProgress Optional callback invoked during the download process. It is called several times with one DownloadProgress parameter. @@ -36,10 +55,17 @@ class RemotePackage extends Package implements IRemotePackage { if (!this.downloadUrl) { CodePushUtil.invokeErrorCallback(new Error("The remote package does not contain a download URL."), errorCallback); } else { - this.currentFileTransfer = new FileTransfer(); + this.isDownloading = true; + + const onFileError: FileSaverErrorHandler = (fileError: FileError, stage: string) => { + const error = new Error("Could not access local package. Stage:" + stage + "Error code: " + fileError.code); + CodePushUtil.invokeErrorCallback(error, errorCallback); + CodePushUtil.logMessage(stage + ":" + fileError); + this.isDownloading = false; + }; - var downloadSuccess = (fileEntry: FileEntry) => { - this.currentFileTransfer = null; + const onFileReady: FileSaverCompletionHandler = (fileEntry: FileEntry) => { + this.isDownloading = false; fileEntry.file((file: File) => { @@ -59,41 +85,30 @@ class RemotePackage extends Package implements IRemotePackage { successCallback && successCallback(localPackage); Sdk.reportStatusDownload(localPackage, localPackage.deploymentKey); }); - }, (fileError: FileError) => { - CodePushUtil.invokeErrorCallback(new Error("Could not access local package. Error code: " + fileError.code), errorCallback); - }); - }; - - var downloadError = (error: FileTransferError) => { - this.currentFileTransfer = null; - CodePushUtil.invokeErrorCallback(new Error(error.body), errorCallback); + }, fileError => onFileError(fileError, "READ_FILE")); }; - this.currentFileTransfer.onprogress = (progressEvent: ProgressEvent) => { - if (downloadProgress) { - var dp: DownloadProgress = { receivedBytes: progressEvent.loaded, totalBytes: progressEvent.total }; - downloadProgress(dp); - } - }; + const filedir = cordova.file.dataDirectory + LocalPackage.DownloadDir + "/"; + const filename = LocalPackage.PackageUpdateFileName; - this.currentFileTransfer.download(this.downloadUrl, cordova.file.dataDirectory + LocalPackage.DownloadDir + "/" + LocalPackage.PackageUpdateFileName, downloadSuccess, downloadError); + cordova.plugin.http.downloadFile(this.downloadUrl, {}, {}, filedir + filename, onFileReady, onFileError); } } catch (e) { CodePushUtil.invokeErrorCallback(new Error("An error occurred while downloading the package. " + (e && e.message) ? e.message : ""), errorCallback); } } - + /** * Aborts the current download session, previously started with download(). - * + * * @param abortSuccess Optional callback invoked if the abort operation succeeded. * @param abortError Optional callback invoked in case of an error. */ public abortDownload(abortSuccess?: SuccessCallback, abortError?: ErrorCallback): void { try { - if (this.currentFileTransfer) { - this.currentFileTransfer.abort(); - + if (this.isDownloading) { + this.isDownloading = false; + /* abort succeeded */ abortSuccess && abortSuccess(); } @@ -104,4 +119,4 @@ class RemotePackage extends Package implements IRemotePackage { } } -export = RemotePackage; \ No newline at end of file +export = RemotePackage; diff --git a/www/sdk.ts b/www/sdk.ts index cb5f74ba..637048c6 100644 --- a/www/sdk.ts +++ b/www/sdk.ts @@ -1,5 +1,4 @@ /// -/// /// "use strict";