package utils.publish; import haxe.crypto.Base64; import hxp.Path; import haxe.Json; import lime.tools.helpers.CLIHelper; import lime.tools.helpers.Log; import lime.tools.helpers.ZipHelper; import lime.tools.helpers.ProcessHelper; import lime.graphics.Image; import lime.net.oauth.*; import lime.net.*; import lime.project.HXProject; import utils.PlatformSetup; import sys.FileSystem; import sys.io.File; class FirefoxMarketplace { private static function compress(project:HXProject):String { var outputDirectory = project.app.path + "/firefox"; var source = outputDirectory + "/bin/"; var packagedFile = project.app.file + ".zip"; var destination = outputDirectory + "/dist/" + packagedFile; System.compress(source, destination); return destination; } public static function isValid(project:HXProject):Bool { var result = FirefoxHelper.validate(project); if (result.errors.length != 0) { var errorMsg = "The application cannot be published\n"; for (error in result.errors) { errorMsg += '\n * ' + error; } if (Log.verbose) Log.println(""); Log.error(errorMsg); return false; } return true; } public static function publish(project:HXProject):Void { var devServer = project.targetFlags.exists("dev"); var forceUpload = project.targetFlags.exists("force"); var answer:Answer; /*if (!devServer) { Log.println ("In which server do you want to publish your application?"); Log.println ("\t1. Production server."); Log.println ("\t2. Development server."); Log.println ("\tq. Quit."); answer = CLIHelper.ask ("Which server?", ["1", "2", "q"]); switch (answer) { case CUSTOM (x): switch (x) { case "2": devServer = true; case "q": Sys.exit (0); } default: } }*/ Log.info("Checking account..."); var defines = project.defines; var existsProd = defines.exists("FIREFOX_MARKETPLACE_KEY") && defines.exists("FIREFOX_MARKETPLACE_SECRET"); var existsDev = defines.exists("FIREFOX_MARKETPLACE_DEV_KEY") && defines.exists("FIREFOX_MARKETPLACE_DEV_SECRET"); if ((!existsProd && !devServer) || (!existsDev && devServer)) { setup(false, devServer, cast defines); // we need to get all the defines after configuring the account Log.mute = true; defines = PlatformSetup.getDefines(); Log.mute = false; } var baseUrl = devServer ? FirefoxHelper.DEVELOPMENT_SERVER_URL : FirefoxHelper.PRODUCTION_SERVER_URL; var appID:Int = -1; var appSlug:String = ""; var appName = project.meta.title; var key = defines.get("FIREFOX_MARKETPLACE" + (devServer ? "_DEV_" : "_") + "KEY"); var secret = defines.get("FIREFOX_MARKETPLACE" + (devServer ? "_DEV_" : "_") + "SECRET"); var marketplace = new MarketplaceAPI(key, secret, devServer); var error = function(r:Dynamic) { Reflect.deleteField(r, "error"); // Log.println (""); Log.error((r.customError != null ? r.customError : 'There was an error:\n\n$r')); }; var response:Dynamic = marketplace.getUserAccount(); if (response.error) { response.customError = "Could not validate your account, please verify your account information"; error(response); } // Log.println ("OK"); var apps:List = Lambda.filter(marketplace.getUserApps(), function(obj) return appName == Reflect.field(obj.name, "en-US")); if (!forceUpload && apps.length > 0) { var app = apps.first(); Log.println("This application has already been submitted to the Firefox Marketplace."); answer = CLIHelper.ask("Do you want to open the edit page?", ["y", "n"]); if (answer == YES) { System.openURL(baseUrl + '/developers/app/${app.slug}/edit'); } Sys.exit(0); } // Log.println ("Submitting \"" + appName + "\" to the Firefox " + (devServer ? "development" : "production") + " server"); var packagedFile = compress(project); response = marketplace.submitForValidation(packagedFile); if (response.error || response.id == null) { error(response); } var uploadID = response.id; Log.println(""); // Log.print ('Server validation ($uploadID)'); Log.print("Waiting for server"); do { Log.print("."); response = marketplace.checkValidationStatus(uploadID); Sys.sleep(1); } while (!response.processed); Log.println(""); if (response.valid) { // Log.println (" VALID"); Log.info("Sending application details..."); response = marketplace.createApp(uploadID); if (response.error || response.id == null) { // Log.println ("ERROR"); error(response); } appID = response.id; appSlug = response.slug; // Log.println ("OK"); // Log.print ("Updating application information... "); response = marketplace.updateAppInformation(appID, project); if (response.error) { // Log.println ("ERROR"); error(response); } // Log.println ("OK"); // Log.println ("Updating screenshots:"); var screenshots:Array = project.config.getArrayString("firefox-marketplace.screenshots.screenshot", "path"); for (i in 0...screenshots.length) { response = marketplace.uploadScreenshot(appID, i, screenshots[i]); Log.println(""); if (response.error) { error(response); } } var urlApp = baseUrl + '/app/$appSlug/'; var devUrlApp = baseUrl + '/developers/app/$appSlug/'; var urlContentRatings = devUrlApp + "content_ratings/edit"; var havePayments = project.config.getString("firefox-marketplace.premium-type", "free") != cast PremiumType.FREE; Log.println(""); Log.info("Application submitted!"); Sys.sleep(1); Log.println(""); Log.info("Before the application is fully published, you will need to fill out a content"); Log.info("rating questionnaire, and send the application for review"); Log.println(""); var answer = CLIHelper.ask("Would you like to complete your submission now?"); if (answer == YES || answer == ALWAYS) { if (Log.verbose) Log.println(""); System.openURL(urlContentRatings); } else { Log.println(""); Log.info("You can complete your submission later by going to " + devUrlApp); } /* Log.println (""); Log.warn ("Before this application can be reviewed & published:"); Log.warn ("* You will need to fill the contents rating questionnaire *"); if (havePayments) Log.warn ("* You will need to add or link a payment account *"); Log.println (""); Log.println ("1. Open the contents rating questionnaire page."); Log.println ("2. Open the application edit page."); Log.println ("3. Open the application listing page."); Log.println ("q. I'm fine, thanks."); answer = CLIHelper.ask ("Open the questionnaire now?", ["1", "2", "3", "q"]); switch (answer) { case CUSTOM (x): switch (x) { case "1": System.openURL (urlContentRatings); case "2": System.openURL (devUrlApp); case "3": System.openURL (urlApp); case _: } default: } Log.println (""); Log.println ("Your application listing page is:"); Log.println ('$urlApp'); Log.println (""); Log.println ("Goodbye!"); */ } else { // Log.println (" FAILED"); Log.println(""); var errorMsg = "Application failed server validation"; var errors:List = Lambda.filter(response.validation.messages, function(m) return m.type == "error"); var n = 1; for (error in errors) { errorMsg += ('\n * ${error.description.join(" ")}'); } // errorMsg += "\nPlease refer to the documentation to fix the issues."; marketplace.close(); Log.error(errorMsg); } marketplace.close(); } public static function setup(askServer:Bool = true, devServer:Bool = false, defines:Map = null):Void { if (defines == null) { defines = PlatformSetup.getDefines(); } var existsProd = defines.exists("FIREFOX_MARKETPLACE_KEY") && defines.exists("FIREFOX_MARKETPLACE_SECRET"); var existsDev = defines.exists("FIREFOX_MARKETPLACE_DEV_KEY") && defines.exists("FIREFOX_MARKETPLACE_DEV_SECRET"); // TODO warning about the override of the account Log.println("You need to link your developer account to publish to the Firefox Marketplace"); var answer = CLIHelper.ask("Would you like to open the developer site now?"); if (answer == YES || answer == ALWAYS) { var server = ""; /*if (askServer) { Log.println (""); Log.println ("First of all you need to select the server you want to setup your account."); Log.println ("Each server has its own configuration and can't be shared."); Log.println ("\t1. Production server (" + FirefoxHelper.PRODUCTION_SERVER_URL + ")"); Log.println ("\t2. Development server (" + FirefoxHelper.DEVELOPMENT_SERVER_URL + ")"); Log.println("\tq. Cancel"); answer = CLIHelper.ask ("Choose the server to setup your Firefox Marketplace account.", ["1", "2", "q"]); } else {*/ answer = devServer ? CUSTOM("2") : CUSTOM("1"); // } switch (answer) { case CUSTOM("1"): server = FirefoxHelper.PRODUCTION_SERVER_URL; devServer = false; case CUSTOM("2"): server = FirefoxHelper.DEVELOPMENT_SERVER_URL; devServer = true; default: Sys.exit(0); } /*if ((existsProd && !devServer) || (existsDev && devServer)) { Log.info (""); Log.warn ("You will override your account settings!"); answer = CLIHelper.ask ("Are you sure?", ["y", "n"]); if (answer == NO) { Sys.exit (0); } }*/ Log.println(""); Log.info("Opening \"" + server + "/developers/api\"..."); Log.println(""); Log.info(" * Create a new account or login"); Log.info(" * Choose \"Command line\" as the client type then press \"Create\""); Sys.sleep(3); if (Log.verbose) Log.println(""); System.openURL(server + "/developers/api"); Sys.sleep(2); Log.println(""); Log.info("\x1b[1mPress any key to continue\x1b[0m"); try { Sys.stdin().readLine(); } catch (e:Dynamic) { Sys.exit(0); } } var key = StringTools.trim(CLIHelper.param("OAuth Key")); var secret = StringTools.trim(CLIHelper.param("OAuth Secret")); Log.println(""); var marketplace = new MarketplaceAPI(key, secret, devServer); var name:String = ""; var account:Dynamic; var valid = false; do { Log.println("Checking account..."); account = marketplace.getUserAccount(); if (account != null && account.display_name != null) { name = account.display_name; valid = true; } if (!valid) { Log.println("There was a problem connecting to your developer account"); answer = CLIHelper.ask("Would you like to try again?"); if (answer == YES) { Log.println(""); key = StringTools.trim(CLIHelper.param("OAuth Key")); secret = StringTools.trim(CLIHelper.param("OAuth Secret")); Log.println(""); marketplace.client.consumer.key = key; marketplace.client.consumer.secret = secret; } else { marketplace.close(); Sys.exit(0); } } } while (!valid); Log.println("Hello " + name + "!"); Log.mute = true; defines = PlatformSetup.getDefines(); Log.mute = false; defines.set("FIREFOX_MARKETPLACE" + (devServer ? "_DEV_" : "_") + "KEY", key); defines.set("FIREFOX_MARKETPLACE" + (devServer ? "_DEV_" : "_") + "SECRET", secret); PlatformSetup.writeConfig(defines.get("LIME_CONFIG"), defines); Log.println(""); } } class FirefoxHelper { public static inline var PRODUCTION_SERVER_URL = "https://marketplace.firefox.com"; public static inline var DEVELOPMENT_SERVER_URL = "https://marketplace-dev.allizom.org"; private static inline var TITLE_MAX_CHARS = 127; private static inline var MAX_CATEGORIES = 2; private static var MIN_WH_SCREENSHOT = {width: 320, height: 480}; private static function isScreenshotValid(path:String):Bool { if (FileSystem.exists(path)) { var img = Image.fromFile(path); var portrait = img.width >= MIN_WH_SCREENSHOT.width && img.height >= MIN_WH_SCREENSHOT.height; var landscape = img.width >= MIN_WH_SCREENSHOT.height && img.height >= MIN_WH_SCREENSHOT.width; return portrait || landscape; } return false; } public static function validate(project:HXProject):{errors:Array, warnings:Array} { var errors:Array = []; var warnings:Array = []; // We will check if the project has the minimal required fields for publishing to the Firefox Marketplace if (project.meta.title == "") { errors.push("You need to have a title\n\n\t\n"); } if (project.meta.title.length > TITLE_MAX_CHARS) { errors.push("Your title is too long (max " + TITLE_MAX_CHARS + " characters)\n"); } if (project.config.getString("firefox-marketplace.description", project.meta.description) == "") { errors.push("You need to have a description\n\n\t\n"); } if (project.meta.company == "") { errors.push("You need to have a company name\n\n\t\n"); } if (project.meta.companyUrl == "") { errors.push("You need to have a company URL\n\n\t\n"); } var categories = project.config.getArrayString("firefox-marketplace.categories.category", "name"); if (categories.length == 0) { errors.push("You need to have at least one category\n\n\t\n\t \n\t \n\t \n\t\n"); } else if (categories.length > MAX_CATEGORIES) { errors.push("You cannot have more than two categories"); } if (project.config.getString("firefox-marketplace.privacyPolicy") == "") { errors.push("You need to have a privacy policy\n\n\t\n\t Policy detail\n\t\n"); } if (project.config.getString("firefox-marketplace.support.email") == "") { errors.push("You need to have a support email address\n\n\t\n\t \n\t\n"); } var screenshots = project.config.getArrayString("firefox-marketplace.screenshots.screenshot", "path"); if (screenshots.length == 0) { errors.push("You need to have at least one screenshot\n\n\t\n\t \n\t \n\t \n\t\n"); } else { for (path in screenshots) { if (!isScreenshotValid(path)) { if (!FileSystem.exists(path)) { errors.push("Screenshot \"" + Path.withoutDirectory(path) + "\" does not exist\n"); } else { errors.push("Screenshot \"" + Path.withoutDirectory(path) + "\" must be at least 320 x 480 in size\n"); } } } } return {errors: errors, warnings: warnings}; } } class MarketplaceAPI { private static inline var API_PATH = "/api/v1/"; public var client:OAuthClient; private var loader:URLLoader; private var entryPoint:String; public function new(key:String = null, secret:String = null, devServer:Bool = false) { loader = new URLLoader(); if (key != null && secret != null) { client = new OAuthClient(OAuthVersion.V1, new OAuthConsumer(key, secret)); } entryPoint = (devServer ? FirefoxHelper.DEVELOPMENT_SERVER_URL : FirefoxHelper.PRODUCTION_SERVER_URL) + API_PATH; } public function checkValidationStatus(uploadID:String):Dynamic { var response = load(GET, 'apps/validation/$uploadID/', null); return response; } public function close():Void { loader.close(); } public function createApp(uploadID:String):Dynamic { var response = load(POST, 'apps/app/', Json.stringify({upload: uploadID})); return response; } public function customRequest(method:URLRequestMethod, path:String, ?data:Dynamic):URLRequest { var request:URLRequest; if (client == null) { request = new URLRequest(entryPoint + path); } else { request = client.createRequest(method, entryPoint + path); } request.method = method; request.data = data; request.contentType = "application/json"; return request; } public function getUserAccount():Dynamic { var response = load(GET, "account/settings/mine/", null); return response; } public function getUserApps():Array { var result:Array = []; var response = load(GET, 'apps/app/', null); if (!response.error && response.objects != null) { for (obj in cast(response.objects, Array)) { result.push(obj); } } return result; } private function load(method:URLRequestMethod, path:String, data:String = null, progressMsg:String = null):Dynamic { var response:Dynamic = {}; var status = 0; var request = customRequest(method, path, data); var withProgress = progressMsg != null && progressMsg.length > 0 && data != null; var uploadingFunc:URLLoader->Int->Int->Void = null; if (withProgress) { uploadingFunc = function(l, up, dl) CLIHelper.progress('$progressMsg', up, data.length); loader.onProgress.add(uploadingFunc); } loader.onHTTPStatus.add(function(_, s) status = s, true); loader.onComplete.add(function(l) { response = Json.parse(l.data); if (withProgress) l.onProgress.remove(uploadingFunc); }, true); loader.load(request); response.error = false; if (status >= 400) { response.error = true; } return response; } public function submitForValidation(path:String, type:String = "application/zip"):Dynamic { var p = new Path(path); var response:Dynamic = {}; if (FileSystem.exists(path) && p.ext == "zip") { var base = Base64.encode(File.getBytes(path)); var filename = p.file + "." + p.ext; var upload = { upload: { type: type, name: filename, data: base } }; response = load(POST, "apps/validation/", Json.stringify(upload), "Uploading:"); } else { response.error = true; response.customError = 'File $path doesn\'t exist'; } return response; } public function updateAppInformation(appID:Int, project:HXProject):Dynamic { var object = { name: project.meta.title, categories: project.config.getArrayString("firefox-marketplace.categories.category", "name"), description: project.config.getString("firefox-marketplace.description", project.meta.description), privacy_policy: project.config.getString("firefox-marketplace.privacyPolicy"), homepage: project.config.getString("firefox-marketplace.homepage"), support_url: project.config.getString("firefox-marketplace.support.url"), support_email: project.config.getString("firefox-marketplace.support.email"), device_types: project.config.getArrayString("firefox-marketplace.devices.device", "type", ["firefoxos", "desktop"]), premium_type: project.config.getString("firefox-marketplace.premium-type", "free"), price: project.config.getString("firefox-marketplace.config.price", "0.99"), }; var response = load(PUT, 'apps/app/$appID/', Json.stringify(object)); return response; } public function uploadScreenshot(appID:Int, position:Int, path:String):Dynamic { var response:Dynamic = {}; if (FileSystem.exists(path)) { var p = new Path(path); var type = p.ext == "png" ? "image/png" : "image/jpeg"; var base = Base64.encode(File.getBytes(path)); var filename = p.file + "." + p.ext; var screenshot = { position: position, file: { type: type, name: filename, data: base, } }; response = load(POST, 'apps/app/$appID/preview/', Json.stringify(screenshot), 'Uploading screenshot:'); } else { response.error = true; response.customError = 'File "$path" does not exist'; } return response; } } @:enum abstract DeviceType(String) { var FIREFOXOS = "firefoxos"; var DESKTOP = "desktop"; var MOBILE = "mobile"; var TABLET = "tablet"; } @:enum abstract PremiumType(String) { var FREE = "free"; var FREE_INAPP = "free-inapp"; var PREMIUM = "premium"; var PREMIUM_INAPP = "premium-inapp"; var OTHER = "other"; }