diff --git a/Finicky/Finicky.xcodeproj/project.pbxproj b/Finicky/Finicky.xcodeproj/project.pbxproj index 11f6b7f..e2bed04 100644 --- a/Finicky/Finicky.xcodeproj/project.pbxproj +++ b/Finicky/Finicky.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 543EC33F21DE54EA004789DF /* validateConfig.js in Resources */ = {isa = PBXBuildFile; fileRef = 543EC33E21DE54E9004789DF /* validateConfig.js */; }; 544B57891B28B87900812908 /* statusitem.png in Resources */ = {isa = PBXBuildFile; fileRef = 544B57871B28B87900812908 /* statusitem.png */; }; 544B578A1B28B87900812908 /* statusitem@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 544B57881B28B87900812908 /* statusitem@2x.png */; }; + 546F1678228B487F006C5375 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546F1677228B487F006C5375 /* Utilities.swift */; }; 5479FDD421655A3400D15A3C /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5479FDD321655A3400D15A3C /* Notifications.swift */; }; 548479A22278ADB40003D51C /* validate.js in Resources */ = {isa = PBXBuildFile; fileRef = 548479A12278ADB40003D51C /* validate.js */; }; 54899CD61B20D5BC00647101 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54899CD51B20D5BC00647101 /* AppDelegate.swift */; }; @@ -45,6 +46,7 @@ 543EC33E21DE54E9004789DF /* validateConfig.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = validateConfig.js; sourceTree = ""; }; 544B57871B28B87900812908 /* statusitem.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = statusitem.png; sourceTree = ""; }; 544B57881B28B87900812908 /* statusitem@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "statusitem@2x.png"; sourceTree = ""; }; + 546F1677228B487F006C5375 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; 5479FDD321655A3400D15A3C /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 548479A12278ADB40003D51C /* validate.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = validate.js; sourceTree = ""; }; 54899CD01B20D5BC00647101 /* Finicky.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Finicky.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -121,6 +123,7 @@ 54B0E7021B4678CE003F8AEE /* ShortUrlResolver.swift */, 5479FDD321655A3400D15A3C /* Notifications.swift */, 54B5771522860D0B0016BF77 /* Credits.rtf */, + 546F1677228B487F006C5375 /* Utilities.swift */, ); path = Finicky; sourceTree = ""; @@ -278,6 +281,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 546F1678228B487F006C5375 /* Utilities.swift in Sources */, 5479FDD421655A3400D15A3C /* Notifications.swift in Sources */, 54E33FD71B24E27000998E13 /* Config.swift in Sources */, 54899CD61B20D5BC00647101 /* AppDelegate.swift in Sources */, diff --git a/Finicky/Finicky/API.swift b/Finicky/Finicky/API.swift index 5bd304f..06d9337 100644 --- a/Finicky/Finicky/API.swift +++ b/Finicky/Finicky/API.swift @@ -4,7 +4,6 @@ import JavaScriptCore @objc protocol FinickyAPIExports : JSExport { static func log(_ message: String?) -> Void static func notify(_ title: JSValue, _ subtitle: JSValue) -> Void - static func matchDomains(_ domains: [String]) -> ((_ url: String) -> JSValue) static func getUrlParts(_ url: String) -> Dictionary } @@ -38,33 +37,19 @@ import JavaScriptCore self.logToConsole = logToConsole } - @objc class func matchDomains(_ domains: [String]) -> ((_ url: String) -> JSValue) { - func matchDomain(url: String) -> JSValue { - for domain in domains { - let urlParts = getUrlParts(url); - if( urlParts["host"] as! String == domain) { - return JSValue(bool: true, in: context) - } - } - return JSValue(bool: false, in: context) - } - - return matchDomain; - } - @objc public class func getUrlParts(_ urlString: String) -> Dictionary { let url: URL! = URL.init(string: urlString) guard url != nil else { return [:] } - let _protocol = url.scheme ?? nil - let username = url.user ?? nil - let password = url.password ?? nil - let host = url.host ?? nil + let _protocol = url.scheme ?? "" + let username = url.user ?? "" + let password = url.password ?? "" + let host = url.host ?? "" let port = url.port ?? nil let pathname = url.path - let search = url.query ?? nil - let hash = url.fragment ?? nil + let search = url.query ?? "" + let hash = url.fragment ?? "" let urlDict = [ "hash": hash as Any, @@ -79,5 +64,4 @@ import JavaScriptCore return urlDict } - } diff --git a/Finicky/Finicky/AppDelegate.swift b/Finicky/Finicky/AppDelegate.swift index a8cd351..2313032 100644 --- a/Finicky/Finicky/AppDelegate.swift +++ b/Finicky/Finicky/AppDelegate.swift @@ -32,7 +32,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele statusItem.menu = statusItemMenu statusItem.highlightMode = true statusItem.image = img - _ = toggleDockIcon(showIcon: false) + toggleDockIcon(showIcon: false) func toggleIconCallback(show: Bool) { guard statusItem != nil else { return } @@ -45,7 +45,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele configLoader = FinickyConfig(toggleIconCallback: toggleIconCallback, logToConsoleCallback: logToConsole, setShortUrlProviders: setShortUrlProviders) configLoader.reload(showSuccess: false) - } @IBAction func reloadConfig(_ sender: NSMenuItem) { @@ -79,23 +78,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele return } if let url = URL.init(string: value) { - if let appDescriptor = configLoader.determineOpeningApp(url: url, sourceBundleIdentifier: "net.kassett.finicky") { - var description = "" - - if let openInBackground = appDescriptor.openInBackground { - description = """ - Would open \(AppDescriptorType.bundleId == appDescriptor.appType ? "bundleId" : "") "\(appDescriptor.name)" \(openInBackground ? "application in the background" : "") URL: "\(appDescriptor.url)" - """ - } else { - description = """ - Would open \(AppDescriptorType.bundleId == appDescriptor.appType ? "bundleId" : "") "\(appDescriptor.name)" URL: "\(appDescriptor.url)" - """ - } - logToConsole(description) + shortUrlResolver.resolveUrl(url, callback: {(URL) -> Void in + self.performTest(url: URL) + }) + } + } + + func performTest(url: URL) { + if let appDescriptor = configLoader.determineOpeningApp(url: url, sourceBundleIdentifier: "net.kassett.finicky") { + var description = "" + + if let openInBackground = appDescriptor.openInBackground { + description = """ + Would open \(AppDescriptorType.bundleId == appDescriptor.appType ? "bundleId" : "")\(appDescriptor.name) \(openInBackground ? "application in the background" : "") URL: \(appDescriptor.url) + """ + } else { + description = """ + Would open \(AppDescriptorType.bundleId == appDescriptor.appType ? "bundleId" : "")\(appDescriptor.name) URL: \(appDescriptor.url) + """ } + logToConsole(description) } } + @discardableResult @objc func toggleDockIcon(showIcon state: Bool) -> Bool { var result: Bool if state { @@ -112,13 +118,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele let pid = event!.attributeDescriptor(forKeyword: AEKeyword(keySenderPIDAttr))!.int32Value let sourceBundleIdentifier = NSRunningApplication(processIdentifier: pid)?.bundleIdentifier - if shortUrlResolver.isShortUrl(url) { - shortUrlResolver.resolveUrl(url, callback: {(URL) -> Void in - self.callUrlHandlers(sourceBundleIdentifier, url: url) - }) - } else { - self.callUrlHandlers(sourceBundleIdentifier, url: url) - } + shortUrlResolver.resolveUrl(url, callback: {(URL) -> Void in + self.callUrlHandlers(sourceBundleIdentifier, url: URL) + }) } @objc func callUrlHandlers(_ sourceBundleIdentifier: String?, url: URL) { @@ -138,7 +140,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele if bundleId != nil { openUrlWithBrowser(appDescriptor.url, bundleIdentifier:bundleId!, openInBackground: appDescriptor.openInBackground ) } else { - print ("Finicky was unable to find the application \"" + appDescriptor.name + "\"") + let description = "Finicky was unable to find the application \"" + appDescriptor.name + "\""; + print(description) + logToConsole(description) showNotification(title: "Unable to find application", informativeText: "Finicky was unable to find the application \"" + appDescriptor.name + "\"", error: true) } } @@ -154,27 +158,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele func openUrlWithBrowser(_ url: URL, bundleIdentifier: String, openInBackground: Bool?) { - let urls = [url] - - // Launch in background by default if finicky isn't active to avoid something.. + // Launch in background by default if finicky isn't active to avoid something that causes some bug to happen... + // Too long ago to remember what actually happened let openInBackground = openInBackground ?? !isActive - if !openInBackground { - NSWorkspace.shared.launchApplication( - withBundleIdentifier: bundleIdentifier, - options: NSWorkspace.LaunchOptions.default, - additionalEventParamDescriptor: nil, - launchIdentifier: nil - ) + print("opening " + url.absoluteString) + if (openInBackground) { + shell("open", url.absoluteString , "-b", bundleIdentifier, "-g") + } else { + shell("open",url.absoluteString , "-b", bundleIdentifier) } - - NSWorkspace.shared.open( - urls, - withAppBundleIdentifier: bundleIdentifier, - options: openInBackground ? NSWorkspace.LaunchOptions.withoutActivation : NSWorkspace.LaunchOptions.default, - additionalEventParamDescriptor: nil, - launchIdentifiers: nil - ) } func application(_ sender: NSApplication, openFiles filenames: [String]) { @@ -183,7 +176,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele } } - func applicationWillFinishLaunching(_ aNotification: Notification) { let appleEventManager:NSAppleEventManager = NSAppleEventManager.shared() appleEventManager.setEventHandler(self, andSelector: #selector(AppDelegate.handleGetURLEvent(_:withReplyEvent:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL)) diff --git a/Finicky/Finicky/Config.swift b/Finicky/Finicky/Config.swift index f886579..83387cc 100644 --- a/Finicky/Finicky/Config.swift +++ b/Finicky/Finicky/Config.swift @@ -109,12 +109,17 @@ open class FinickyConfig { dispatchSource?.resume() } + @discardableResult open func createContext() -> JSContext { ctx = JSContext() ctx.exceptionHandler = { - context, exception in + (context: JSContext!, exception: JSValue!) in self.hasError = true; + //let stacktrace = exception.objectForKeyedSubscript("stack").toString() + //let lineNumber = exception.objectForKeyedSubscript("line").toString() + //let columnNumber = exception.objectForKeyedSubscript("column").toString() + //let message = "Error parsing config: \"\(String(describing: exception!))\" \nStack: \(stacktrace!):\(lineNumber!):\(columnNumber!)"; let message = "Error parsing config: \"\(String(describing: exception!))\""; print(message) showNotification(title: "Error parsing config", informativeText: String(describing: exception!), error: true) @@ -131,6 +136,7 @@ open class FinickyConfig { return ctx } + @discardableResult open func parseConfig(_ config: String) -> Bool { ctx.evaluateScript(config) @@ -173,18 +179,19 @@ open class FinickyConfig { if config == nil { let message = "Config file could not be read or found" - showNotification(title: message, subtitle: "Click here to show example config file", error: true) + showNotification(title: message, subtitle: "Click here for assistance", error: true) print(message) if (self.logToConsole != nil) { self.logToConsole!(message + "\n\n" + """ // -------------------------------------------------------------- // Example config, save as ~/.finicky.js + // For more examples, see the Finicky github page https://github.com/johnste/finicky module.exports = { defaultBrowser: "Safari", handlers: [ { - match: /^https?:\\/\\/(youtube|facebook|twitter|linkedin|keep\\.google)\\.com/, - app: "Google Chrome" + match: finicky.matchDomains(["youtube.com", "facebook.com", "twitter.com", "linkedin.com"]), + browser: "Google Chrome" } ] }; @@ -230,12 +237,16 @@ open class FinickyConfig { open func getShortUrlProviders() -> [String]? { let urlShorteners = ctx.evaluateScript("module.exports.options && module.exports.options.urlShorteners || []")?.toArray() - return urlShorteners as! [String]?; + let list = urlShorteners as! [String]?; + if (list?.count == 0) { + return nil; + } + return list; } open func determineOpeningApp(url: URL, sourceBundleIdentifier: String? = nil) -> AppDescriptor? { let appValue = getConfiguredAppValue(url: url, sourceBundleIdentifier: sourceBundleIdentifier) - + if ((appValue?.isObject)!) { let dict = appValue?.toDictionary() let appType = AppDescriptorType(rawValue: dict!["appType"] as! String) @@ -268,8 +279,10 @@ open class FinickyConfig { func getConfiguredAppValue(url: URL, sourceBundleIdentifier: String?) -> JSValue? { let optionsDict = [ "sourceBundleIdentifier": sourceBundleIdentifier as Any, + "urlString": url.absoluteString, + "url": FinickyAPI.getUrlParts(url.absoluteString), ] as [AnyHashable : Any] - let result = ctx.evaluateScript(processUrlJS!)?.call(withArguments: [url.absoluteString, optionsDict]) + let result = ctx.evaluateScript(processUrlJS!)?.call(withArguments: [optionsDict]) return result } @@ -279,5 +292,37 @@ open class FinickyConfig { } FinickyAPI.setContext(ctx) ctx.setObject(FinickyAPI.self, forKeyedSubscript: "finicky" as NSCopying & NSObjectProtocol) + + ctx.evaluateScript(""" + finicky.matchDomains = function(matchers) { + if (!Array.isArray(matchers)) { + matchers = [matchers]; + } + + return function({ url }) { + const domain = url.host; + return matchers.some(matcher => { + if (matcher instanceof RegExp) { + return matcher.test(domain); + } else if (typeof matcher === "string") { + return matcher === domain; + } + + return false; + }); + } + } + + // Warn when using deprecated API methods + finicky.onUrl = function() { + finicky.log("finicky.onUrl is no longer supported in this version of Finicky, please go to https://github.com/johnste/finicky for updated documentation"); + finicky.notify("finicky.onUrl is no longer supported", "Check the Finicky website for updated documentation"); + } + + finicky.setDefaultBrowser = function() { + finicky.log("finicky.setDefaultBrowser is no longer supported in this version of Finicky, please go to https://github.com/johnste/finicky for updated documentation"); + finicky.notify("finicky.setDefaultBrowser is no longer supported", "Check the Finicky website for updated documentation"); + } + """) } } diff --git a/Finicky/Finicky/Info.plist b/Finicky/Finicky/Info.plist index 2102b1f..5fcfa2b 100644 --- a/Finicky/Finicky/Info.plist +++ b/Finicky/Finicky/Info.plist @@ -40,7 +40,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0-alpha + 2.0-beta CFBundleSignature ???? CFBundleURLTypes diff --git a/Finicky/Finicky/ShortUrlResolver.swift b/Finicky/Finicky/ShortUrlResolver.swift index f214bb4..c6141af 100644 --- a/Finicky/Finicky/ShortUrlResolver.swift +++ b/Finicky/Finicky/ShortUrlResolver.swift @@ -27,20 +27,28 @@ class ResolveShortUrls: NSObject, URLSessionDelegate, URLSessionTaskDelegate { class FNShortUrlResolver { fileprivate var shortUrlProviders : [String] = [] + var version : String; init(shortUrlProviders: [String]?) { self.shortUrlProviders = shortUrlProviders ?? [ + "adf.ly", + "bit.do", "bit.ly", + "buff.ly", + "deck.ly", + "fur.ly", "goo.gl", + "is.gd", + "mcaf.ee", "ow.ly", - "deck.ly", - "t.co", - "su.pr", "spoti.fi", - "fur.ly", - "tinyurl.com", - "tiny.cc" + "su.pr", + "t.co", + "tiny.cc", + "tinyurl.com" ] + + self.version = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String } func isShortUrl(_ url: URL) -> Bool { @@ -53,15 +61,16 @@ class FNShortUrlResolver { return } - let request = URLRequest(url: url) + var request = URLRequest(url: url) + request.setValue("finicky/\(self.version)", forHTTPHeaderField: "User-Agent") let myDelegate = ResolveShortUrls(shortUrlResolver: self) - let session = URLSession(configuration: URLSessionConfiguration.default, delegate: myDelegate, delegateQueue: nil) let task = session.dataTask(with: request, completionHandler: { (data, response, error) -> Void in - if let responsy : HTTPURLResponse = response as? HTTPURLResponse { - let newUrl = URL(string: (responsy.allHeaderFields["Location"] as? String)!)! - callback(newUrl) + + if let httpResponse : HTTPURLResponse = response as? HTTPURLResponse { + let newUrl = URL(string: (httpResponse.allHeaderFields["Location"] as? String ?? url.absoluteString) ) + callback(newUrl ?? url) } else { callback(url) } diff --git a/Finicky/Finicky/Utilities.swift b/Finicky/Finicky/Utilities.swift new file mode 100644 index 0000000..cf46ef1 --- /dev/null +++ b/Finicky/Finicky/Utilities.swift @@ -0,0 +1,11 @@ +import Foundation + +@discardableResult +func shell(_ args: String...) -> Int32 { + let task = Process() + task.launchPath = "/usr/bin/env" + task.arguments = args + task.launch() + task.waitUntilExit() + return task.terminationStatus +} diff --git a/Finicky/Finicky/processUrl.js b/Finicky/Finicky/processUrl.js index fd0bbc9..d28ba63 100644 --- a/Finicky/Finicky/processUrl.js +++ b/Finicky/Finicky/processUrl.js @@ -1,54 +1,94 @@ (function() { const { validate, getErrors } = fastidious; - return function processUrl(url, options = {}) { - const { url: finalUrl, urlParts } = rewriteUrl(url, options); - let finalOptions = { - ...options, - url: urlParts - }; - - for (handler of module.exports.handlers) { - if (isMatch(handler.match, finalUrl, finalOptions)) { - return processBrowserResult(handler.app, finalUrl, finalOptions); + const appDescriptorSchema = { + name: validate.string.isRequired, + appType: validate.oneOf([ + validate.value("bundleId"), + validate.value("appName") + ]).isRequired, + openInBackground: validate.boolean + }; + + const urlSchema = { + url: validate.oneOf([ + validate.string, + validate.shape({ + protocol: validate.string.isRequired, + username: validate.string, + password: validate.string, + host: validate.string.isRequired, + port: validate.number, + pathname: validate.string, + search: validate.string, + hash: validate.string + }) + ]).isRequired + }; + + return function processUrl(options = {}) { + options = rewriteUrl(options); + + if (Array.isArray(module.exports.handlers)) { + for (let handler of module.exports.handlers) { + if (isMatch(handler.match, options)) { + return processBrowserResult(handler.browser, options); + } } } - if (module.exports.defaultBrowser) { - return processBrowserResult( - module.exports.defaultBrowser, - finalUrl, - finalOptions + return processBrowserResult(module.exports.defaultBrowser, options); + }; + + function validateSchema(value, schema, path = "") { + const errors = getErrors(value, schema, path); + if (errors.length > 0) { + throw new Error( + errors.join("\n") + "\nRecieved value:" + JSON.stringify(value) ); } - }; + } + + function createUrl(url) { + const { protocol, host, pathname = "" } = url; + let port = url.port ? `:${url.port}` : ""; + let search = url.search ? `?${url.search}` : ""; + let hash = url.hash ? `#${url.hash}` : ""; + let auth = url.username ? `${url.username}` : ""; + auth += url.password ? `:${url.password}` : ""; - function rewriteUrl(url, options) { - let finalOptions = { - ...options, - url: finicky.getUrlParts(url) - }; + return `${protocol}://${auth}${host}${port}${pathname}${search}${hash}`; + } + function rewriteUrl(options) { if (Array.isArray(module.exports.rewrite)) { - for (rewrite of module.exports.rewrite) { - if (isMatch(rewrite.match, url, finalOptions)) { - url = resolveFn(rewrite.url, url, finalOptions); - - finalOptions = { - ...options, - url: finicky.getUrlParts(url) - }; + for (let rewrite of module.exports.rewrite) { + if (isMatch(rewrite.match, options)) { + let urlResult = resolveFn(rewrite.url, options); + + validateSchema({ url: urlResult }, urlSchema); + + if (typeof urlResult === "string") { + options = { + ...options, + url: finicky.getUrlParts(urlResult), + urlString: urlResult + }; + } else { + options = { + ...options, + url: urlResult, + urlString: createUrl(urlResult) + }; + } } } } - return { - url: url, - urlParts: finicky.getUrlParts(url) - }; + return options; } - function isMatch(matcher, url, options) { + function isMatch(matcher, options) { if (!matcher) { return false; } @@ -57,11 +97,11 @@ return matchers.some(matcher => { if (matcher instanceof RegExp) { - return matcher.test(url); + return matcher.test(options.urlString); } else if (typeof matcher === "string") { - return matcher === url; + return matcher === options.urlString; } else if (typeof matcher === "function") { - return !!matcher(url, options); + return !!matcher(options); } return false; @@ -80,38 +120,26 @@ return isBundleIdentifier(value) ? "bundleId" : "appName"; } - function processBrowserResult(handler, url, options) { - let app = resolveFn(handler, url, options); + function processBrowserResult(handler, options) { + let browser = resolveFn(handler, options); // If all we got was a string, try to figure out if it's a bundle identifier or an application name - if (typeof app === "string") { - app = { - name: app + if (typeof browser === "string") { + browser = { + name: browser }; } - if (typeof app === "object" && app.name && !app.appType) { - app = { - ...app, - appType: getAppType(app.name) + if (typeof browser === "object" && browser.name && !browser.appType) { + browser = { + ...browser, + appType: getAppType(browser.name) }; } - const appDescriptorSchema = { - name: validate.string.isRequired, - appType: validate.oneOf([ - validate.value("bundleId"), - validate.value("appName") - ]).isRequired, - openInBackground: validate.boolean - }; - - const errors = getErrors(app, appDescriptorSchema, "result."); - if (errors.length > 0) { - throw new Error(errors.join(", ") + "\n" + JSON.stringify(app)); - } + validateSchema(browser, appDescriptorSchema); - return { ...app, url }; + return { ...browser, url: options.urlString }; } function isBundleIdentifier(value) { diff --git a/Finicky/Finicky/validate.js b/Finicky/Finicky/validate.js index db3984b..572492c 100644 --- a/Finicky/Finicky/validate.js +++ b/Finicky/Finicky/validate.js @@ -4,7 +4,7 @@ (global = global || self, factory(global.fastidious = {})); }(this, function (exports) { 'use strict'; - /* fastidious 1.0.3 - https://github.com/johnste/fastidious */ + /* fastidious 1.0.4 - https://github.com/johnste/fastidious */ function isDefined(value) { return typeof value !== "undefined" && value !== null; @@ -13,10 +13,35 @@ if (value instanceof RegExp) { return value.toString(); } - return JSON.stringify(value); + else if (Array.isArray(value)) { + return `[Array]`; + } + else if (typeof value === "function") { + return `[Function${value.name ? ` ${value.name}` : ""}]`; + } + else if (value instanceof Date) { + return `[Date]`; + } + else if (value === null) { + return "[null]"; + } + else if (value === undefined) { + return "[undefined]"; + } + return `[${JSON.stringify(value)}]`; } function getKeys(object) { return Object.keys(object).filter(key => Object.prototype.hasOwnProperty.call(object, key)); + } + function enumerate(names, mode = "or") { + if (names.length === 0) { + return ""; + } + if (names.length == 1) { + return names[0]; + } + const [tail, ...body] = names.reverse(); + return `${body.join(", ")} ${mode} ${tail}`; } function getTypeName(typeName) { @@ -32,10 +57,10 @@ } const result = typeCallback(value, key); if (typeof result === "boolean" && !result) { - return `Value at ${key}: ${formatValue(value)} is not ${typeName}`; + return `Value at ${key}: ${formatValue(value)} is not ${getTypeName(typeName)}`; } else if (Array.isArray(result) && result.length > 0) { - return result.join(", "); + return result.join("\n"); } } function isRequired(value, key) { @@ -86,7 +111,18 @@ boolean: createValidator("boolean", value => typeof value === "boolean"), string: createValidator("string", value => typeof value === "string"), number: createValidator("number", value => typeof value === "number" && !Number.isNaN(value)), - function: createValidator("function", value => typeof value === "function"), + function: (argNames) => { + if (!Array.isArray(argNames)) { + if (argNames) { + argNames = [argNames]; + } + else { + argNames = []; + } + } + const name = `function(${argNames.join(", ")})`; + return createValidator(name, value => typeof value === "function"); + }, regex: createValidator("regex", value => value instanceof RegExp), value: (expectedValue) => createValidator(expectedValue, value => { return value === expectedValue; @@ -119,8 +155,8 @@ } return v; }); - const description = typeCheckers.map(oneOf => getTypeName(oneOf.typeName)); - return createValidator(`oneOf: [${description}]`, (value, key) => { + const description = enumerate(typeCheckers.map(oneOf => getTypeName(oneOf.typeName))); + return createValidator(`${description}`, (value, key) => { const errors = typeCheckers.every(oneOfValidator => typeof oneOfValidator(value, key) === "string"); return errors ? [`${key}: Value not one of ${description}`] : true; }); diff --git a/Finicky/Finicky/validateConfig.js b/Finicky/Finicky/validateConfig.js index fa1fb6a..ca7bb8c 100644 --- a/Finicky/Finicky/validateConfig.js +++ b/Finicky/Finicky/validateConfig.js @@ -1,43 +1,46 @@ (function() { const { validate, getErrors } = fastidious; + const browserSchema = validate.oneOf([ + validate.string, + validate.shape({ + name: validate.string.isRequired, + appType: validate.oneOf(["appName", "bundleId"]), + openInBackground: validate.boolean + }), + validate.function("options") + ]); + + const matchSchema = validate.oneOf([ + validate.string, + validate.function("options"), + validate.regex, + validate.arrayOf( + validate.oneOf([ + validate.string, + validate.function("options"), + validate.regex + ]) + ) + ]); + const schema = { - defaultBrowser: validate.string.isRequired, + defaultBrowser: browserSchema.isRequired, options: validate.shape({ hideIcon: validate.boolean, urlShorteners: validate.arrayOf(validate.string) }), rewrite: validate.arrayOf( validate.shape({ - match: validate.oneOf([ - validate.string, - validate.function, - validate.regex, - validate.arrayOf( - validate.oneOf([validate.string, validate.function, validate.regex]) - ) - ]).isRequired, - url: validate.oneOf([validate.string, validate.function]).isRequired + match: matchSchema.isRequired, + url: validate.oneOf([validate.string, validate.function("options")]) + .isRequired }).isRequired ), handlers: validate.arrayOf( validate.shape({ - match: validate.oneOf([ - validate.string, - validate.function, - validate.regex, - validate.arrayOf( - validate.oneOf([validate.string, validate.function, validate.regex]) - ) - ]).isRequired, - app: validate.oneOf([ - validate.string, - validate.shape({ - name: validate.string.isRequired, - appType: validate.oneOf(["appName", "bundleId"]), - openInBackground: validate.boolean - }) - ]).isRequired + match: matchSchema.isRequired, + browser: browserSchema.isRequired }) ) }; diff --git a/Finicky/FinickyTests/ConfigAppResult.swift b/Finicky/FinickyTests/ConfigAppResult.swift index b97b7e3..b8fc433 100644 --- a/Finicky/FinickyTests/ConfigAppResult.swift +++ b/Finicky/FinickyTests/ConfigAppResult.swift @@ -13,7 +13,7 @@ class ConfigAppResultTests: XCTestCase { override func setUp() { super.setUp() configLoader = FinickyConfig() - _ = configLoader.createContext() + configLoader.createContext() } override func tearDown() { @@ -21,80 +21,80 @@ class ConfigAppResultTests: XCTestCase { } func testStringResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "\"Test Success\"")) + configLoader.parseConfig(generateHandlerConfig(browser: "\"Test Success\"")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test Success") XCTAssertEqual(result!.appType, AppDescriptorType.appName) } func testObjectResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'Test Success' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'Test Success' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test Success") XCTAssertEqual(result!.appType, AppDescriptorType.appName) } func testStringBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "'test.success'")) + configLoader.parseConfig(generateHandlerConfig(browser: "'test.success'")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "test.success") XCTAssertEqual(result!.appType, AppDescriptorType.bundleId) } func testObjectBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "test.success") XCTAssertEqual(result!.appType, AppDescriptorType.bundleId) } func testObjectFixedTypeResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success', appType: 'appName' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success', appType: 'appName' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.appType, AppDescriptorType.appName) } func testObjectFixedTypeBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success', appType: 'bundleId' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success', appType: 'bundleId' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.appType, AppDescriptorType.bundleId) } func testObjectIncorrectObjectResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ wrong: 'test.success', bad: 'bundleId' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ wrong: 'test.success', bad: 'bundleId' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertNil(result) } func testObjectNoOpenInBackgroundBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success' }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success' }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertNil(result!.openInBackground) } func testObjectOpenInBackgroundBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success', openInBackground: true }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success', openInBackground: true }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.openInBackground, true) } func testObjectDisableOpenInBackgroundBundleIdResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "{ name: 'test.success', openInBackground: false }")) + configLoader.parseConfig(generateHandlerConfig(browser: "{ name: 'test.success', openInBackground: false }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.openInBackground, false) } func testFunctionResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "(url, options) => { return { name: 'test.success', appType: 'bundleId' }}")) + configLoader.parseConfig(generateHandlerConfig(browser: "(options) => { return { name: 'test.success', appType: 'bundleId' }}")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "test.success") XCTAssertEqual(result!.appType, AppDescriptorType.bundleId) } func testFunctionOptionsResult() { - _ = configLoader.parseConfig(generateHandlerConfig(app: "(url, options) => { return { name: options.url.protocol, appType: 'bundleId' }}")) + configLoader.parseConfig(generateHandlerConfig(browser: "(options) => { return { name: options.url.protocol, appType: 'bundleId' }}")) let result = configLoader.determineOpeningApp(url: exampleUrl) - XCTAssertEqual(result!.name, "test.success") + XCTAssertEqual(result!.name, "http") XCTAssertEqual(result!.appType, AppDescriptorType.bundleId) } diff --git a/Finicky/FinickyTests/ConfigHandlers.swift b/Finicky/FinickyTests/ConfigHandlers.swift index 569ba94..3225bd9 100644 --- a/Finicky/FinickyTests/ConfigHandlers.swift +++ b/Finicky/FinickyTests/ConfigHandlers.swift @@ -12,52 +12,52 @@ class ConfigHandlerTests: XCTestCase { override func setUp() { super.setUp() configLoader = FinickyConfig() - _ = configLoader.createContext() + configLoader.createContext() } override func tearDown() { super.tearDown() } func testStringMatch() { - _ = configLoader.parseConfig(generateHandlerConfig(match: "'http://example.com'")) + configLoader.parseConfig(generateHandlerConfig(match: "'http://example.com'")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test config") } func testRegexMatcher() { - _ = configLoader.parseConfig(generateHandlerConfig(match: "/http:\\/\\/example\\.com/")) + configLoader.parseConfig(generateHandlerConfig(match: "/http:\\/\\/example\\.com/")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test config") } func testFunctionMatcher() { - _ = configLoader.parseConfig(generateHandlerConfig(match: "(url) => url === 'http://example.com'")) + configLoader.parseConfig(generateHandlerConfig(match: "(options) => options.urlString === 'http://example.com'")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test config") } func testFunctionMatcherDomainMatcher() { - _ = configLoader.parseConfig(generateHandlerConfig(match: "finicky.matchDomains(['example.com'])")) + configLoader.parseConfig(generateHandlerConfig(match: "finicky.matchDomains(['example.com'])")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "Test config") } func testFunctionSourceBundleIdentifierMatcher() { - _ = configLoader.parseConfig(generateHandlerConfig(match: - "(url, options) => options.sourceBundleIdentifier === 'testBundleId'" + configLoader.parseConfig(generateHandlerConfig(match: + "(options) => options.sourceBundleIdentifier === 'testBundleId'" )) let result = configLoader.determineOpeningApp(url: exampleUrl, sourceBundleIdentifier: "testBundleId") XCTAssertEqual(result!.name, "Test config") } func testDefaultBrowser() { - _ = configLoader.parseConfig(generateHandlerConfig(defaultBrowser: "'defaultBrowser'", match: "() => false")) + configLoader.parseConfig(generateHandlerConfig(defaultBrowser: "'defaultBrowser'", match: "() => false")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.name, "defaultBrowser") } func testInvalidConfig() { - _ = configLoader.parseConfig("!!! gibberish broken !!!") + configLoader.parseConfig("!!! gibberish broken !!!") let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertNil(result) } diff --git a/Finicky/FinickyTests/RewriteURL.swift b/Finicky/FinickyTests/RewriteURL.swift index d35257b..c71bd83 100644 --- a/Finicky/FinickyTests/RewriteURL.swift +++ b/Finicky/FinickyTests/RewriteURL.swift @@ -33,13 +33,13 @@ class URLRewriteTests: XCTestCase { } func testRewriteFunctionArgs() { - _ = configLoader.parseConfig(generateRewriteConfig(url: "(url) => url + '?ok'")) + configLoader.parseConfig(generateRewriteConfig(url: "(options) => options.urlString + '?ok'")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.url, URL(string: "http://example.com?ok")) } func testRewriteFunctionArgs2() { - _ = configLoader.parseConfig(generateRewriteConfig(url: "(url, options) => url + '?' + options.url.protocol")) + configLoader.parseConfig(generateRewriteConfig(url: "(options) => { finicky.log(options); return options.urlString + '?' + options.url.protocol; }")) let result = configLoader.determineOpeningApp(url: exampleUrl) XCTAssertEqual(result!.url, URL(string: "http://example.com?http")) } diff --git a/Finicky/FinickyTests/Utilities.swift b/Finicky/FinickyTests/Utilities.swift index 5ab9a72..3ec4c45 100644 --- a/Finicky/FinickyTests/Utilities.swift +++ b/Finicky/FinickyTests/Utilities.swift @@ -9,18 +9,18 @@ func generateConfig(defaultBrowser : String = "net.kassett.defaultBrowser", hand """ } -func generateHandlerConfig(defaultBrowser : String = "'net.kassett.defaultBrowser'", match: String = "() => true", app: String = "'Test config'") -> String { +func generateHandlerConfig(defaultBrowser : String = "'net.kassett.defaultBrowser'", match: String = "() => true", browser: String = "'Test config'") -> String { try! validateScript(defaultBrowser) try! validateScript(match) - try! validateScript(app) - + try! validateScript(browser) + return """ module.exports = { defaultBrowser: \(defaultBrowser), handlers: [{ match: \(match), - app: \(app) + browser: \(browser) }] } """ @@ -45,7 +45,6 @@ func generateRewriteConfig(defaultBrowser : String = "'net.kassett.defaultBrowse """ } - enum ScriptEvaluationError: Error { case error(msg: String) } @@ -57,8 +56,8 @@ func validateScript(_ script: String) throws -> Void{ context, exception in error = "\"" + String(describing: exception!) + "\" script: " + script } - _ = context.evaluateScript("const finicky = { log() {}, matchDomains() {} }") - _ = context.evaluateScript("const x = " + script) + context.evaluateScript("const finicky = { log() {}, matchDomains() {} }") + context.evaluateScript("const x = " + script) guard error == nil else { diff --git a/LICENSE b/LICENSE index dfe25d9..dc865b3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 John Sterling +Copyright (c) 2015-2019 John Sterling Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6b66c60..056e2e3 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,168 @@ -# Finicky ![Finicky logo](https://raw.githubusercontent.com/johnste/finicky/master/Finicky/Finicky/statusitem%402x.png) +
+

Finickyfinicky logo - hand pointing downwards +

+ +

Always open the right browser

+ +
+ +Finicky is an Mac OS application that allows you to set up rules that decide which browser is opened for every link. Open Facebook or Reddit in one browser, and Trello or LinkedIn in another. + +- Write rules to open urls in any browser +- Rewrite and replace parts of urls before opening them + +[![GitHub downloads](https://img.shields.io/github/downloads/johnste/finicky/total.svg?style=flat-square)](https://GitHub.com/johnste/finicky/releases/) +[![GitHub release](https://img.shields.io/github/release-pre/johnste/finicky.svg?style=flat-square)](https://GitHub.com/johnste/finicky/releases/) + + +## Table of Contents + + + + + +- [Installation](#installation) +- [Example configuration](#example-configuration) + - [Basic configuration](#basic-configuration) + - [Rewrite urls](#rewrite-urls) + - [Advanced usage, settings](#advanced-usage-settings) +- [API Reference](#api-reference) +- [Issues](#issues) + - [Bugs](#bugs) + - [Feature Requests](#feature-requests) +- [Questions](#questions) +- [License](#license) +- [Building from source](#building-from-source) + + + +## Installation + +1. Install Finicky: + +- Download [the latest release](https://github.com/johnste/finicky/releases), unzip and put `Finicky.app` in your application folder. +- Alternatively, you can install with [homebrew-cask](https://github.com/caskroom/homebrew-cask): `brew cask install finicky`. + +2. Create a file called `.finicky.js` with configuration + ([examples](#example-configuration)) in your home directory. +3. Start Finicky. Please allow it to be set as the default browser. +4. And you're done. All links clicked that would have opened your browser are now first handled by Finicky. + +## Example configuration + +### Basic configuration + +```js +module.exports = { + defaultBrowser: "Google Chrome", + handlers: [{ + // Open apple.com and example.org urls in Safari + match: finicky.matchDomains(["apple.com", "example.org"]), + browser: "Safari" + }, { + // Open any url including the string "workplace" in Firefox + match: /workplace/, + browser: "Firefox" + }]; +} +``` + +### Rewrite urls + +```js +module.exports = { + defaultBrowser: "Google Chrome", + rewrite: [ + { + // Redirect all urls to use https + match: ({ url }) => url.protocol === "http", + url: ({ url }) => ({ + ...url, + protocol: "https" + }) + }, + { + // Avoid being rickrolled + match: [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=oHg5SJYRHA0" + ], + url: "about:blank" + } + ] +}; +``` + +### Advanced usage, settings + +```js +module.exports = { + defaultBrowser: "Google Chrome", + options: { + // Hide the finicky icon from the top bar + hideIcon: true + }, + handlers: [ + { + // Open any link clicked in Slack in Safari + match: ({ sourceBundleIdentifier }) => + sourceBundleIdentifier === "com.tinyspeck.chatlyio", + browser: "Safari" + }, + { + match: ["http://zombo.com"], + browser: { + name: "Google Chrome Canary", + // Force opening the link in the background + openInBackground: true + } + } + ] +}; +``` + +## API Reference + +- [Configuration Reference](https://johnste.github.io/finicky-docs/modules/_finickyconfig_.html) +- [API Reference](https://johnste.github.io/finicky-docs/modules/_finickyapi_.html) + +## Issues + +### Bugs -*Always open the right browser* +Please file an issue for bugs, missing documentation, or unexpected behavior. -Finicky is an OS X application that allows you to set up rules that decide which browser is opened for every link that would open the default browser. Open Facebook or Reddit in one browser, and Trello or LinkedIn in another. Or Spotify links in the Spotify client. Or whatever url in whatever app. +[**See Bugs**](https://github.com/johnste/finicky/issues?q=is%3aopen+is%3aissue+label%3abug) -Features include: -- Url rewriting -- Opening links in background -- Opening links in the currently active browser -- Resolving short urls before running them through handlers +### Feature Requests -#### Install +Please file an issue to suggest new features. Vote on feature requests by adding +a 👍. -1. Download [the latest release](https://github.com/johnste/finicky/releases), unzip and drop Finicky.app in your application folder. Alternatively, if you have [homebrew-cask](https://github.com/caskroom/homebrew-cask) available, install with `brew cask install finicky`. -2. Create a file called `.finicky.js` in your home directory and set a default browser and one or more url handlers. -3. Start finicky. Please allow it to be set as the default browser. -4. And you're done. All http and https links clicked that would have opened a link in your default browser are now first handled by Finicky. +[**See Feature Requests**](https://github.com/johnste/finicky/labels/feature%20request) -#### Building from source -Install XCode and XCode command line tools, then from a terminal: +## Questions +Have any other questions or need help? Please feel free to reach out to me on [Twitter](https://twitter.com/johnste_). + +## License + +[MIT](https://raw.githubusercontent.com/johnste/finicky/master/LICENSE) + +## Building from source + +Install XCode and XCode command line tools and then run commands: + +```shell git clone https://github.com/johnste/finicky.git cd finicky/Finicky xcodebuild - -When complete you'll find a freshly built **Finicky** app in -`build/release`. - -#### Documentation -- [Javascript API Documentation](https://github.com/johnste/finicky/wiki#javascript-api) - -#### Example configuration -```javascript - -finicky.setDefaultBrowser('com.google.Chrome'); - -// Open social network links in Google Chrome -finicky.onUrl(function(url, opts) { - if (url.match(/^https?:\/\/(youtube|facebook|twitter|linkedin)\.com/)) { - return { - bundleIdentifier: "com.google.Chrome" - }; - } -}); - -// Open Spotify links in client -finicky.onUrl(function(url, opts) { - if (url.match(/^https?:\/\/open\.spotify\.com/)) { - return { - bundleIdentifier: "com.spotify.client" - }; - } -}); - -// Rewrite all Bing links to DuckDuckGo instead -finicky.onUrl(function(url, opts) { - var url = url.replace( - /^https?:\/\/www\.bing\.com\/search/, - "https://duckduckgo.com" - ); - return { - url: url - }; -}); - -// Always open links from Mail in Safari -finicky.onUrl(function(url, opts) { - var sourceApplication = opts && opts.sourceBundleIdentifier; - if (sourceApplication === "com.apple.mail") { - return { - bundleIdentifier: "com.apple.safari" - }; - } -}); - -// By supplying an array of bundle identifiers, finicky opens the url in the first one -// that's currently running. If none are running, the first app in the array is started. -finicky.onUrl(function(url, opts) { - return { - bundleIdentifier: [ - "org.mozilla.firefox", - "com.apple.Safari", - "com.google.Chrome" - ] - }; -}); - ``` -#### A note on the current status of finicky -Finicky is, as of October 2018, under active development. I plan to have a larger release out sometime in 2019, with focus on making it simpler to configure. Feel free to leave bug reports and feature requests, so please go ahead and add an issue on github, or ask me on [twitter](https://twitter.com/johnste_) +When complete you'll find a freshly built **Finicky** app in +`build/release`. \ No newline at end of file