Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customisation #175

Merged
merged 20 commits into from
Jul 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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