Skip to content

Commit

Permalink
Merge pull request #175 from televator-apps/172-customisation
Browse files Browse the repository at this point in the history
Customisation
  • Loading branch information
nbelzer authored Jul 31, 2020
2 parents cd74280 + aa06c1f commit 362da89
Show file tree
Hide file tree
Showing 12 changed files with 571 additions and 358 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ Changelog
-------------

### Unreleased
* Add user customisation (based on the work of @nieldm [#163](https://github.com/televator-apps/vimari/pull/163)).
* Update Vimari interface to allow users access to their configuration.
* Remove `closeTabReverse` action.

### 2.0.3 (2019-09-26)

Expand Down
118 changes: 118 additions & 0 deletions Vimari Extension/ConfigurationModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// ConfigurationModel.swift
// Vimari Extension
//
// Created by Daniel Mendez on 12/15/19.
// Copyright © 2019 net.televator. All rights reserved.
//

protocol ConfigurationModelProtocol {
func editConfigFile() throws
func resetConfigFile() throws
func getDefaultSettings() throws -> [String: Any]
func getUserSettings() throws -> [String : Any]
}

import Foundation
import SafariServices

class ConfigurationModel: ConfigurationModelProtocol {

private enum Constant {
static let settingsFileName = "defaultSettings"
static let userSettingsFileName = "userSettings"
static let defaultEditor = "TextEdit"
}

let userSettingsUrl: URL = FileManager.documentDirectoryURL
.appendingPathComponent(Constant.userSettingsFileName)
.appendingPathExtension("json")

func editConfigFile() throws {
let settingsFilePath = try findOrCreateUserSettings()
NSWorkspace.shared.openFile(
settingsFilePath,
withApplication: Constant.defaultEditor
)
}

func resetConfigFile() throws {
let settingsFilePath = try overwriteUserSettings()
NSWorkspace.shared.openFile(
settingsFilePath,
withApplication: Constant.defaultEditor
)
}

func getDefaultSettings() throws -> [String : Any] {
return try loadSettings(fromFile: Constant.settingsFileName)
}

func getUserSettings() throws -> [String : Any] {
let userFilePath = try findOrCreateUserSettings()
let urlSettingsFile = URL(fileURLWithPath: userFilePath)
let settingsData = try Data(contentsOf: urlSettingsFile)
return try settingsData.toJSONObject()
}

private func loadSettings(fromFile file: String) throws -> [String : Any] {
let settingsData = try Bundle.main.getJSONData(from: file)
return try settingsData.toJSONObject()
}

private func findOrCreateUserSettings() throws -> String {
let url = userSettingsUrl
let urlString = url.path
if FileManager.default.fileExists(atPath: urlString) {
return urlString
}
let data = try Bundle.main.getJSONData(from: Constant.settingsFileName)
try data.write(to: url)
return urlString
}

private func overwriteUserSettings() throws -> String {
let url = userSettingsUrl
let urlString = userSettingsUrl.path
let data = try Bundle.main.getJSONData(from: Constant.settingsFileName)
try data.write(to: url)
return urlString
}
}

enum DataError: Error {
case unableToParse
case notFound
}

private extension Data {
func toJSONObject() throws -> [String: Any] {
let serialized = try JSONSerialization.jsonObject(with: self, options: [])
guard let result = serialized as? [String: Any] else {
throw DataError.unableToParse
}
return result
}
}

private extension Bundle {
func getJSONPath(for file: String) throws -> String {
guard let result = self.path(forResource: file, ofType: ".json") else {
throw DataError.notFound
}
return result
}

func getJSONData(from file: String) throws -> Data {
let settingsPath = try self.getJSONPath(for: file)
let urlSettingsFile = URL(fileURLWithPath: settingsPath)
return try Data(contentsOf: urlSettingsFile)
}
}

private extension FileManager {
static var documentDirectoryURL: URL {
let documentDirectoryURL = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
return documentDirectoryURL
}
}
2 changes: 1 addition & 1 deletion Vimari Extension/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<array>
<dict>
<key>Script</key>
<string>settings.js</string>
<string>SafariExtensionCommunicator.js</string>
</dict>
<dict>
<key>Script</key>
Expand Down
182 changes: 147 additions & 35 deletions Vimari Extension/SafariExtensionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,51 @@ enum ActionType: String {
case tabForward
case tabBackward
case closeTab
case updateSettings
}

enum InputAction: String {
case openSettings
case resetSettings
}

enum TabDirection: String {
case forward
case backward
}

func mod(_ a: Int, _ n: Int) -> Int {
// https://stackoverflow.com/questions/41180292/negative-number-modulo-in-swift
precondition(n > 0, "modulus must be positive")
let r = a % n
return r >= 0 ? r : r + n
}

class SafariExtensionHandler: SFSafariExtensionHandler {
override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) {
guard let action = ActionType(rawValue: messageName) else {
NSLog("Received message with unsupported type: \(messageName)")
return

private enum Constant {
static let mainAppName = "Vimari"
static let newTabPageURL = "https://duckduckgo.com" //Try it :D
}

let configuration: ConfigurationModelProtocol = ConfigurationModel()

//MARK: Overrides

// This method handles messages from the Vimari App (located /Vimari in the repository)
override func messageReceivedFromContainingApp(withName messageName: String, userInfo: [String : Any]? = nil) {
do {
switch InputAction(rawValue: messageName) {
case .openSettings:
try configuration.editConfigFile()
case .resetSettings:
try configuration.resetConfigFile()
case .none:
NSLog("Input not supported " + messageName)
}
} catch {
NSLog(error.localizedDescription)
}

}

// This method handles messages from the extension (in the browser page)
override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) {
NSLog("Received message: \(messageName)")
switch action {
switch ActionType(rawValue: messageName) {
case .openLinkInTab:
let url = URL(string: userInfo?["url"] as! String)
openInNewTab(url: url!)
Expand All @@ -40,66 +62,156 @@ class SafariExtensionHandler: SFSafariExtensionHandler {
changeTab(withDirection: .backward, from: page)
case .closeTab:
closeTab(from: page)
case .updateSettings:
updateSettings(page: page)
case .none:
NSLog("Received message with unsupported type: \(messageName)")
}
}

func openInNewTab(url: URL) {
override func toolbarItemClicked(in _: SFSafariWindow) {
// This method will be called when your toolbar item is clicked.
NSLog("The extension's toolbar item was clicked")
NSWorkspace.shared.launchApplication(Constant.mainAppName)
}

override func validateToolbarItem(in _: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) {
// This is called when Safari's state changed in some way that would require the extension's toolbar item to be validated again.
validationHandler(true, "")
}

override func popoverViewController() -> SFSafariExtensionViewController {
return SafariExtensionViewController.shared
}

// MARK: Tabs Methods

private func openInNewTab(url: URL) {
SFSafariApplication.getActiveWindow { activeWindow in
activeWindow?.openTab(with: url, makeActiveIfPossible: false, completionHandler: { _ in
// Perform some action here after the page loads
})
}
}

func openNewTab() {
// Ideally this URL would be something that represents an empty tab better than localhost
let url = URL(string: "http://localhost")!
private func openNewTab() {
var newPageUrl: String? = getSetting("openTabUrl") as? String
if newPageUrl == nil || newPageUrl!.isEmpty {
newPageUrl = Constant.newTabPageURL
}
let url = URL(string: newPageUrl!)!
SFSafariApplication.getActiveWindow { activeWindow in
activeWindow?.openTab(with: url, makeActiveIfPossible: true, completionHandler: { _ in
// Perform some action here after the page loads
})
}
}

func changeTab(withDirection direction: TabDirection, from page: SFSafariPage, completionHandler: (() -> Void)? = nil ) {
page.getContainingTab(completionHandler: { currentTab in
currentTab.getContainingWindow(completionHandler: { window in
window?.getAllTabs(completionHandler: { tabs in
private func changeTab(withDirection direction: TabDirection, from page: SFSafariPage, completionHandler: (() -> Void)? = nil ) {
page.getContainingTab() { currentTab in
// Using .currentWindow instead of .containingWindow, this prevents the window being nil in the case of a pinned tab.
self.currentWindow(from: page) { window in
window?.getAllTabs() { tabs in
tabs.forEach { tab in NSLog(tab.description) }
if let currentIndex = tabs.firstIndex(of: currentTab) {
let indexStep = direction == TabDirection.forward ? 1 : -1

// Wrap around the ends with a modulus operator.
// % calculates the remainder, not the modulus, so we need a
// custom function.
let newIndex = mod(currentIndex + indexStep, tabs.count)

tabs[newIndex].activate(completionHandler: completionHandler ?? {})

}
})
})
})
}
}
}
}

/**
Returns the containing window of a SFSafariPage, if not available default to the current active window.
*/
private func currentWindow(from page: SFSafariPage, completionHandler: @escaping ((SFSafariWindow?) -> Void)) {
page.getContainingTab() { $0.getContainingWindow() { window in
if window != nil {
return completionHandler(window)
} else {
SFSafariApplication.getActiveWindow() { window in
return completionHandler(window)
}
}
}}
}

func closeTab(from page: SFSafariPage) {
private func closeTab(from page: SFSafariPage) {
page.getContainingTab {
tab in
tab.close()
}
}

// MARK: Settings

override func toolbarItemClicked(in _: SFSafariWindow) {
// This method will be called when your toolbar item is clicked.
NSLog("The extension's toolbar item was clicked")
NSWorkspace.shared.launchApplication("Vimari")
private func getSetting(_ settingKey: String) -> Any? {
do {
let settings = try configuration.getUserSettings()
return settings[settingKey]
} catch {
NSLog("Was not able to retrieve the user settings\n\(error.localizedDescription)")
return nil
}
}

private func updateSettings(page: SFSafariPage) {
do {
let settings: [String: Any]
if let userSettings = try? configuration.getUserSettings() {
settings = userSettings
} else {
settings = try configuration.getDefaultSettings()
}
page.dispatch(settings: settings)
} catch {
NSLog(error.localizedDescription)
}
}

private func fallbackSettings(page: SFSafariPage) {
do {
let settings = try configuration.getUserSettings()
page.dispatch(settings: settings)
} catch {
NSLog(error.localizedDescription)
}
}
}

override func validateToolbarItem(in _: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) {
// This is called when Safari's state changed in some way that would require the extension's toolbar item to be validated again.
validationHandler(true, "")
// MARK: Helpers

private func mod(_ a: Int, _ n: Int) -> Int {
// https://stackoverflow.com/questions/41180292/negative-number-modulo-in-swift
precondition(n > 0, "modulus must be positive")
let r = a % n
return r >= 0 ? r : r + n
}

private extension SFSafariPage {
func dispatch(settings: [String: Any]) {
self.dispatchMessageToScript(
withName: "updateSettingsEvent",
userInfo: settings
)
}
}

override func popoverViewController() -> SFSafariExtensionViewController {
return SafariExtensionViewController.shared
private extension SFSafariApplication {
static func getActivePage(completionHandler: @escaping (SFSafariPage?) -> Void) {
SFSafariApplication.getActiveWindow {
$0?.getActiveTab {
$0?.getActivePage(completionHandler: completionHandler)
}
}
}
}

Loading

0 comments on commit 362da89

Please sign in to comment.