├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── CNAME ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── ShortWebCore │ ├── Action.swift │ ├── ActionType.swift │ ├── AutomationDocument.swift │ ├── AutomationRunner.swift │ ├── AutomationRunnerDelegate.swift │ ├── WebView.swift │ ├── WebViewManager.swift │ └── index.js ├── _config.yml ├── _includes ├── appstoreimages.html ├── features.html ├── footer.html ├── head.html ├── header.html └── screencontent.html ├── _layouts └── default.html ├── _sass ├── base.scss └── layout.scss ├── assets ├── appstore.png ├── black.png ├── blue.png ├── coral.png ├── headerimage.png ├── playstore.png ├── screenshot │ └── Screenshot.png ├── squircle.svg ├── squircle120.svg ├── videos │ └── Place-video-files-here.txt ├── white.png └── yellow.png ├── automatic-app-landing-page_LICENSE ├── index.html └── main.scss /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | shortweb.app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Emma Labbé 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ShortWebCore", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "ShortWebCore", 15 | type: .dynamic, 16 | targets: ["ShortWebCore"] 17 | ) 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "ShortWebCore", 28 | dependencies: [], 29 | resources: [ 30 | .process("index.js") 31 | ] 32 | ), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShortWebCore 2 | 3 | This iOS library lets you run automations on Web Views. 4 | 5 | ## Example 6 | 7 | (Optional) Declare class conforming to `AutomationRunnerDelegate`: 8 | 9 | ```swift 10 | 11 | import ShortWebCore 12 | 13 | class Delegate: AutomationRunnerDelegate { 14 | 15 | func automationRunner(_ automationRunner: AutomationRunner, didGet result: Any, for action: Action, at index: Int) { 16 | 17 | print("Action \(index) returned \(result)") 18 | } 19 | 20 | func automationRunnerDidFinishRunning(_ automationRunner: AutomationRunner) { 21 | print("Automation did finish running") 22 | } 23 | 24 | func automationRunner(_ automationRunner: AutomationRunner, willExecute action: Action, at index: Int) { 25 | 26 | print("Will run action at \(index)") 27 | } 28 | } 29 | ``` 30 | 31 | Create an array of `Action`: 32 | 33 | ```swift 34 | 35 | let actions = [ 36 | Action(type: .openURL(URL(string: "https://www.google.com")!, false)), 37 | Action(type: .input("input[type=text]", "1 chf to clp")), 38 | Action(type: .click("input[type=submit][value='Google Search']")), 39 | Action(type: .urlChange), 40 | Action(type: .getResult("div[data-exchange-rate] > div:nth-of-type(2)")) 41 | ] 42 | ``` 43 | 44 | Actions take the path of an HTML element in the same format than `document.querySelector`. These are the type of actions: 45 | 46 | ```swift 47 | 48 | /// The type of an `Action`. 49 | public indirect enum ActionType : Codable { 50 | 51 | /// Click on the given HTML selector. 52 | case click(String) 53 | 54 | /// Input the given text on the given HTML selector. The first parameter is the HTML selector and the second parameter is the text to input. 55 | case input(String, String) 56 | 57 | /// Interact with an element in an iframe. The first parameter is the HTML selector of the iframe and the second one is the action to execute in the iframe. 58 | case iframe(String, ActionType) 59 | 60 | /// Get a string or an `UIImage` from the given HTML selector. 61 | case getResult(String) 62 | 63 | /// Wait until the web view finishes loading a new URL. 64 | case urlChange 65 | 66 | /// Upload a file with the given URL on the given input. 67 | case uploadFile(String, URL) 68 | 69 | /// Open the given URL. The second argument is `true` to open the URL in mobile mode. 70 | case openURL(URL, Bool) 71 | } 72 | ``` 73 | 74 | Next, create a `WebView` object and an `AutomationRunner` to run the actions (must be done in the main thread): 75 | 76 | ```swift 77 | let webView = ShortWebCore.WebView() 78 | 79 | let runner = AutomationRunner(actions: actions, webView: webView) 80 | let delegate = Delegate() 81 | runner.delegate = delegate 82 | runner.run { result in 83 | print(result) 84 | } 85 | ``` 86 | 87 | The `result` parameter in the closure is an array of items returned by `.getResult(_:)` actions. 88 | 89 | NOTE: The Web View should be in the view hierarchy to run correctly. 90 | 91 | ## Inspecting 92 | 93 | `WebView` contains an `inspect(_:)` function that can be used to inspect the HTML elements in it. It takes a block with a `WebViewManager` parameter that runs in a background thread. 94 | 95 | ```swift 96 | 97 | webView.inspect { manager in 98 | 99 | } 100 | ``` 101 | 102 | `WebViewManager` has the following methods: 103 | 104 | ```swift 105 | 106 | /// Checks if the HTML element at the given selector is a text box. 107 | /// 108 | /// - Parameters: 109 | /// - path: The selector of the element to check. 110 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 111 | /// 112 | /// - Returns: `true` if the given element takes text input, if not, `false`. 113 | public func isInput(_ path: String, onIframeAt iframePath: String? = nil) -> Bool 114 | 115 | /// Checks if the HTML element at the given selector is an input for uploading a file. 116 | /// 117 | /// - Parameters: 118 | /// - path: The selector of the element to check. 119 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 120 | /// 121 | /// - Returns: `true` if the given element takes a file as input, if not, `false`. 122 | public func isFileInput(_ path: String, onIframeAt iframePath: String? = nil) -> Bool 123 | 124 | /// Checks if the HTML element at the given selector is an iframe. 125 | /// 126 | /// - Parameters: 127 | /// - path: The selector of the element to check. 128 | /// 129 | /// - Returns: `true` if the given element is an iframe. 130 | public func isIframe(_ path: String) -> Bool 131 | 132 | /// Returns the location of the element at the given path. 133 | /// 134 | /// - Parameters: 135 | /// - path: The selector of the element to check. 136 | /// 137 | /// - Returns: The location of the element. 138 | public func location(ofElementAt path: String) -> CGPoint 139 | 140 | /// Get the HTML element at the given location in the given web view. 141 | /// 142 | /// - Parameters: 143 | /// 144 | /// - location: The location of the element. 145 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 146 | /// 147 | /// - Returns: The selector of the element or an empty string if it does not exist. 148 | public func element(at location: CGPoint, onIframeAt iframePath: String? = nil) -> String 149 | ``` 150 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/Action.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Action.swift 3 | // ShortWeb 4 | // 5 | // Created by Emma Labbé on 02-05-21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// An automatic action to be executed on a web view. 11 | public struct Action: Hashable, Codable { 12 | 13 | public var id = UUID() 14 | 15 | /// The type of the action. 16 | public var type: ActionType 17 | 18 | /// A timeout if the element does not exist. 19 | public var timeout: TimeInterval 20 | 21 | /// If set to `true`, the automation runner will ask for input before running the action instead of the input saved in the disk. 22 | public var askForValueEachTime = false 23 | 24 | /// Initialize an action with the given type. 25 | /// 26 | /// - Parameters: 27 | /// - type: The type of the action. 28 | /// - timeout: A timeout if the element does not exist. 29 | public init(type: ActionType, timeout: TimeInterval = 0) { 30 | self.type = type 31 | self.timeout = timeout 32 | } 33 | 34 | /// The JavaScript code to be executed. 35 | public var code: String { 36 | switch type { 37 | case .click(let path): 38 | return "click(document.querySelector('\(path)'))" 39 | case .input(let path, _): 40 | return "document.querySelector('\(path)').focus()" 41 | case .getResult(let path): 42 | return "getData(document.querySelector('\(path)'));" 43 | case .iframe(_, let actionType): 44 | switch actionType { 45 | case .input(let path, let input): 46 | return "input(document.querySelector('\(path)'), '\(input.data(using: .utf8)?.base64EncodedString() ?? "")')" 47 | default: 48 | return Action(type: actionType).code 49 | } 50 | case .uploadFile(let path, _): 51 | return Action(type: .click(path)).code 52 | default: 53 | return "" 54 | } 55 | } 56 | 57 | public var accessibilityLabel: Text { 58 | switch type { 59 | case .click(_): 60 | return Text("Click element") 61 | case .input(_, let input): 62 | return Text("Input '\(input)'") 63 | case .urlChange: 64 | return Text("URL Change") 65 | case .openURL(let url, _): 66 | return Text("Open \(url.absoluteString)") 67 | case .uploadFile(_, let url): 68 | return Text("Upload \(url.lastPathComponent)") 69 | case .iframe(_, let actionType): 70 | return Action(type: actionType).accessibilityLabel 71 | case .getResult(_): 72 | return Text("Get content") 73 | } 74 | } 75 | 76 | /// The description of the action as a SwiftUI text. 77 | public var description: Text { 78 | switch type { 79 | case .click(let path): 80 | return (Text("Click element at ") + Text(path).font(.footnote).fontWeight(.thin)) 81 | case .input(let path, let input): 82 | return Text("Type ") + Text(input).font(.footnote).fontWeight(.thin) + Text(" at ") + Text(path).font(.footnote).fontWeight(.thin) 83 | case .urlChange: 84 | return Text("URL change") 85 | case .openURL(let url, let mobile): 86 | return Text("Open ") + Text(url.absoluteString).font(.footnote).fontWeight(.thin) + Text(" (\(mobile ? "Mobile" : "Desktop"))") 87 | case .getResult(let path): 88 | return Text("Get content at ") + Text(path).font(.footnote).fontWeight(.thin) 89 | case .iframe(let path, let action): 90 | return Text("In iframe at ") + Text(path).font(.footnote).fontWeight(.thin) + Text(" ") + Action(type: action).description 91 | case .uploadFile(let path, let url): 92 | return Text("Upload ") + Text(url.lastPathComponent).font(.footnote).fontWeight(.thin) + Text(" at ") + Text(path).font(.footnote).fontWeight(.thin) 93 | } 94 | } 95 | // MARK: - Codable 96 | 97 | enum CodingKeys: CodingKey { 98 | case type 99 | case timeout 100 | case askEachTime 101 | } 102 | 103 | public func encode(to encoder: Encoder) throws { 104 | var container = encoder.container(keyedBy: CodingKeys.self) 105 | try container.encode(type, forKey: .type) 106 | try container.encode(timeout, forKey: .timeout) 107 | try container.encode(askForValueEachTime, forKey: .askEachTime) 108 | } 109 | 110 | public init(from decoder: Decoder) throws { 111 | let container = try decoder.container(keyedBy: CodingKeys.self) 112 | type = try container.decode(ActionType.self, forKey: .type) 113 | timeout = (try? container.decode(TimeInterval.self, forKey: .timeout)) ?? 0 114 | askForValueEachTime = (try? container.decode(Bool.self, forKey: .askEachTime)) ?? false 115 | } 116 | 117 | // MARK: - Hashable 118 | 119 | public static func == (lhs: Action, rhs: Action) -> Bool { 120 | return lhs.description == rhs.description && lhs.timeout == rhs.timeout && lhs.askForValueEachTime == rhs.askForValueEachTime 121 | } 122 | 123 | public func hash(into hasher: inout Hasher) { 124 | hasher.combine("\(description)") 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/ActionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionType.swift 3 | // ShortWebCore 4 | // 5 | // Created by Emma Labbé on 05-05-21. 6 | // 7 | 8 | import Foundation 9 | 10 | fileprivate struct CodableIframeAction: Codable { 11 | 12 | var path: String 13 | 14 | var action: ActionType 15 | } 16 | 17 | fileprivate struct OpenURL: Codable { 18 | 19 | var url: URL 20 | 21 | var mobile: Bool 22 | } 23 | 24 | /// The type of an `Action`. 25 | public indirect enum ActionType: Codable { 26 | 27 | /// Click on the given HTML selector. 28 | case click(String) 29 | 30 | /// Input the given text on the given HTML selector. The first parameter is the HTML selector and the second parameter is the text to input. 31 | case input(String, String) 32 | 33 | /// Interact with an element in an iframe. The first parameter is the HTML selector of the iframe and the second one is the action to execute in the iframe. 34 | case iframe(String, ActionType) 35 | 36 | /// Get a string or an `UIImage` from the given HTML selector. 37 | case getResult(String) 38 | 39 | /// Wait until the web view finishes loading a new URL. 40 | case urlChange 41 | 42 | /// Upload a file with the given URL on the given input. 43 | case uploadFile(String, URL) 44 | 45 | /// Open the given URL. The second argument is `true` to open the URL in mobile mode. 46 | case openURL(URL, Bool) 47 | 48 | enum CodingKeys: CodingKey { 49 | case click 50 | case input 51 | case urlChange 52 | case openURL 53 | case getResult 54 | case iframe 55 | case uploadFile 56 | } 57 | 58 | enum DecodingError: Error { 59 | case invalidKey 60 | } 61 | 62 | public init(from decoder: Decoder) throws { 63 | let container = try decoder.container(keyedBy: CodingKeys.self) 64 | 65 | if container.contains(.urlChange) { 66 | self = .urlChange 67 | } else if let path = try? container.decode(String.self, forKey: .click) { 68 | self = .click(path) 69 | } else if let values = try? container.decode([String].self, forKey: .input) { 70 | self = .input(values[0], values[1]) 71 | } else if let url = try? container.decode(URL.self, forKey: .openURL) { 72 | self = .openURL(url, false) 73 | } else if let url = try? container.decode(OpenURL.self, forKey: .openURL) { 74 | self = .openURL(url.url, url.mobile) 75 | } else if let path = try? container.decode(String.self, forKey: .getResult) { 76 | self = .getResult(path) 77 | } else if let iframe = try? container.decode(CodableIframeAction.self, forKey: .iframe) { 78 | self = .iframe(iframe.path, iframe.action) 79 | } else if let uploadFile = try? container.decode([String].self, forKey: .uploadFile) { 80 | 81 | let path = uploadFile[0] 82 | let data = Data(base64Encoded: uploadFile[1]) ?? Data() 83 | 84 | do { 85 | var isStale = false 86 | self = .uploadFile(path, try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale)) 87 | } catch { 88 | print(error.localizedDescription) 89 | 90 | self = .uploadFile(path, URL(fileURLWithPath: "/file")) 91 | } 92 | } else { 93 | throw DecodingError.invalidKey 94 | } 95 | } 96 | 97 | public func encode(to encoder: Encoder) throws { 98 | var container = encoder.container(keyedBy: CodingKeys.self) 99 | switch self { 100 | case .click(let path): 101 | try container.encode(path, forKey: .click) 102 | case .input(let path, let input): 103 | try container.encode([path, input], forKey: .input) 104 | case .urlChange: 105 | try container.encode(true, forKey: .urlChange) 106 | case .openURL(let url, let mobile): 107 | try container.encode(OpenURL(url: url, mobile: mobile), forKey: .openURL) 108 | case .getResult(let path): 109 | try container.encode(path, forKey: .getResult) 110 | case .iframe(let path, let type): 111 | try container.encode(CodableIframeAction(path: path, action: type), forKey: .iframe) 112 | case .uploadFile(let path, let url): 113 | guard let data = try? url.bookmarkData() else { 114 | try container.encode([path, Data().base64EncodedString()], forKey: .uploadFile) 115 | return 116 | } 117 | 118 | try container.encode([path, data.base64EncodedString()], forKey: .uploadFile) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/AutomationDocument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomationDocument.swift 3 | // ShortWeb 4 | // 5 | // Created by Emma Labbé on 03-05-21. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A document contained a list of actions. 11 | public class AutomationDocument: UIDocument, Identifiable { 12 | 13 | /// The actions contained in the document. 14 | public var actions = [Action]() 15 | 16 | public override func contents(forType typeName: String) throws -> Any { 17 | return try PropertyListEncoder().encode(actions) 18 | } 19 | 20 | public override func load(fromContents contents: Any, ofType typeName: String?) throws { 21 | if let data = contents as? Data { 22 | actions = try PropertyListDecoder().decode([Action].self, from: data) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/AutomationRunner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomationRunner.swift 3 | // ShortWeb 4 | // 5 | // Created by Emma Labbé on 03-05-21. 6 | // 7 | 8 | import WebKit 9 | import UserNotifications 10 | import UniformTypeIdentifiers 11 | 12 | /// An `AutomationRunner` manages the execution of its assigned actions in a web view. 13 | public class AutomationRunner: NSObject { 14 | 15 | /// The actions to execute. 16 | public var actions: [Action] 17 | 18 | /// The web view in which the actions will be executed. 19 | public var webView: WKWebView 20 | 21 | /// The object that will get notified on the state of the executed. 22 | public var delegate: AutomationRunnerDelegate? 23 | 24 | /// The results from the `ActionType.getResult(_)` actions. 25 | public var results = [Any]() 26 | 27 | internal var semaphore: DispatchSemaphore? 28 | 29 | internal var iframeSemaphore: DispatchSemaphore? 30 | 31 | private var searchingForPath: String? 32 | 33 | private var previousDelegate: WKNavigationDelegate? 34 | 35 | private var completion: (([Any]) -> Void)? 36 | 37 | private var _stop = false 38 | 39 | private var webViewWasVisible = true 40 | 41 | private var backgroundTask: UIBackgroundTaskIdentifier? 42 | 43 | internal var isRunning = false 44 | 45 | private var inputCompletion: ((String) -> Void)? 46 | 47 | /// Initializes the automation runner with the given actions and web view. 48 | /// 49 | /// - Parameters: 50 | /// - actions: The actions to be executed. 51 | /// - webView: The web view in which the actions will be executed. 52 | public init(actions: [Action], webView: WebView) { 53 | self.actions = actions 54 | self.webView = webView 55 | super.init() 56 | webView.didFinishNavigation = { _ in 57 | self.semaphore?.signal() 58 | } 59 | } 60 | 61 | private func evaluateSynchronously(_ script: String, iframe: IFrame? = nil) -> Any? { 62 | let semaphore = DispatchSemaphore(value: 0) 63 | 64 | var result: Any? 65 | 66 | DispatchQueue.main.async { 67 | if let iframe = iframe { 68 | self.webView.evaluateJavaScript(script, in: iframe.frameInfo, in: iframe.contentWorld) { res in 69 | switch res { 70 | case .success(let res): 71 | result = res 72 | default: 73 | break 74 | } 75 | 76 | semaphore.signal() 77 | } 78 | } else { 79 | self.webView.evaluateJavaScript(script) { res, _ in 80 | result = res 81 | semaphore.signal() 82 | } 83 | } 84 | } 85 | 86 | semaphore.wait() 87 | 88 | return result 89 | } 90 | 91 | /// Stops the execution of the actions. 92 | public func stop() { 93 | _stop = true 94 | isRunning = false 95 | inputCompletion?("") 96 | inputCompletion = nil 97 | DispatchQueue.main.async { 98 | if self.webView.isLoading { 99 | self.webView.stopLoading() 100 | } 101 | 102 | self.webView.load(URLRequest(url: URL(string: "about:blank")!)) 103 | 104 | self.semaphore?.signal() 105 | } 106 | } 107 | 108 | private var waitingAction: Action? 109 | 110 | private var waitingFrame: IFrame? 111 | 112 | private func executeAction(at index: Int, frame: IFrame? = nil, customAction: Action? = nil) { 113 | guard actions.indices.contains(index) && !_stop else { 114 | 115 | print("Ended with results: \(results)") 116 | 117 | self.completion?(self.results) 118 | DispatchQueue.main.async { 119 | self.completion = nil 120 | self._stop = false 121 | self.isRunning = false 122 | 123 | if !self.webViewWasVisible { 124 | self.webView.removeFromSuperview() 125 | } 126 | 127 | self.webViewWasVisible = true 128 | (self.webView as? WebView)?.automationRunner = nil 129 | 130 | if let task = self.backgroundTask { 131 | (UIApplication.perform(NSSelectorFromString("sharedApplication")).takeUnretainedValue() as? UIApplication)?.endBackgroundTask(task) 132 | } 133 | 134 | self.delegate?.automationRunnerDidFinishRunning(self) 135 | } 136 | return 137 | } 138 | 139 | let action = customAction ?? actions[index] 140 | 141 | switch action.type { 142 | case .iframe(_, _): 143 | break 144 | default: 145 | DispatchQueue.main.async { 146 | self.delegate?.automationRunner(self, willExecute: action, at: index) 147 | } 148 | } 149 | 150 | switch action.type { 151 | case .openURL(let url, let mobile): // Open the URL and wait 152 | semaphore = DispatchSemaphore(value: 0) 153 | DispatchQueue.main.async { 154 | (self.webView as? WebView)?.setContentMode(mobile: mobile) 155 | self.webView.load(URLRequest(url: url)) 156 | } 157 | semaphore?.wait() 158 | executeAction(at: index+1) 159 | case .urlChange: // Just wait till a new URL is loaded 160 | semaphore = DispatchSemaphore(value: 0) 161 | semaphore?.wait() 162 | executeAction(at: index+1) 163 | case .click(let path), .input(let path, _), .getResult(let path), .uploadFile(let path, _), .iframe(let path, _): 164 | 165 | if evaluateSynchronously("document.querySelector('\(path)') == null", iframe: frame) as? Bool == true { // Doesn't exist, wait 166 | 167 | waitingAction = action 168 | waitingFrame = frame 169 | 170 | var finishedBecauseOfTheTimeout = false 171 | 172 | if action.timeout > 0 { 173 | DispatchQueue.global().asyncAfter(deadline: .now()+action.timeout) { 174 | 175 | guard self.waitingAction == action else { 176 | return 177 | } 178 | 179 | finishedBecauseOfTheTimeout = true 180 | self.semaphore?.signal() 181 | } 182 | } 183 | 184 | semaphore = DispatchSemaphore(value: 0) 185 | semaphore?.wait() 186 | 187 | waitingAction = nil 188 | 189 | if finishedBecauseOfTheTimeout { 190 | return self.executeAction(at: index+1) 191 | } 192 | 193 | self.executeAction(at: index) 194 | 195 | waitingFrame = nil 196 | 197 | } else { // Exists 198 | 199 | switch action.type { 200 | case .iframe(let iframePath, let actionType): 201 | (webView as? WebView)?.inspect({ manager in 202 | return self.executeAction(at: index, frame: manager.frame(for: iframePath), customAction: Action(type: actionType)) 203 | }) 204 | return 205 | case .input(let path, _): 206 | if action.askForValueEachTime { 207 | func didProvideInput(_ input: String) { 208 | queue.async { 209 | self.executeAction(at: index, frame: frame, customAction: Action(type: .input(path, input), timeout: action.timeout)) 210 | } 211 | self.inputCompletion = nil 212 | } 213 | 214 | self.inputCompletion = didProvideInput 215 | 216 | DispatchQueue.main.async { 217 | self.delegate?.automationRunner(self, shouldProvideInput: didProvideInput, for: action, at: index) 218 | } 219 | return 220 | } 221 | default: 222 | break 223 | } 224 | 225 | if index > 0 { 226 | Thread.sleep(forTimeInterval: 1) 227 | } 228 | 229 | switch actions[index].type { 230 | case .getResult(_): // Wait till src != undefined 231 | if path.hasSuffix("> img") && evaluateSynchronously("isSrcUndefined(document.querySelector('\(path)'))", iframe: frame) as? Bool == true { 232 | return queue.asyncAfter(deadline: .now()+0.5, execute: { 233 | self.executeAction(at: index) 234 | }) 235 | } 236 | default: 237 | break 238 | } 239 | 240 | (self.webView as? WebView)?.inspect({ manager in 241 | let frame = frame ?? manager.mainFrame 242 | DispatchQueue.main.async { 243 | var code = action.code 244 | if !frame.frameInfo.isMainFrame { 245 | switch action.type { 246 | case .input(_, _): 247 | code = Action(type: .iframe("", action.type)).code 248 | default: 249 | break 250 | } 251 | } 252 | self.webView.evaluateJavaScript(code, in: frame.frameInfo, in: frame.contentWorld) { result in 253 | 254 | switch result { 255 | case .success(let result): 256 | switch action.type { 257 | case .input(_, let input): 258 | if frame.frameInfo.isMainFrame { 259 | DispatchQueue.main.async { 260 | ((self.webView.value(forKey: "_contentView") as? NSValue)?.nonretainedObjectValue as? UITextInput)?.insertText(input) 261 | } 262 | } 263 | case .uploadFile(_, let url): 264 | if self.webView.window == nil { 265 | NSLog("The web view currently running an automation is not in the view hierarchy and a file upload dialog was presented. The file upload could not be automatized.") 266 | } else if !action.askForValueEachTime { 267 | DispatchQueue.main.asyncAfter(deadline: .now()+0.5) { 268 | 269 | var presented = self.webView.window?.rootViewController 270 | 271 | while true { 272 | 273 | presented = presented?.presentedViewController 274 | 275 | // _UIContextMenuActionsOnlyViewController 276 | if presented != nil, type(of: presented!) == NSClassFromString(String(data: Data(base64Encoded: "X1VJQ29udGV4dE1lbnVBY3Rpb25zT25seVZpZXdDb250cm9sbGVy")!, encoding: .utf8)!) { 277 | break 278 | } 279 | 280 | if presented == nil { 281 | break 282 | } 283 | } 284 | 285 | // _contentView,_fileUploadPanel 286 | guard let panel = (((self.webView.value(forKey: String(data: Data(base64Encoded: "X2NvbnRlbnRWaWV3")!, encoding: .utf8)!) as? NSValue)?.nonretainedObjectValue as? NSObject)?.value(forKey: String(data: Data(base64Encoded: "X2ZpbGVVcGxvYWRQYW5lbA==")!, encoding: .utf8)!) as? NSValue)?.nonretainedObjectValue as? UIDocumentPickerDelegate else { 287 | return 288 | } 289 | 290 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType("public.item")!]) 291 | panel.documentPicker?(picker, didPickDocumentsAt: [url]) 292 | 293 | presented?.dismiss(animated: true, completion: nil) 294 | } 295 | } 296 | case .getResult(_): // Append the result 297 | 298 | if let result = result as? String, result.hasPrefix("data:image/") || result.hasPrefix("http:") || result.hasPrefix("https:"), let url = URL(string: result), let data = try? Data(contentsOf: url), let image = UIImage(data: data) { 299 | 300 | self.results.append(image) 301 | self.delegate?.automationRunner(self, didGet: image, for: action, at: index) 302 | } else { 303 | self.results.append(result) 304 | self.delegate?.automationRunner(self, didGet: result, for: self.actions[index], at: index) 305 | } 306 | default: 307 | break 308 | } 309 | case .failure(let error): 310 | print(error.localizedDescription) 311 | } 312 | 313 | DispatchQueue.global().async { 314 | self.executeAction(at: index+1) 315 | } 316 | } 317 | } 318 | }) 319 | } 320 | } 321 | } 322 | 323 | let queue = DispatchQueue.global() 324 | 325 | /// Runs the receiver's actions. Will throw an error if not called from the main thread. 326 | /// 327 | /// - Parameters: 328 | /// - completion: A block called when the execution is finished. Takes the results from `ActionType.getResult(_)` actions as parameter. 329 | public func run(completion: @escaping (([Any]) -> Void)) { 330 | guard Thread.current.isMainThread else { 331 | fatalError("`AutomationRunner.run` must be called from the main thread") 332 | } 333 | 334 | guard actions.count > 0 else { 335 | return 336 | } 337 | 338 | let app = UIApplication.perform(NSSelectorFromString("sharedApplication"))?.takeUnretainedValue() as? UIApplication 339 | 340 | if webView.superview == nil && webView.window == nil { 341 | webViewWasVisible = false 342 | webView.isHidden = true 343 | app?.windows.first?.addSubview(webView) 344 | } 345 | 346 | self.completion = completion 347 | 348 | (webView as? WebView)?.didReceiveMessage = { msg in 349 | self.didReceive(message: msg) 350 | } 351 | 352 | backgroundTask = app?.beginBackgroundTask(expirationHandler: nil) 353 | 354 | (self.webView as? WebView)?.automationRunner = self 355 | 356 | queue.async { 357 | self.isRunning = true 358 | self.executeAction(at: 0) 359 | } 360 | } 361 | 362 | func didReceive(message: WKScriptMessage) { 363 | 364 | if let str = message.body as? String, str == "DOM Change" { 365 | DispatchQueue.global().async { 366 | if let action = self.waitingAction { 367 | switch action.type { 368 | case .click(let path), .input(let path, _), .getResult(let path), .iframe(let path, _): 369 | if self.evaluateSynchronously("document.querySelector('\(path)') != null", iframe: self.waitingFrame) as? Bool == true { 370 | Thread.sleep(forTimeInterval: 0.5) 371 | self.semaphore?.signal() 372 | } 373 | default: 374 | self.semaphore?.signal() 375 | } 376 | } else { 377 | self.semaphore?.signal() 378 | } 379 | } 380 | } 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/AutomationRunnerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomationRunnerDelegate.swift 3 | // ShortWeb 4 | // 5 | // Created by Emma Labbé on 03-05-21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Functions to get notified about the execution state of an `AutomationRunner` object. 11 | public protocol AutomationRunnerDelegate { 12 | 13 | /// The given action will be executed. 14 | func automationRunner(_ automationRunner: AutomationRunner, willExecute action: Action, at index: Int) 15 | 16 | /// The given action produced the given output. 17 | func automationRunner(_ automationRunner: AutomationRunner, didGet result: Any, for action: Action, at index: Int) 18 | 19 | /// The automation runner needs input for the given text input action. 20 | func automationRunner(_ automationRunner: AutomationRunner, shouldProvideInput completionHandler: @escaping (String) -> Void, for action: Action, at index: Int) 21 | 22 | /// The automation finished running. 23 | func automationRunnerDidFinishRunning(_ automationRunner: AutomationRunner) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebView.swift 3 | // ShortWebCore 4 | // 5 | // Created by Emma Labbé on 27-05-21. 6 | // 7 | 8 | import WebKit 9 | 10 | /// A Web View that can perform automations. 11 | public class WebView: WKWebView { 12 | 13 | private let manager = WebViewManager() 14 | 15 | internal var automationRunner: AutomationRunner? 16 | 17 | /// A closure called when the web view finishes the given navigation. 18 | public var didFinishNavigation: ((WKNavigation) -> Void)? 19 | 20 | internal var didReceiveMessage: ((WKScriptMessage) -> Void)? 21 | 22 | let group = DispatchGroup() 23 | 24 | public override var inputView: UIView? { 25 | if automationRunner?.isRunning == true { 26 | return UIView() 27 | } else { 28 | return super.inputView 29 | } 30 | } 31 | 32 | // https://stackoverflow.com/a/52109021/7515957 33 | private func makeConfiguration() { 34 | 35 | //Need to reuse the same process pool to achieve cookie persistence 36 | let processPool: WKProcessPool 37 | 38 | if let pool: WKProcessPool = manager.getData(key: "pool") { 39 | processPool = pool 40 | } 41 | else { 42 | processPool = WKProcessPool() 43 | manager.setData(processPool, key: "pool") 44 | } 45 | 46 | configuration.processPool = processPool 47 | 48 | if let cookies: [HTTPCookie] = manager.getData(key: "cookies") { 49 | 50 | for cookie in cookies { 51 | group.enter() 52 | configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { 53 | print("Set cookie = \(cookie) with name = \(cookie.name)") 54 | self.group.leave() 55 | } 56 | } 57 | 58 | } 59 | } 60 | 61 | /// Sets the web view content mode to mobile or desktop. 62 | /// 63 | /// - Parameters: 64 | /// - mobile: `true` to display web pages in mobile mode. 65 | public func setContentMode(mobile: Bool) { 66 | if mobile { 67 | configuration.defaultWebpagePreferences.preferredContentMode = .mobile 68 | customUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1" 69 | frame.size = CGSize(width: 400, height: 1000) 70 | } else { 71 | configuration.defaultWebpagePreferences.preferredContentMode = .desktop 72 | customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15" 73 | frame.size = CGSize(width: 1500, height: 1000) 74 | } 75 | } 76 | 77 | private func configure() { 78 | frame.size = CGSize(width: 1500, height: 1000) 79 | 80 | isUserInteractionEnabled = false 81 | navigationDelegate = manager 82 | configuration.userContentController.add(manager, name: "ShortWeb") 83 | 84 | manager.webView = self 85 | 86 | configuration.userContentController.addUserScript(WKUserScript(source: """ 87 | window.webkit.messageHandlers.ShortWeb.postMessage("frame"); 88 | """, injectionTime: .atDocumentStart, forMainFrameOnly: false)) 89 | 90 | allowDisplayingKeyboardWithoutUserAction() 91 | 92 | makeConfiguration() 93 | } 94 | 95 | init() { 96 | super.init(frame: .zero, configuration: WKWebViewConfiguration()) 97 | 98 | configure() 99 | } 100 | 101 | public override init(frame: CGRect, configuration: WKWebViewConfiguration) { 102 | super.init(frame: frame, configuration: configuration) 103 | 104 | configure() 105 | } 106 | 107 | required init?(coder: NSCoder) { 108 | fatalError("init(coder:) has not been implemented") 109 | } 110 | 111 | /// Calls the given block with a `WebViewManager` obejct asynchronously in a background thread to inspect the Web View. 112 | /// 113 | /// - Parameters: 114 | /// - block: A closure that takes a `WebViewManager` object. Use it to inspect the content of the web view. 115 | public func inspect(_ block: @escaping ((WebViewManager) -> Void)) { 116 | DispatchQueue.global().async { 117 | block(self.manager) 118 | } 119 | } 120 | 121 | // MARK: - Keyboard 122 | 123 | typealias OldClosureType = @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Any?) -> Void 124 | typealias NewClosureType = @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void 125 | 126 | func allowDisplayingKeyboardWithoutUserAction() { 127 | guard let WKContentView: AnyClass = NSClassFromString("WKContentView") else { 128 | print("allowDisplayingKeyboardWithoutUserAction extension: Cannot find the WKContentView class") 129 | return 130 | } 131 | var selector: Selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:") 132 | if let method = class_getInstanceMethod(WKContentView, selector) { 133 | let originalImp: IMP = method_getImplementation(method) 134 | let original: NewClosureType = unsafeBitCast(originalImp, to: NewClosureType.self) 135 | let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3, arg4) in 136 | original(me, selector, arg0, true, arg2, arg3, arg4) 137 | } 138 | let override: IMP = imp_implementationWithBlock(block) 139 | 140 | method_setImplementation(method, override); 141 | } 142 | guard let WKContentViewAgain: AnyClass = NSClassFromString("WKApplicationStateTrackingView_CustomInputAccessoryView") else { 143 | print("allowDisplayingKeyboardWithoutUserAction extension: Cannot find the WKApplicationStateTrackingView_CustomInputAccessoryView class") 144 | return 145 | } 146 | selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:") 147 | if let method = class_getInstanceMethod(WKContentViewAgain, selector) { 148 | let originalImp: IMP = method_getImplementation(method) 149 | let original: NewClosureType = unsafeBitCast(originalImp, to: NewClosureType.self) 150 | let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3, arg4) in 151 | original(me, selector, arg0, true, arg2, arg3, arg4) 152 | } 153 | let override: IMP = imp_implementationWithBlock(block) 154 | 155 | method_setImplementation(method, override); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/WebViewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebViewManager.swift 3 | // ShortWeb 4 | // 5 | // Created by Emma Labbé on 04-05-21. 6 | // 7 | 8 | import WebKit 9 | 10 | /// A tuple containing values that can be passed to `WKWebView.evaluateJavaScript(_:in:in:)` to evaluate JS code in an iframe. 11 | public typealias IFrame = (frameInfo: WKFrameInfo, contentWorld: WKContentWorld) 12 | 13 | /// A class that contains methods to inspect the content of a Web View. 14 | public class WebViewManager: NSObject, WKNavigationDelegate, WKScriptMessageHandler { 15 | 16 | weak internal var webView: WKWebView? 17 | 18 | internal override init() { 19 | super.init() 20 | } 21 | 22 | var frames = [String : IFrame]() 23 | 24 | var mainFrame = (frameInfo: WKFrameInfo(), contentWorld: WKContentWorld.defaultClient) 25 | 26 | func setData(_ value: Any, key: String) { 27 | let ud = UserDefaults.standard 28 | let archivedPool = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: false) 29 | ud.set(archivedPool, forKey: key) 30 | } 31 | 32 | func getData(key: String) -> T? { 33 | let ud = UserDefaults.standard 34 | if let val = ud.value(forKey: key) as? Data, 35 | let obj = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(val) { 36 | return obj as? T 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // MARK: - Navigation delegate 43 | 44 | /*public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 45 | 46 | frames = [:] 47 | decisionHandler(.allow) 48 | }*/ 49 | 50 | public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 51 | 52 | webView.reloadInputViews() 53 | 54 | guard let jsURL = Bundle.module.url(forResource: "index", withExtension: "js") else { 55 | return 56 | } 57 | 58 | guard let js = try? String(contentsOf: jsURL) else { 59 | return 60 | } 61 | 62 | webView.evaluateJavaScript(js, completionHandler: nil) 63 | 64 | (webView as? WebView)?.didFinishNavigation?(navigation) 65 | 66 | webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in 67 | self.setData(cookies, key: "cookies") 68 | } 69 | } 70 | 71 | // MARK: - Script message handler 72 | 73 | public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 74 | 75 | (webView as? WebView)?.didReceiveMessage?(message) 76 | 77 | let frame = message.frameInfo 78 | if frame.isMainFrame { 79 | mainFrame = (frameInfo: frame, contentWorld: message.world) 80 | } else if (message.body as? String) == "frame", let url = frame.request.url?.absoluteString { 81 | 82 | frames[url] = (frameInfo: frame, contentWorld: message.world) 83 | 84 | guard let jsURL = Bundle.module.url(forResource: "index", withExtension: "js") else { 85 | return 86 | } 87 | 88 | guard let js = try? String(contentsOf: jsURL) else { 89 | return 90 | } 91 | 92 | webView?.evaluateJavaScript(js, in: frame, in: message.world, completionHandler: nil) 93 | } 94 | } 95 | 96 | // MARK: - JS 97 | 98 | /// Returns the web kit frame info object corresponding to the given iframe. 99 | /// 100 | /// - Parameters: 101 | /// - iframePath: The HTML selector of an iframe. 102 | /// 103 | /// - Returns: The frame info and the content world which urls corresponds to the iframe. Returns the main frame if no frame info is found. 104 | public func frame(for iframePath: String) -> IFrame { 105 | 106 | if Thread.current.isMainThread { 107 | fatalError("Cannot be called on the main thread") 108 | } 109 | 110 | let semaphore = DispatchSemaphore(value: 0) 111 | 112 | var result = mainFrame 113 | 114 | DispatchQueue.main.async { 115 | self.webView?.evaluateJavaScript("document.querySelector('\(iframePath)').src", completionHandler: { res, _ in 116 | if let res = res as? String { 117 | result = self.frames[res] ?? self.mainFrame 118 | } 119 | semaphore.signal() 120 | }) 121 | } 122 | 123 | semaphore.wait() 124 | 125 | if result.frameInfo.isMainFrame { 126 | Thread.sleep(forTimeInterval: 0.2) 127 | return frame(for: iframePath) 128 | } 129 | 130 | return result 131 | } 132 | 133 | /// Checks if the HTML element at the given selector is a text box. 134 | /// 135 | /// - Parameters: 136 | /// - path: The selector of the element to check. 137 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 138 | /// 139 | /// - Returns: `true` if the given element takes text input, if not, `false`. 140 | public func isInput(_ path: String, onIframeAt iframePath: String? = nil) -> Bool { 141 | 142 | if Thread.current.isMainThread { 143 | fatalError("Cannot be called on the main thread") 144 | } 145 | 146 | let semaphore = DispatchSemaphore(value: 0) 147 | 148 | var result = false 149 | 150 | var frame = mainFrame 151 | if let path = iframePath { 152 | frame = self.frame(for: path) 153 | } 154 | 155 | DispatchQueue.main.async { 156 | self.webView?.evaluateJavaScript(""" 157 | isInput(document.querySelector('\(path)')); 158 | """, in: frame.frameInfo, in: frame.contentWorld) { res in 159 | 160 | switch res { 161 | case .success(let res): 162 | result = ((res as? Bool) != nil && (res as! Bool)) 163 | semaphore.signal() 164 | default: 165 | break 166 | } 167 | } 168 | } 169 | 170 | semaphore.wait() 171 | 172 | return result 173 | } 174 | 175 | /// Checks if the HTML element at the given selector is an input for uploading a file. 176 | /// 177 | /// - Parameters: 178 | /// - path: The selector of the element to check. 179 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 180 | /// 181 | /// - Returns: `true` if the given element takes a file as input, if not, `false`. 182 | public func isFileInput(_ path: String, onIframeAt iframePath: String? = nil) -> Bool { 183 | 184 | if Thread.current.isMainThread { 185 | fatalError("Cannot be called on the main thread") 186 | } 187 | 188 | let semaphore = DispatchSemaphore(value: 0) 189 | 190 | var result = false 191 | 192 | var frame = mainFrame 193 | if let path = iframePath { 194 | frame = self.frame(for: path) 195 | } 196 | 197 | DispatchQueue.main.async { 198 | self.webView?.evaluateJavaScript(""" 199 | isFileInput(document.querySelector('\(path)')); 200 | """, in: frame.frameInfo, in: frame.contentWorld) { res in 201 | 202 | switch res { 203 | case .success(let res): 204 | result = ((res as? Bool) != nil && (res as! Bool)) 205 | semaphore.signal() 206 | default: 207 | break 208 | } 209 | } 210 | } 211 | 212 | semaphore.wait() 213 | 214 | return result 215 | } 216 | 217 | /// Checks if the HTML element at the given selector is an iframe. 218 | /// 219 | /// - Parameters: 220 | /// - path: The selector of the element to check. 221 | /// 222 | /// - Returns: `true` if the given element is an iframe. 223 | public func isIframe(_ path: String) -> Bool { 224 | 225 | if Thread.current.isMainThread { 226 | fatalError("Cannot be called on the main thread") 227 | } 228 | 229 | let semaphore = DispatchSemaphore(value: 0) 230 | 231 | var result = false 232 | 233 | DispatchQueue.main.async { 234 | self.webView?.evaluateJavaScript(""" 235 | (document.querySelector('\(path)') instanceof HTMLIFrameElement); 236 | """) { res, _ in 237 | 238 | result = ((res as? Bool) != nil && (res as! Bool)) 239 | semaphore.signal() 240 | } 241 | } 242 | 243 | semaphore.wait() 244 | 245 | return result 246 | } 247 | 248 | /// Returns the location of the element at the given path. 249 | /// 250 | /// - Parameters: 251 | /// - path: The selector of the element to check. 252 | /// 253 | /// - Returns: The location of the element. 254 | public func location(ofElementAt path: String) -> CGPoint { 255 | 256 | if Thread.current.isMainThread { 257 | fatalError("Cannot be called on the main thread") 258 | } 259 | 260 | let semaphore = DispatchSemaphore(value: 0) 261 | 262 | var result = CGPoint.zero 263 | 264 | DispatchQueue.main.async { 265 | self.webView?.evaluateJavaScript(""" 266 | getOffsetAsArray(document.querySelector('\(path)')); 267 | """) { res, _ in 268 | 269 | guard let array = res as? [Double] else { 270 | semaphore.signal() 271 | return 272 | } 273 | 274 | guard let x = array.first, let y = array.last else { 275 | semaphore.signal() 276 | return 277 | } 278 | 279 | result = CGPoint(x: x, y: y) 280 | 281 | semaphore.signal() 282 | } 283 | } 284 | 285 | semaphore.wait() 286 | 287 | return result 288 | } 289 | 290 | /// Get the HTML element at the given location in the given web view. 291 | /// 292 | /// - Parameters: 293 | /// 294 | /// - location: The location of the element. 295 | /// - iframePath: The HTML selector of an iframe if the element is located in there. 296 | /// 297 | /// - Returns: The selector of the element or an empty string if it does not exist. 298 | public func element(at location: CGPoint, onIframeAt iframePath: String? = nil) -> String { 299 | 300 | if Thread.current.isMainThread { 301 | fatalError("Cannot be called on the main thread") 302 | } 303 | 304 | let semaphore = DispatchSemaphore(value: 0) 305 | 306 | var result = "" 307 | 308 | var frame = mainFrame 309 | if let path = iframePath { 310 | frame = self.frame(for: path) 311 | } 312 | 313 | DispatchQueue.main.async { 314 | self.webView?.evaluateJavaScript(""" 315 | getDomPath(document.elementFromPoint(\(location.x), \(location.y))); 316 | """, in: frame.frameInfo, in: frame.contentWorld) { res in 317 | 318 | switch res { 319 | case .success(let res): 320 | result = res as? String ?? "" 321 | semaphore.signal() 322 | default: 323 | break 324 | } 325 | } 326 | } 327 | 328 | semaphore.wait() 329 | 330 | return result 331 | } 332 | 333 | } 334 | -------------------------------------------------------------------------------- /Sources/ShortWebCore/index.js: -------------------------------------------------------------------------------- 1 | var meta = document.createElement('meta'); 2 | meta.name = 'viewport'; 3 | meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; 4 | var head = document.getElementsByTagName('head')[0]; 5 | head.appendChild(meta); 6 | 7 | function click(element) { 8 | var ev = new MouseEvent('click', { 9 | 'view': window, 10 | 'bubbles': true, 11 | 'cancelable': true 12 | }); 13 | 14 | element.dispatchEvent(ev); 15 | } 16 | 17 | function isInput(element) { 18 | let nodeName = element.tagName.toLowerCase(); 19 | 20 | if (element.nodeType == 1 && (nodeName == "textarea" || (nodeName == "input" && /^(?:text|email|number|search|tel|url|password)$/i.test(element.type))) || element.contentEditable == "true") { 21 | 22 | return true; 23 | } else { 24 | return false; 25 | } 26 | } 27 | 28 | function isFileInput(element) { 29 | return (element.tagName.toLowerCase() == "input" && element.type == "file"); 30 | } 31 | 32 | function input(element, text) { 33 | let decoded = decodeURIComponent(escape(window.atob(text))); 34 | let event = new InputEvent('input', {bubbles: true}); 35 | element.textContent = decoded; 36 | element.value = decoded; 37 | setTimeout(function() { 38 | element.dispatchEvent(event); 39 | }, (1 * 1000)); 40 | } 41 | 42 | var getDataUrl = function (img) { 43 | var canvas = document.createElement('canvas'); 44 | var ctx = canvas.getContext('2d'); 45 | 46 | canvas.width = img.width; 47 | canvas.height = img.height; 48 | ctx.drawImage(img, 0, 0); 49 | 50 | // If the image is not png, the format 51 | // must be specified here 52 | return canvas.toDataURL(); 53 | } 54 | 55 | function getDomPath(el) { 56 | if (!el) { 57 | return; 58 | } 59 | var stack = []; 60 | var isShadow = false; 61 | while (el.parentNode != null) { 62 | // console.log(el.nodeName); 63 | var sibCount = 0; 64 | var sibIndex = 0; 65 | // get sibling indexes 66 | for ( var i = 0; i < el.parentNode.childNodes.length; i++ ) { 67 | var sib = el.parentNode.childNodes[i]; 68 | if ( sib.nodeName == el.nodeName ) { 69 | if ( sib === el ) { 70 | sibIndex = sibCount; 71 | } 72 | sibCount++; 73 | } 74 | } 75 | // if ( el.hasAttribute('id') && el.id != '' ) { no id shortcuts, ids are not unique in shadowDom 76 | // stack.unshift(el.nodeName.toLowerCase() + '#' + el.id); 77 | // } else 78 | var nodeName = el.nodeName.toLowerCase(); 79 | if (isShadow) { 80 | nodeName += "::shadow"; 81 | isShadow = false; 82 | } 83 | if ( sibCount > 1 ) { 84 | stack.unshift(nodeName + ':nth-of-type(' + (sibIndex + 1) + ')'); 85 | } else { 86 | stack.unshift(nodeName); 87 | } 88 | el = el.parentNode; 89 | if (el.nodeType === 11) { // for shadow dom, we 90 | isShadow = true; 91 | el = el.host; 92 | } 93 | } 94 | stack.splice(0,1); // removes the html element 95 | return stack.join(' > '); 96 | } 97 | 98 | function getData(element) { 99 | console.log("This is the src:"); 100 | console.log(element.src); 101 | if (element.src != undefined && element.src != "") { 102 | console.log("Will return src"); 103 | console.log(element.src) 104 | return element.src; 105 | } else { 106 | return element.innerText; 107 | } 108 | } 109 | 110 | function isSrcUndefined(element) { 111 | return (element.src == undefined || element.src == "") 112 | } 113 | 114 | function getOffset(el) { 115 | const rect = el.getBoundingClientRect(); 116 | return { 117 | left: rect.left + window.scrollX, 118 | top: rect.top + window.scrollY 119 | }; 120 | } 121 | 122 | function getOffsetAsArray(el) { 123 | return [getOffset(el).left, getOffset(el).top] 124 | } 125 | 126 | var observeDOM = (function(){ 127 | var MutationObserver = window.MutationObserver || window.WebKitMutationObserver; 128 | 129 | return function( obj, callback ){ 130 | if( !obj || obj.nodeType !== 1 ) return; 131 | 132 | if( MutationObserver ){ 133 | // define a new observer 134 | var mutationObserver = new MutationObserver(callback) 135 | 136 | // have the observer observe foo for changes in children 137 | mutationObserver.observe( obj, { childList:true, subtree:true }) 138 | return mutationObserver 139 | } 140 | 141 | // browser support fallback 142 | else if( window.addEventListener ){ 143 | obj.addEventListener('DOMNodeInserted', callback, false) 144 | obj.addEventListener('DOMNodeRemoved', callback, false) 145 | obj.addEventListener('DOMAttrModified', callback, false) 146 | 147 | } 148 | } 149 | })() 150 | 151 | observeDOM(document.getElementsByTagName("html")[0], function(m){ 152 | var addedNodes = [], removedNodes = []; 153 | 154 | m.forEach(record => record.addedNodes.length & addedNodes.push(...record.addedNodes)) 155 | 156 | m.forEach(record => record.removedNodes.length & removedNodes.push(...record.removedNodes)) 157 | 158 | if (window.webkit != undefined) { 159 | window.webkit.messageHandlers.ShortWeb.postMessage("DOM Change"); 160 | 161 | var iframes = []; 162 | document.querySelectorAll("iframe").forEach(function (item) { 163 | iframes.push(item.src); 164 | }) 165 | window.webkit.messageHandlers.ShortWeb.postMessage(iframes); 166 | } 167 | }); 168 | 169 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | #page title 2 | page_title : # Automatically populates with app name if not set and if iOS app ID is set. Otherwise enter manually. 3 | 4 | # App Info 5 | ios_app_id : 1570003826 # Required. Enter iOS app ID to automatically populate name, price and icons (e.g. 718043190). 6 | 7 | appstore_link : # Automatically populates if not set and if iOS app ID is set. Otherwise enter manually. 8 | playstore_link : # Enter Google Play Store URL. 9 | presskit_download_link : # Enter a link to downloadable file or (e.g. public Dropbox link to a .zip file). 10 | # Or upload your press kit file to assets and set path accordingly (e.g. "assets/your_press_kit.zip"). 11 | 12 | app_icon : # assets/appicon.png # Automatically populates if not set and if iOS app ID is set. Otherwise enter path to icon file manually. 13 | app_name : # Automatically populates if not set and if iOS app ID is set. Otherwise enter manually. 14 | app_price : $3.99 (3-Day Trial) 15 | app_description : Web automations 16 | 17 | enable_smart_app_banner : true # Set to true to show a smart app banner at top of page on mobile devices. 18 | 19 | 20 | 21 | # Information About Yourself 22 | your_name : Emma Labbé 23 | 24 | your_link : https://labbe.me 25 | 26 | your_city : Viña Del Mar 27 | email_address : emma@labbe.me 28 | facebook_username : 29 | instagram_username : 30 | twitter_username : develobile 31 | github_username : ColdGrub1384 32 | youtube_username : 33 | 34 | 35 | 36 | # Feature List Edit, add or remove features to be presented. 37 | features : 38 | 39 | - title : Automatize web pages 40 | description : Record and run automations in web pages without coding. 41 | fontawesome_icon_name : robot 42 | 43 | - title : Shortcuts 44 | description : Run automations in background and with custom input with Shortcuts 45 | fontawesome_icon_name : cubes 46 | 47 | - title : iCloud 48 | description : Share your automations with all your devices through iCloud. 49 | fontawesome_icon_name : cloud 50 | 51 | - title : iOS API 52 | description : ShortWeb's core logic is open source. 53 | fontawesome_icon_name : lock-open 54 | 55 | 56 | 57 | # Theme Settings 58 | topbar_color : "#000000" 59 | topbar_transparency : 0 60 | topbar_title_color : "#000000" 61 | 62 | cover_image : # Replace with alternative image path or image URL. 63 | cover_overlay_color : "#ffffff" 64 | cover_overlay_transparency : 0.8 65 | 66 | device_color : black # Set to: blue, black, yellow, coral or white. 67 | 68 | body_background_color : "#ededed" 69 | 70 | link_color : "#1d63ea" 71 | 72 | app_title_color : "#000000" 73 | app_price_color : "#000000" 74 | app_description_color : "#000000" 75 | 76 | feature_title_color : "#000000" 77 | feature_text_color : "#666666" 78 | 79 | feature_icons_foreground_color : "#1d63ea" 80 | feature_icons_background_color : "#e6e6e6" 81 | 82 | social_icons_foreground_color : "#666666" 83 | social_icons_background_color : "#e6e6e6" 84 | 85 | footer_text_color : "#666666" 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | #################################################### 100 | ### Jekyll Configuration. No need to touch this. ### 101 | #################################################### 102 | 103 | # Set the Sass partials directory, as we're using @imports 104 | sass: 105 | style: :compressed # You might prefer to minify using :compressed 106 | 107 | # Exclude these files from your production _site 108 | exclude: 109 | - LICENSE 110 | - README.md 111 | - CNAME 112 | 113 | theme: jekyll-theme-cayman 114 | -------------------------------------------------------------------------------- /_includes/appstoreimages.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if site.ios_app_id %} 4 | 5 | 66 | 67 | {% endif %} -------------------------------------------------------------------------------- /_includes/features.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {% for feature in site.features %} 4 | 5 | {% if feature.title %} 6 |
7 |
8 | 9 | 10 | 11 | 12 |
13 |
14 |

15 | {{ feature.title }} 16 |

17 |

18 | {{ feature.description }} 19 |

20 |
21 |
22 | {% endif %} 23 | 24 | {% endfor %} 25 | 26 |
-------------------------------------------------------------------------------- /_includes/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ site.page_title }} 8 | 9 | 10 | 11 | 12 | 13 | {% if site.enable_smart_app_banner %} 14 | 15 | {% endif %} 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /_includes/header.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 16 | 23 |
-------------------------------------------------------------------------------- /_includes/screencontent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for file in site.static_files %} 4 | {% if file.path contains 'assets/screenshot/' %} 5 | 12 | {% elsif file.path contains 'assets/videos/' %} 13 | {% unless file.path contains 'assets/videos/Place-video-files-here.txt' %} 14 | 20 | {% endunless %} 21 | {% if file.extname == ".mov" or file.extname == ".mp4" %} 22 | 29 | {% elsif file.extname == ".ogg" %} 30 | 37 | {% elsif file.extname == ".webm" %} 38 | 45 | {% endif %} 46 | {% endif %} 47 | {% endfor %} -------------------------------------------------------------------------------- /_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head.html %} 5 | 6 | 7 |
8 |
9 |
10 | {% include header.html %} 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | {% include screencontent.html %} 27 | 28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 |

42 | {{ site.app_name }} 43 |

44 |

45 | {{ site.app_price }} 46 |

47 |
48 |
49 |

50 | {{ site.app_description }} 51 |

52 |
53 |
54 | {% if site.playstore_link %} 55 | 56 | {% endif %} 57 | 58 |
59 |
60 | {% include features.html %} 61 | {% include footer.html %} 62 | {% include appstoreimages.html %} 63 |
64 |
65 |
66 | 67 | -------------------------------------------------------------------------------- /_sass/base.scss: -------------------------------------------------------------------------------- 1 | // Layout and grids 2 | $content-width: 1170px; 3 | 4 | // Fonts and sizes 5 | $font: 'Helvetica Neue', sans-serif; 6 | $primary-text-color: #000; 7 | 8 | html { 9 | font-size: 62.5%; 10 | font-family: $font; 11 | line-height: 1; 12 | } 13 | 14 | body { 15 | font-size: 2rem; 16 | background-color: $body-color; 17 | } 18 | 19 | h1 { 20 | font-size: 3rem; 21 | } 22 | 23 | h2 { 24 | font-size: 2rem; 25 | } 26 | 27 | h3 { 28 | font-size: 2rem; 29 | } 30 | 31 | // Better font rendering 32 | body { 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | background-color: $body-color; 36 | } 37 | 38 | a:link, 39 | a:hover, 40 | a:visited, 41 | a:active { 42 | color: $accent-color; 43 | text-decoration: none; 44 | } 45 | 46 | // Shadows 47 | $drop-shadow: drop-shadow(0px 5px 10px rgba(#000, 0.1)) drop-shadow(0px 1px 1px rgba(#000, 0.2)); 48 | 49 | 50 | // Various resets 51 | *, 52 | *::before, 53 | *::after { 54 | -webkit-box-sizing: border-box; 55 | -moz-box-sizing: border-box; 56 | box-sizing: border-box; 57 | 58 | margin: 0; 59 | padding: 0; 60 | } -------------------------------------------------------------------------------- /_sass/layout.scss: -------------------------------------------------------------------------------- 1 | .imageWrapper { // Sets the background image in the header area 2 | height: 714px; 3 | background: 4 | linear-gradient( 5 | rgba($image-overlay-color, $image-overlay-transparency), 6 | rgba($image-overlay-color, $image-overlay-transparency) 7 | ), 8 | 9 | url($header-image); 10 | 11 | background-repeat: no-repeat; 12 | background-size: cover; 13 | background-position: top; 14 | border-radius: 0px 0px 40px 40px; 15 | 16 | } 17 | 18 | .headerBackground { 19 | height: 115px; 20 | background-color: rgba($header-color, $header-transparency); 21 | } 22 | 23 | .container { // Set up the container for the site content 24 | display: grid; 25 | margin: auto; 26 | max-width: $content-width; 27 | padding-left: 15px; 28 | padding-right: 15px; 29 | grid-template-columns: repeat(12, 1fr); 30 | grid-template-rows: 115px 876px auto auto; 31 | grid-column-gap: 30px; 32 | grid-template-areas: 33 | "h h h h h h h h h h h h" 34 | "p p p p p i i i i i i i" 35 | "c c c c c c c c c c c c" 36 | "f f f f f f f f f f f f"; 37 | } 38 | 39 | @media only screen and (max-width: 1070px) { 40 | 41 | .container { // Set up the container for the site content 42 | grid-template-rows: 115px 811px auto auto; 43 | } 44 | } 45 | 46 | @media only screen and (max-width: 992px) { 47 | 48 | .container { 49 | grid-column-gap: 0px; 50 | grid-template-columns: 1; 51 | grid-template-rows: 115px auto auto auto auto; 52 | grid-template-areas: 53 | "h h h h h h h h h h h h" 54 | "i i i i i i i i i i i i" 55 | "p p p p p p p p p p p p" 56 | "c c c c c c c c c c c c" 57 | "f f f f f f f f f f f f"; 58 | } 59 | } 60 | 61 | header { 62 | grid-area: h; 63 | display: flex; 64 | } 65 | 66 | .logo { 67 | display: flex; 68 | width: 100%; 69 | justify-content: flex-start; 70 | align-items: center; 71 | height: 115px; 72 | } 73 | 74 | .logo > p { 75 | color: $header-title-color; 76 | display: flex; 77 | font-weight: bold; 78 | padding-bottom: 1px; 79 | } 80 | 81 | .headerIcon { 82 | width: 50px; 83 | height: 50px; 84 | -webkit-clip-path: url(#shape); 85 | clip-path: url(#shape); 86 | margin-right: 15px; 87 | } 88 | 89 | 90 | 91 | // Navigation Links 92 | nav { 93 | width: 100%; 94 | display: flex; 95 | justify-content: flex-end; 96 | align-items: center; 97 | height: 115px; 98 | } 99 | 100 | nav > ul { 101 | color: #fff; 102 | display: flex; 103 | list-style-type: none; 104 | } 105 | 106 | nav > ul li { 107 | padding-left: 50px; 108 | text-align: right; 109 | } 110 | 111 | nav > ul li:first-child { 112 | padding-left: 0px; 113 | } 114 | 115 | nav > ul li a:link, 116 | nav > ul li a:visited { 117 | text-decoration: none; 118 | color: rgba($header-title-color, 0.6); 119 | } 120 | 121 | nav > ul li a:hover, 122 | nav > ul li a:active { 123 | text-decoration: none; 124 | color: rgba($header-title-color, 1); 125 | } 126 | 127 | 128 | 129 | // App Title, Price, Description and Links 130 | 131 | .appInfo { 132 | grid-area: i; 133 | display: flex; 134 | flex-wrap: wrap; 135 | padding-top: 140px; 136 | align-content: flex-start; 137 | } 138 | 139 | @media only screen and (max-width: 992px) { 140 | 141 | .appInfo { 142 | padding-top: 50px; 143 | justify-content: center; 144 | } 145 | 146 | } 147 | 148 | .appIconShadow { 149 | display: flex; 150 | filter: $drop-shadow; 151 | } 152 | 153 | .appIconLarge { 154 | width: 120px; 155 | height: 120px; 156 | -webkit-clip-path: url(#shape120); 157 | clip-path: url(#shape120); 158 | } 159 | 160 | .appNamePriceContainer { 161 | display: flex; 162 | flex: 0 1 auto; 163 | flex-direction: column; 164 | align-items: start; 165 | justify-content: center; 166 | margin-left: 30px; 167 | } 168 | 169 | .appName { 170 | color: $app-title-color; 171 | } 172 | 173 | .appPrice { 174 | color: $app-price-color; 175 | font-weight: normal; 176 | margin-top: 13px; 177 | } 178 | 179 | @media only screen and (max-width: 768px) { 180 | 181 | .appNamePriceContainer { 182 | width: 100%; 183 | margin-left: 0px; 184 | align-items: center; 185 | justify-content: center; 186 | } 187 | 188 | .appName { 189 | margin-top: 30px; 190 | text-align: center; 191 | } 192 | 193 | .appPrice { 194 | margin-top: 13px; 195 | text-align: center; 196 | } 197 | 198 | } 199 | 200 | .appDescriptionContainer { 201 | font-size: 2.5rem; 202 | font-weight: normal; 203 | width: 100%; 204 | align-items: flex-start; 205 | margin-top: 45px; 206 | flex: 0 1 auto; 207 | line-height: 1.5; 208 | } 209 | 210 | .appDescription { 211 | color: $app-description-color; 212 | } 213 | 214 | @media only screen and (max-width: 992px) { 215 | 216 | .appDescription { 217 | text-align: center; 218 | } 219 | 220 | } 221 | 222 | .downloadButtonsContainer { 223 | display: inline-block; 224 | margin-top: 42px; 225 | filter: $drop-shadow; 226 | } 227 | 228 | @media only screen and (max-width: 992px) { 229 | 230 | .downloadButtonsContainer { 231 | text-align: center; 232 | } 233 | 234 | } 235 | 236 | .playStore { 237 | height: 75px; 238 | margin-right: 24px; 239 | } 240 | 241 | @media only screen and (max-width: 992px) { 242 | 243 | .playStore { 244 | margin-right: 24px; 245 | margin-bottom: 0px; 246 | } 247 | 248 | } 249 | 250 | @media only screen and (max-width: 528px) { 251 | 252 | .playStore { 253 | margin-right: 0px; 254 | margin-bottom: 24px; 255 | } 256 | 257 | } 258 | 259 | .appStore { 260 | height: 75px; 261 | } 262 | 263 | 264 | 265 | // iPhone Device Preview 266 | 267 | .iphonePreview { 268 | grid-area: p; 269 | background-image: url($device-color); 270 | background-size: 400px auto; 271 | background-repeat: no-repeat; 272 | margin-top: 68px; 273 | } 274 | 275 | .iphoneScreen { 276 | width: 349px; 277 | -webkit-clip-path: url(#screenMask); 278 | clip-path: url(#screenMask); 279 | margin-left: 26px; 280 | margin-top: 23px; 281 | } 282 | 283 | .videoContainer { 284 | width: 349px; 285 | height: 755px; 286 | -webkit-clip-path: url(#screenMask); 287 | clip-path: url(#screenMask); 288 | margin-left: 26px; 289 | margin-top: 23px; 290 | } 291 | 292 | .videoContainer > video { 293 | width: 349px; 294 | height: 755px; 295 | } 296 | 297 | @media only screen and (max-width: 1070px) { 298 | 299 | .iphonePreview { 300 | background-size: 370px auto; 301 | } 302 | 303 | .iphoneScreen { 304 | width: 322px; 305 | margin-left: 24px; 306 | margin-top: 22px; 307 | } 308 | 309 | .videoContainer { 310 | width: 322px; 311 | height: 698px; 312 | margin-left: 24px; 313 | margin-top: 22px; 314 | } 315 | 316 | .videoContainer > video { 317 | width: 322px; 318 | height: 698px; 319 | } 320 | 321 | } 322 | 323 | @media only screen and (max-width: 992px) { 324 | 325 | .iphonePreview { 326 | display: flex; 327 | background-size: 260px auto; 328 | background-position: center 0; 329 | margin-top: 47px; 330 | justify-content: center; 331 | padding-bottom: 75px; 332 | } 333 | 334 | .iphoneScreen { 335 | width: 226px; 336 | height: 488px; 337 | -webkit-clip-path: url(#screenMask); 338 | clip-path: url(#screenMask); 339 | margin: 0px; 340 | margin-top: 17px; 341 | } 342 | 343 | .videoContainer { 344 | width: 226px; 345 | height: 488px; 346 | margin-left: 0px; 347 | margin-top: 17px; 348 | } 349 | 350 | .videoContainer > video { 351 | width: 226px; 352 | height: 488px; 353 | } 354 | 355 | } 356 | 357 | 358 | // Feature List 359 | 360 | .features { 361 | grid-area: c; 362 | display: flex; 363 | flex: 0 1 auto; 364 | align-content: flex-start; 365 | justify-content: flex-start; 366 | flex-grow: 1; 367 | flex-wrap: wrap; 368 | margin-top: 93px; 369 | } 370 | 371 | .feature { 372 | display: flex; 373 | padding-top: 63px; 374 | padding-left: 15px; 375 | padding-right: 15px; 376 | width: calc(100%/3); 377 | } 378 | 379 | .feature:nth-child(-n+3) { 380 | padding-top: 0px; 381 | } 382 | 383 | .feature:nth-child(3n) { 384 | padding-right: 0px; 385 | } 386 | 387 | .feature:nth-child(3n+1) { 388 | padding-left: 0px; 389 | } 390 | 391 | .iconBack { 392 | color: $feature-icons-background-color; 393 | } 394 | 395 | .iconTop { 396 | color: $feature-icons-foreground-color; 397 | } 398 | 399 | .socialIconBack { 400 | color: $social-icons-background-color; 401 | } 402 | 403 | .socialIconTop { 404 | color: $social-icons-foreground-color; 405 | } 406 | 407 | .featureText { 408 | margin-left: 18px; 409 | } 410 | 411 | .featureText > h3 { 412 | color: $feature-title-color; 413 | } 414 | 415 | .featureText > p { 416 | color: $feature-text-color; 417 | margin-top: 8px; 418 | line-height: 1.5; 419 | } 420 | 421 | @media only screen and (max-width: 992px) { 422 | 423 | .features { 424 | flex-grow: 1; 425 | flex-direction: row; 426 | flex-wrap: wrap; 427 | margin-top: 11px; 428 | } 429 | 430 | .feature { 431 | display: flex; 432 | padding-top: 41px; 433 | padding-left: 15px; 434 | padding-right: 15px; 435 | width: 100%; 436 | } 437 | 438 | .feature:nth-child(-n+3) { 439 | padding-top: 41px; 440 | } 441 | 442 | .feature:nth-child(1) { 443 | padding-top: 0px; 444 | } 445 | 446 | .feature:nth-child(3n) { 447 | padding-right: 15px; 448 | } 449 | 450 | .feature:nth-child(3n+1) { 451 | padding-left: 15px; 452 | } 453 | 454 | } 455 | 456 | 457 | 458 | // Footer 459 | 460 | footer { 461 | grid-area: f; 462 | display: flex; 463 | flex-wrap: wrap; 464 | justify-content: center; 465 | align-content: center; 466 | } 467 | 468 | .footerText { 469 | color: $footer-text-color; 470 | display: block; 471 | line-height: 1.5; 472 | width: 100%; 473 | text-align: center; 474 | padding-top: 70px; 475 | padding-bottom: 70px; 476 | } 477 | 478 | .footerIcons { 479 | padding-bottom: 70px; 480 | display: flex; 481 | } 482 | 483 | @media only screen and (max-width: 992px) { 484 | 485 | .footerText { 486 | color: $footer-text-color; 487 | display: block; 488 | line-height: 1.5; 489 | width: 100%; 490 | text-align: center; 491 | padding-top: 54px; 492 | padding-bottom: 61px; 493 | } 494 | 495 | .footerIcons { 496 | padding-bottom: 70px; 497 | display: flex; 498 | } 499 | 500 | } 501 | 502 | .hidden { 503 | display: none; 504 | } -------------------------------------------------------------------------------- /assets/appstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/appstore.png -------------------------------------------------------------------------------- /assets/black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/black.png -------------------------------------------------------------------------------- /assets/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/blue.png -------------------------------------------------------------------------------- /assets/coral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/coral.png -------------------------------------------------------------------------------- /assets/headerimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/headerimage.png -------------------------------------------------------------------------------- /assets/playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/playstore.png -------------------------------------------------------------------------------- /assets/screenshot/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/screenshot/Screenshot.png -------------------------------------------------------------------------------- /assets/squircle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/squircle120.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/videos/Place-video-files-here.txt: -------------------------------------------------------------------------------- 1 | Place video files in this folder. 2 | 3 | Formats for Chrome & Firefox: 4 | .webm 5 | .ogg 6 | 7 | Formats for Safari: 8 | .mp4 9 | .mov 10 | 11 | Optimal video resolutions: 12 | 828x1792 13 | 1125x2436 14 | 1242x2688 15 | -------------------------------------------------------------------------------- /assets/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/white.png -------------------------------------------------------------------------------- /assets/yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColdGrub1384/ShortWebCore/49310481ab8b21cd6bfc901f5433d61f160483e6/assets/yellow.png -------------------------------------------------------------------------------- /automatic-app-landing-page_LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Emil Baehr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- -------------------------------------------------------------------------------- /main.scss: -------------------------------------------------------------------------------- 1 | --- 2 | # Front matter comment to ensure Jekyll properly reads file. 3 | --- 4 | 5 | $body-color: {{ site.body_background_color }}; 6 | 7 | $header-image: "{{ site.cover_image }}"; 8 | $device-color: "assets/{{ site.device_color }}.png"; 9 | 10 | $accent-color: {{ site.link_color }}; 11 | 12 | $header-title-color: {{ site.topbar_title_color }}; 13 | $app-title-color: {{ site.app_title_color }}; 14 | $app-price-color: {{ site.app_price_color }}; 15 | $app-description-color: {{ site.app_description_color }}; 16 | 17 | $feature-title-color: {{ site.feature_title_color }}; 18 | $feature-text-color: {{ site.feature_text_color }}; 19 | $footer-text-color: {{ site.footer_text_color }}; 20 | 21 | $header_color: {{ site.topbar_color }}; 22 | $header_transparency: {{ site.topbar_transparency }}; 23 | 24 | $image-overlay-color: {{ site.cover_overlay_color }}; 25 | $image-overlay-transparency: {{ site.cover_overlay_transparency }}; 26 | 27 | $feature-icons-foreground-color: {{ site.feature_icons_foreground_color }}; 28 | $feature-icons-background-color: {{ site.feature_icons_background_color }}; 29 | 30 | $social-icons-foreground-color: {{ site.social_icons_foreground_color }}; 31 | $social-icons-background-color: {{ site.social_icons_background_color }}; 32 | 33 | @import 34 | "base", 35 | "layout" --------------------------------------------------------------------------------