diff --git a/.gitmodules b/.gitmodules index d364a01ad..4a73daede 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "MastodonKit"] path = MastodonKit - url = git://github.com/MastodonKit/MastodonKit.git + url = git://github.com/shadowfacts/MastodonKit.git [submodule "SwiftSoup"] path = SwiftSoup url = git://github.com/scinfu/SwiftSoup.git diff --git a/MastodonKit b/MastodonKit index 43144bf87..6a03c64b6 160000 --- a/MastodonKit +++ b/MastodonKit @@ -1 +1 @@ -Subproject commit 43144bf87cea83c81ad899fc0488be3eae645a01 +Subproject commit 6a03c64b6788faf5915c2918d429e5031af04fe6 diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7cd2edd2b..07b7556ee 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; + D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; }; + D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D6BED16F212663DA00F02DA0 /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; }; D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */; }; @@ -57,6 +60,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; }; + D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = ""; }; + D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -139,6 +145,7 @@ isa = PBXGroup; children = ( D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, + D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D6F953F121251A2F00CF0F2B /* Controllers */, D6F953E9212519B800CF0F2B /* View Controllers */, D6BED1722126661300F02DA0 /* Views */, @@ -173,6 +180,7 @@ children = ( D6D4DDD1212518A000E1C4BB /* ViewController.swift */, D6F953EB212519E700CF0F2B /* StatusesTableViewController.swift */, + D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -182,6 +190,7 @@ children = ( D6D4DDD3212518A000E1C4BB /* Main.storyboard */, D6F953ED21251A0700CF0F2B /* Statuses.storyboard */, + D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */, ); path = Storyboards; sourceTree = ""; @@ -300,6 +309,7 @@ buildActionMask = 2147483647; files = ( D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, + D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */, D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */, D6F953EE21251A0700CF0F2B /* Statuses.storyboard in Resources */, D6D4DDD5212518A000E1C4BB /* Main.storyboard in Resources */, @@ -330,6 +340,8 @@ D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */, D6D4DDD2212518A000E1C4BB /* ViewController.swift in Sources */, + D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, + D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D6F953EC212519E700CF0F2B /* StatusesTableViewController.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, ); diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index d930cd644..5155880e2 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -13,15 +13,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - MastodonController.shared.connect() +// MastodonController.shared.connect() + + if LocalData.shared.hasLaunchedBefore { + MastodonController.shared.createClient() { + } + } else { + showOnboarding() + } return true } + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return false } + + print("opened with url: \(url)") + + if components.host == "oauth" { + let code = components.queryItems?.first { + $0.name == "code" + } + if let authCode = code?.value { +// LocalData.shared.refreshToken = refreshToken + MastodonController.shared.authorize(authorizationCode: authCode) { + } + } + return true + } else { + return false + } + + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. @@ -44,6 +71,29 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - +} + +extension AppDelegate: OnboardingViewControllerDelegate { + + func showOnboarding() { + if let window = self.window, + let onboardingViewController = UIStoryboard(name: "Onboarding", bundle: nil).instantiateInitialViewController() as? OnboardingViewController { + + onboardingViewController.delegate = self + window.makeKeyAndVisible() + window.rootViewController?.present(onboardingViewController, animated: true, completion: nil) + } + } + + func hideOnboarding() { + if let window = UIApplication.shared.keyWindow { + window.rootViewController?.dismiss(animated: true, completion: nil) + } + } + + func didFinishOnboarding() { + hideOnboarding() + } + } diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 5e48a3384..70b3a7531 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -13,62 +13,81 @@ class MastodonController { static let shared = MastodonController() - var userDefaults = UserDefaults() +// var userDefaults = UserDefaults() var client: Client! - lazy var clientID: String? = self.userDefaults.string(forKey: "clientID") - lazy var clientSecret: String? = self.userDefaults.string(forKey: "clientSecret") - - lazy var accessToken: String? = self.userDefaults.string(forKey: "accessToken") +// lazy var clientID: String? = self.userDefaults.string(forKey: "clientID") +// lazy var clientSecret: String? = self.userDefaults.string(forKey: "clientSecret") +// +// lazy var accessToken: String? = self.userDefaults.string(forKey: "accessToken") private init() { } - func connect() { - let url = ProcessInfo.processInfo.environment["mastodon_url"]! + func createClient(completion: @escaping () -> Void) { + guard let url = LocalData.shared.instanceURL else { fatalError("Can't connect without instance URL") } - if let accessToken = accessToken { - client = Client(baseURL: url, accessToken: accessToken) + client = Client(baseURL: url) + + if let refreshToken = LocalData.shared.refreshToken { +// client.accessToken = accessToken +// completion() + authorize(authorizationCode: refreshToken, completion: completion) } else { - client = Client(baseURL: url) - - login() + register(completion: completion) } } private func register(completion: @escaping () -> Void) { - if clientID != nil, - clientSecret != nil { - completion() - } else { - let registerRequest = Clients.register(clientName: "Tusker", scopes: [.read, .write, .follow]) - - client.run(registerRequest) { result in - guard case let .success(application, _) = result else { fatalError() } - self.clientID = application.clientID - self.clientSecret = application.clientSecret - self.userDefaults.set(self.clientID, forKey: "clientID") - self.userDefaults.set(self.clientSecret, forKey: "clientSecret") + guard LocalData.shared.clientID == nil, + LocalData.shared.clientSecret == nil else { completion() - } + return + } + + let registerRequest = Clients.register(clientName: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) + + client.run(registerRequest) { result in + guard case let .success(application, _) = result else { fatalError() } + LocalData.shared.clientID = application.clientID + LocalData.shared.clientSecret = application.clientSecret + completion() } } - private func login() { - // TODO: OAuth - let username = ProcessInfo.processInfo.environment["mastodon_username"]! - let password = ProcessInfo.processInfo.environment["mastodon_password"]! - - register() { - let loginReq = Login.silent(clientID: self.clientID!, clientSecret: self.clientSecret!, scopes: [.read, .write, .follow], username: username, password: password) - - self.client.run(loginReq) { result in - guard case let .success(loginSettings, _) = result else { fatalError() } - self.accessToken = loginSettings.accessToken - self.userDefaults.set(self.accessToken, forKey: "accessToken") - } + func authorize(authorizationCode: String, completion: @escaping () -> Void) { +// let parameters = [ +// Parameter(name: "client_id", value: LocalData.shared.clientID), +// Parameter(name: "client_secret", value: LocalData.shared.clientSecret), +// Parameter(name: "grant_type", value: "refresh_token"), +// Parameter(name: "refresh_token", value: LocalData.shared.refreshToken) +// ] +// let method = HTTPMethod.post(.parameters(parameters)) + let authorizeRequest = Login.authorize(code: authorizationCode, clientID: LocalData.shared.clientID!, clientSecret: LocalData.shared.clientSecret!) + client.run(authorizeRequest) { result in + guard case let .success(settings, _) = result else { fatalError() } + LocalData.shared.refreshToken = settings.refreshToken + LocalData.shared.accessToken = settings.accessToken + self.client.accessToken = settings.accessToken + completion() } } +// private func login() { +// // TODO: OAuth +// let username = ProcessInfo.processInfo.environment["mastodon_username"]! +// let password = ProcessInfo.processInfo.environment["mastodon_password"]! +// +// register() { +// let loginReq = Login.silent(clientID: self.clientID!, clientSecret: self.clientSecret!, scopes: [.read, .write, .follow], username: username, password: password) +// +// self.client.run(loginReq) { result in +// guard case let .success(loginSettings, _) = result else { fatalError() } +// self.accessToken = loginSettings.accessToken +// self.userDefaults.set(self.accessToken, forKey: "accessToken") +// } +// } +// } + } diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 89d7858b3..d629a2970 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -16,6 +16,19 @@ APPL CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + net.shadowfacts.Tusker + CFBundleURLSchemes + + tusker + + + CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/Tusker/LocalData.swift b/Tusker/LocalData.swift new file mode 100644 index 000000000..7a7f8b581 --- /dev/null +++ b/Tusker/LocalData.swift @@ -0,0 +1,80 @@ +// +// LocalData.swift +// Tusker +// +// Created by Shadowfacts on 8/18/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation + +class LocalData { + + static let shared = LocalData() + + let defaults = UserDefaults() + + private let hasLaunchedBeforeKey = "hasLaunchedBefore" + var hasLaunchedBefore: Bool { + get { + return defaults.bool(forKey: hasLaunchedBeforeKey) + } + set { + defaults.set(newValue, forKey: hasLaunchedBeforeKey) + } + } + + private let instanceURLKey = "instanceURL" + var instanceURL: String? { + get { + return defaults.string(forKey: instanceURLKey) + } + set { + defaults.set(newValue, forKey: instanceURLKey) + } + } + + private let clientIDKey = "clientID" + var clientID: String? { + get { + return defaults.string(forKey: clientIDKey) + } + set { + defaults.set(newValue, forKey: clientIDKey) + } + } + + private let clientSecretKey = "clientSecret" + var clientSecret: String? { + get { + return defaults.string(forKey: clientSecretKey) + } + set { + defaults.set(newValue, forKey: clientSecretKey) + } + } + + private let refreshTokenKey = "refreshToken" + var refreshToken: String? { + get { + return defaults.string(forKey: refreshTokenKey) + } + set { + defaults.set(newValue, forKey: refreshTokenKey) + } + } + + private let accessTokenKey = "accessToken" + var accessToken: String? { + get { + return defaults.string(forKey: accessTokenKey) + } + set { + defaults.set(newValue, forKey: accessTokenKey) + } + } + + private init() { + } + +} diff --git a/Tusker/Storyboards/Onboarding.storyboard b/Tusker/Storyboards/Onboarding.storyboard new file mode 100644 index 000000000..e1bd4d4df --- /dev/null +++ b/Tusker/Storyboards/Onboarding.storyboard @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/View Controllers/OnboardingViewController.swift b/Tusker/View Controllers/OnboardingViewController.swift new file mode 100644 index 000000000..dfc115f2b --- /dev/null +++ b/Tusker/View Controllers/OnboardingViewController.swift @@ -0,0 +1,93 @@ +// +// OnboardingViewController.swift +// Tusker +// +// Created by Shadowfacts on 8/18/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import AuthenticationServices + +protocol OnboardingViewControllerDelegate { + + func didFinishOnboarding() + +} + +class OnboardingViewController: UIViewController { + + var delegate: OnboardingViewControllerDelegate? + + @IBOutlet weak var urlTextField: UITextField! + + var authenticationSession: ASWebAuthenticationSession? + + override func viewDidLoad() { + super.viewDidLoad() + } + + @IBAction func loginPressed(_ sender: Any) { + guard let text = urlTextField.text, + var components = URLComponents(string: text) else { return } + + LocalData.shared.instanceURL = text + MastodonController.shared.createClient { + let clientID = LocalData.shared.clientID! + + let callbackURL = "tusker://oauth" + + components.path = "/oauth/authorize" + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: "read write follow"), + URLQueryItem(name: "redirect_uri", value: callbackURL) + ] + let url = components.url! + + print("oauth url: \(url)") + + DispatchQueue.main.async { + self.delegate?.didFinishOnboarding() + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + +// self.delegate?.didFinishOnboarding() +// self.authenticationSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURL) { url, error in +// guard error == nil, +// let url = url, +// let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { fatalError() } +// +// print("callback url: \(url)") +// +// let item = components.queryItems?.first { $0.name == "code" } +// if let accessToken = item?.value { +// LocalData.shared.accessToken = accessToken +// MastodonController.shared.client.accessToken = accessToken +// self.delegate?.didFinishOnboarding() +// self.authenticationSession = nil +// } +// } +// self.authenticationSession!.start() + } + } + + @IBAction func clearDataPressed(_ sender: Any) { + LocalData.shared.instanceURL = nil + LocalData.shared.clientID = nil + LocalData.shared.clientSecret = nil + LocalData.shared.refreshToken = nil + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/Tusker/View Controllers/StatusesTableViewController.swift b/Tusker/View Controllers/StatusesTableViewController.swift index 2280ace85..1137142b0 100644 --- a/Tusker/View Controllers/StatusesTableViewController.swift +++ b/Tusker/View Controllers/StatusesTableViewController.swift @@ -24,6 +24,7 @@ class StatusesTableViewController: UITableViewController { var older: RequestRange? override func viewWillAppear(_ animated: Bool) { + guard MastodonController.shared.client?.accessToken != nil else { return } MastodonController.shared.client.run(Timelines.home()) { result in guard case let .success(statuses, pagination) = result else { fatalError() } self.statuses = statuses