diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 6261214d..965b3b26 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -13,8 +13,12 @@ 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */; }; 9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; }; 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; }; - 9F398AB329360A4C00A889F2 /* TimelineTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTabView.swift */; }; - 9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */; }; + 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTab.swift */; }; + 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; }; + 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; }; + 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD029379AD600772766 /* AppAccount.swift */; }; + 9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */; }; + 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; }; 9FBFE641292A715600C250E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FBFE640292A715600C250E9 /* Assets.xcassets */; }; 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; }; /* End PBXBuildFile section */ @@ -27,9 +31,13 @@ 9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = ""; }; 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = ""; }; 9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = ""; }; - 9F398AB229360A4C00A889F2 /* TimelineTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTabView.swift; sourceTree = ""; }; + 9F398AB229360A4C00A889F2 /* TimelineTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTab.swift; sourceTree = ""; }; + 9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; + 9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = ""; }; + 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountsManager.swift; sourceTree = ""; }; 9FBFE639292A715500C250E9 /* IceCubesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IceCubesApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesAppApp.swift; sourceTree = ""; }; + 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = ""; }; 9FBFE640292A715600C250E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -39,6 +47,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */, 9F398AA92935FFDB00A889F2 /* Account in Frameworks */, 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */, 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */, @@ -53,9 +62,10 @@ 9F398AB429360A5800A889F2 /* App */ = { isa = PBXGroup; children = ( - 9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */, + 9FAE4ACF29379ACA00772766 /* AppAccounts */, + 9FAE4AC9293783A200772766 /* Tabs */, + 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */, 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */, - 9F398AB229360A4C00A889F2 /* TimelineTabView.swift */, ); path = App; sourceTree = ""; @@ -69,6 +79,24 @@ path = Resources; sourceTree = ""; }; + 9FAE4AC9293783A200772766 /* Tabs */ = { + isa = PBXGroup; + children = ( + 9F398AB229360A4C00A889F2 /* TimelineTab.swift */, + 9FAE4ACA293783B000772766 /* SettingsTab.swift */, + ); + path = Tabs; + sourceTree = ""; + }; + 9FAE4ACF29379ACA00772766 /* AppAccounts */ = { + isa = PBXGroup; + children = ( + 9FAE4AD029379AD600772766 /* AppAccount.swift */, + 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */, + ); + path = AppAccounts; + sourceTree = ""; + }; 9FBFE630292A715500C250E9 = { isa = PBXGroup; children = ( @@ -94,6 +122,7 @@ 9FBFE63B292A715500C250E9 /* IceCubesApp */ = { isa = PBXGroup; children = ( + 9FAE4AC8293774FF00772766 /* Info.plist */, 9F398AB429360A5800A889F2 /* App */, 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */, 9F398AB529360A6100A889F2 /* Resources */, @@ -130,6 +159,7 @@ 9F398AA82935FFDB00A889F2 /* Account */, 9F398AAA2935FFDB00A889F2 /* Models */, 9F24EEBA293619210042359D /* Routeur */, + 9FAE4ACD29379A5A00772766 /* KeychainSwift */, ); productName = IceCubesApp; productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */; @@ -159,6 +189,9 @@ Base, ); mainGroup = 9FBFE630292A715500C250E9; + packageReferences = ( + 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */, + ); productRefGroup = 9FBFE63A292A715500C250E9 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -185,9 +218,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9F398AB329360A4C00A889F2 /* TimelineTabView.swift in Sources */, + 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */, + 9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */, + 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */, - 9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */, + 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, + 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,6 +352,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IceCubesApp/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -354,6 +391,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IceCubesApp/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -402,6 +440,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/evgenyneu/keychain-swift"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 9F24EEBA293619210042359D /* Routeur */ = { isa = XCSwiftPackageProductDependency; @@ -419,6 +468,11 @@ isa = XCSwiftPackageProductDependency; productName = Models; }; + 9FAE4ACD29379A5A00772766 /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; 9FBFE64D292A72BD00C250E9 /* Network */ = { isa = XCSwiftPackageProductDependency; productName = Network; diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e33f79a9..b86a1e17 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "00d7a9744bbd1e7762c587bbd248775e16345a65", "version" : "1.0.0" } + }, + { + "identity" : "keychain-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/evgenyneu/keychain-swift", + "state" : { + "branch" : "master", + "revision" : "32a99b537d1c6f3529a08257c28a5feb70c0c5af" + } } ], "version" : 2 diff --git a/IceCubesApp/App/AppAccounts/AppAccount.swift b/IceCubesApp/App/AppAccounts/AppAccount.swift new file mode 100644 index 00000000..690b78f7 --- /dev/null +++ b/IceCubesApp/App/AppAccounts/AppAccount.swift @@ -0,0 +1,51 @@ +import SwiftUI +import Timeline +import Network +import KeychainSwift +import Models + +struct AppAccount: Codable { + let server: String + let oauthToken: OauthToken? + + var key: String { + if let oauthToken { + return "\(server):\(oauthToken.createdAt)" + } else { + return "\(server):anonymous:\(Date().timeIntervalSince1970)" + } + } + + func save() throws { + let encoder = JSONEncoder() + let data = try encoder.encode(self) + let keychain = KeychainSwift() + keychain.set(data, forKey: key) + } + + func delete() { + KeychainSwift().delete(key) + } + + static func retrieveAll() throws -> [AppAccount] { + let keychain = KeychainSwift() + let decoder = JSONDecoder() + let keys = keychain.allKeys + var accounts: [AppAccount] = [] + for key in keys { + if let data = keychain.getData(key) { + let account = try decoder.decode(AppAccount.self, from: data) + accounts.append(account) + } + } + return accounts + } + + static func deleteAll() { + let keychain = KeychainSwift() + let keys = keychain.allKeys + for key in keys { + keychain.delete(key) + } + } +} diff --git a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift new file mode 100644 index 00000000..c4e610eb --- /dev/null +++ b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift @@ -0,0 +1,34 @@ +import SwiftUI +import Network + +class AppAccountsManager: ObservableObject { + @Published var currentAccount: AppAccount { + didSet { + currentClient = .init(server: currentAccount.server, + oauthToken: currentAccount.oauthToken) + } + } + @Published var currentClient: Client + + init() { + var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) + do { + let keychainAccounts = try AppAccount.retrieveAll() + defaultAccount = keychainAccounts.first ?? defaultAccount + } catch {} + currentAccount = defaultAccount + currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken) + } + + func add(account: AppAccount) { + do { + try account.save() + currentAccount = account + } catch { } + } + + func delete(account: AppAccount) { + account.delete() + currentAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) + } +} diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift new file mode 100644 index 00000000..e3ce9047 --- /dev/null +++ b/IceCubesApp/App/IceCubesApp.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Timeline +import Network +import KeychainSwift + +@main +struct IceCubesApp: App { + public static let defaultServer = "mastodon.world" + + @StateObject private var appAccountsManager = AppAccountsManager() + + var body: some Scene { + WindowGroup { + TabView { + TimelineTab() + .tabItem { + Label("Home", systemImage: "globe") + } + SettingsTabs() + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .environmentObject(appAccountsManager) + .environmentObject(appAccountsManager.currentClient) + } + } +} diff --git a/IceCubesApp/App/IceCubesAppApp.swift b/IceCubesApp/App/IceCubesAppApp.swift deleted file mode 100644 index ba7da798..00000000 --- a/IceCubesApp/App/IceCubesAppApp.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI -import Timeline -import Network - -@main -struct IceCubesAppApp: App { - @State private var tabs: [String] = ["mastodon.social"] - @State private var isServerSelectDisplayed: Bool = false - @State private var newServerURL: String = "" - - var body: some Scene { - WindowGroup { - TabView { - ForEach(tabs, id: \.self) { tab in - TimelineTabView(tab: tab) - .tabItem { - Label(tab, systemImage: "globe") - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - isServerSelectDisplayed.toggle() - } label: { - Image(systemName: "globe") - } - } - } - .alert("Connect to another server", isPresented: $isServerSelectDisplayed) { - TextField(tab, text: $newServerURL) - Button("Connect", action: { - tabs.append(newServerURL) - newServerURL = "" - }) - Button("Cancel", role: .cancel, action: {}) - } - } - } - } - } -} diff --git a/IceCubesApp/App/Tabs/SettingsTab.swift b/IceCubesApp/App/Tabs/SettingsTab.swift new file mode 100644 index 00000000..c5fa9267 --- /dev/null +++ b/IceCubesApp/App/Tabs/SettingsTab.swift @@ -0,0 +1,103 @@ +import SwiftUI +import Timeline +import Routeur +import Network +import Account +import Models + +struct SettingsTabs: View { + @Environment(\.openURL) private var openURL + @EnvironmentObject private var client: Client + @EnvironmentObject private var appAccountsManager: AppAccountsManager + + @State private var signInInProgress = false + @State private var accountData: Account? + @State private var signInServer = IceCubesApp.defaultServer + + var body: some View { + NavigationStack { + Form { + Section("Account") { + if let accountData { + VStack(alignment: .leading) { + Text(appAccountsManager.currentAccount.server) + .font(.headline) + Text(accountData.displayName) + Text(accountData.username) + .font(.footnote) + .foregroundColor(.gray) + } + signOutButton + } else { + TextField("Mastodon server", text: $signInServer) + signInButton + } + } + } + .onOpenURL(perform: { url in + Task { + await continueSignIn(url: url) + } + }) + .navigationTitle(Text("Settings")) + .navigationBarTitleDisplayMode(.inline) + } + .task { + if appAccountsManager.currentAccount.oauthToken != nil { + signInInProgress = true + await refreshAccountInfo() + signInInProgress = false + } + } + } + + private var signInButton: some View { + Button { + signInInProgress = true + Task { + await signIn() + } + } label: { + if signInInProgress { + ProgressView() + } else { + Text("Sign in") + } + } + } + + private var signOutButton: some View { + Button { + accountData = nil + appAccountsManager.delete(account: appAccountsManager.currentAccount) + } label: { + Text("Sign out").foregroundColor(.red) + } + + } + + private func signIn() async { + do { + client.server = signInServer + let oauthURL = try await client.oauthURL() + openURL(oauthURL) + } catch { + signInInProgress = false + } + } + + private func continueSignIn(url: URL) async { + do { + let oauthToken = try await client.continueOauthFlow(url: url) + appAccountsManager.add(account: AppAccount(server: client.server, oauthToken: oauthToken)) + await refreshAccountInfo() + signInInProgress = false + } catch { + signInInProgress = false + } + } + + private func refreshAccountInfo() async { + accountData = try? await client.get(endpoint: Accounts.verifyCredentials) + } +} diff --git a/IceCubesApp/App/TimelineTabView.swift b/IceCubesApp/App/Tabs/TimelineTab.swift similarity index 59% rename from IceCubesApp/App/TimelineTabView.swift rename to IceCubesApp/App/Tabs/TimelineTab.swift index 9ed2662b..c9d6dc21 100644 --- a/IceCubesApp/App/TimelineTabView.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -3,23 +3,14 @@ import Timeline import Routeur import Network -struct TimelineTabView: View { - let tab: String - - private let client: Client +struct TimelineTab: View { @StateObject private var routeurPath = RouterPath() - init(tab: String) { - self.tab = tab - self.client = .init(server: tab) - } - var body: some View { NavigationStack(path: $routeurPath.path) { TimelineView() .withAppRouteur() } .environmentObject(routeurPath) - .environmentObject(client) } } diff --git a/IceCubesApp/Info.plist b/IceCubesApp/Info.plist new file mode 100644 index 00000000..41332934 --- /dev/null +++ b/IceCubesApp/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + icecubesapp + CFBundleURLSchemes + + icecubesapp + + + + + diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 968003dc..dd71d0b1 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -8,7 +8,7 @@ class AccountDetailViewModel: ObservableObject { var client: Client = .init(server: "") enum State { - case loading, data(account: Models.Account), error(error: Error) + case loading, data(account: Account), error(error: Error) } @Published var state: State = .loading @@ -19,7 +19,7 @@ class AccountDetailViewModel: ObservableObject { func fetchAccount() async { do { - state = .data(account: try await client.fetch(endpoint: Network.Account.accounts(id: accountId))) + state = .data(account: try await client.get(endpoint: Accounts.accounts(id: accountId))) } catch { state = .error(error: error) } diff --git a/Packages/Models/Sources/Models/App.swift b/Packages/Models/Sources/Models/App.swift new file mode 100644 index 00000000..aab8e025 --- /dev/null +++ b/Packages/Models/Sources/Models/App.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct App: Codable, Identifiable { + public let id: String + public let name: String + public let website: URL? + public let redirectUri: String + public let clientId: String + public let clientSecret: String + public let vapidKey: String +} diff --git a/Packages/Models/Sources/Models/OauthToken.swift b/Packages/Models/Sources/Models/OauthToken.swift new file mode 100644 index 00000000..b8e3d284 --- /dev/null +++ b/Packages/Models/Sources/Models/OauthToken.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct OauthToken: Codable { + public let accessToken: String + public let tokenType: String + public let scope: String + public let createdAt: Double +} diff --git a/Packages/Network/Package.swift b/Packages/Network/Package.swift index 023af411..ab5f1d2b 100644 --- a/Packages/Network/Package.swift +++ b/Packages/Network/Package.swift @@ -13,11 +13,15 @@ let package = Package( name: "Network", targets: ["Network"]), ], - dependencies: [], + dependencies: [ + .package(name: "Models", path: "../Models"), + ], targets: [ .target( name: "Network", - dependencies: []), + dependencies: [ + .product(name: "Models", package: "Models") + ]), .testTarget( name: "NetworkTests", dependencies: ["Network"]), diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index ff5c2f5c..8f31caf3 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -1,34 +1,102 @@ import Foundation import SwiftUI +import Models public class Client: ObservableObject { public enum Version: String { case v1 } - public let server: String + public enum OauthError: Error { + case missingApp + case invalidRedirectURL + } + + public var server: String public let version: Version private let urlSession: URLSession private let decoder = JSONDecoder() - public init(server: String, version: Version = .v1) { + /// Only used as a transitionary app while in the oauth flow. + private var oauthApp: Models.App? + + private var oauthToken: OauthToken? + + public var isAuth: Bool { + oauthToken != nil + } + + public init(server: String, version: Version = .v1, oauthToken: OauthToken? = nil) { self.server = server self.version = version self.urlSession = URLSession.shared self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.oauthToken = oauthToken } private func makeURL(endpoint: Endpoint) -> URL { var components = URLComponents() components.scheme = "https" components.host = server - components.path += "/api/\(version.rawValue)/\(endpoint.path())" + if type(of: endpoint) == Oauth.self { + components.path += "/\(endpoint.path())" + } else { + components.path += "/api/\(version.rawValue)/\(endpoint.path())" + } components.queryItems = endpoint.queryItems() return components.url! } + + private func makeURLRequest(url: URL, httpMethod: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = httpMethod + if let oauthToken { + request.setValue("Bearer \(oauthToken.accessToken)", forHTTPHeaderField: "Authorization") + } + return request + } - public func fetch(endpoint: Endpoint) async throws -> Entity { - let (data, _) = try await urlSession.data(from: makeURL(endpoint: endpoint)) + public func get(endpoint: Endpoint) async throws -> Entity { + let url = makeURL(endpoint: endpoint) + let request = makeURLRequest(url: url, httpMethod: "GET") + let (data, httpResponse) = try await urlSession.data(for: request) + logResponseOnError(httpResponse: httpResponse, data: data) return try decoder.decode(Entity.self, from: data) } + + public func post(endpoint: Endpoint) async throws -> Entity { + let url = makeURL(endpoint: endpoint) + let request = makeURLRequest(url: url, httpMethod: "POST") + let (data, httpResponse) = try await urlSession.data(for: request) + logResponseOnError(httpResponse: httpResponse, data: data) + return try decoder.decode(Entity.self, from: data) + } + + public func oauthURL() async throws -> URL { + let app: Models.App = try await post(endpoint: Apps.registerApp) + self.oauthApp = app + return makeURL(endpoint: Oauth.authorize(clientId: app.clientId)) + } + + public func continueOauthFlow(url: URL) async throws -> OauthToken { + guard let app = oauthApp else { + throw OauthError.missingApp + } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let code = components.queryItems?.first(where: { $0.name == "code"})?.value else { + throw OauthError.invalidRedirectURL + } + let token: OauthToken = try await post(endpoint: Oauth.token(code: code, + clientId: app.clientId, + clientSecret: app.clientSecret)) + self.oauthToken = token + return token + } + + private func logResponseOnError(httpResponse: URLResponse, data: Data) { + if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 { + print(httpResponse) + print(String(data: data, encoding: .utf8) ?? "") + } + } } diff --git a/Packages/Network/Sources/Network/Endpoint/Account.swift b/Packages/Network/Sources/Network/Endpoint/Accounts.swift similarity index 64% rename from Packages/Network/Sources/Network/Endpoint/Account.swift rename to Packages/Network/Sources/Network/Endpoint/Accounts.swift index 51cec0aa..4a3b2da7 100644 --- a/Packages/Network/Sources/Network/Endpoint/Account.swift +++ b/Packages/Network/Sources/Network/Endpoint/Accounts.swift @@ -1,12 +1,15 @@ import Foundation -public enum Account: Endpoint { +public enum Accounts: Endpoint { case accounts(id: String) + case verifyCredentials public func path() -> String { switch self { case .accounts(let id): return "accounts/\(id)" + case .verifyCredentials: + return "accounts/verify_credentials" } } diff --git a/Packages/Network/Sources/Network/Endpoint/Apps.swift b/Packages/Network/Sources/Network/Endpoint/Apps.swift new file mode 100644 index 00000000..ceaabd65 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Apps.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum Apps: Endpoint { + case registerApp + + public func path() -> String { + switch self { + case .registerApp: + return "apps" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case .registerApp: + return [ + .init(name: "client_name", value: "IceCubesApp"), + .init(name: "redirect_uris", value: "icecubesapp://"), + .init(name: "scopes", value: "read write follow push"), + .init(name: "website", value: "https://github.com/Dimillian/IceCubesApp") + ] + } + } +} diff --git a/Packages/Network/Sources/Network/Endpoint/Oauth.swift b/Packages/Network/Sources/Network/Endpoint/Oauth.swift new file mode 100644 index 00000000..1d819ee7 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Oauth.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum Oauth: Endpoint { + case authorize(clientId: String) + case token(code: String, clientId: String, clientSecret: String) + + public func path() -> String { + switch self { + case .authorize: + return "oauth/authorize" + case .token: + return "oauth/token" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .authorize(clientId): + return [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: clientId), + .init(name: "redirect_uri", value: "icecubesapp://"), + .init(name: "scope", value: "read write follow push") + ] + case let .token(code, clientId, clientSecret): + return [ + .init(name: "grant_type", value: "authorization_code"), + .init(name: "client_id", value: clientId), + .init(name: "client_secret", value: clientSecret), + .init(name: "redirect_uri", value: "icecubesapp://"), + .init(name: "code", value: code), + .init(name: "scope", value: "read write follow push") + ] + } + } +} diff --git a/Packages/Network/Sources/Network/Endpoint/Timeline.swift b/Packages/Network/Sources/Network/Endpoint/Timelines.swift similarity index 62% rename from Packages/Network/Sources/Network/Endpoint/Timeline.swift rename to Packages/Network/Sources/Network/Endpoint/Timelines.swift index 9eec17be..91058e39 100644 --- a/Packages/Network/Sources/Network/Endpoint/Timeline.swift +++ b/Packages/Network/Sources/Network/Endpoint/Timelines.swift @@ -1,12 +1,15 @@ import Foundation -public enum Timeline: Endpoint { +public enum Timelines: Endpoint { case pub(sinceId: String?) + case home(sinceId: String?) public func path() -> String { switch self { case .pub: return "timelines/public" + case .home: + return "timelines/home" } } @@ -14,6 +17,8 @@ public enum Timeline: Endpoint { switch self { case .pub(let sinceId): return [.init(name: "max_id", value: sinceId)] + case .home(let sinceId): + return [.init(name: "max_id", value: sinceId)] } } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 4ecb2380..1fb8d8c2 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -33,8 +33,13 @@ public struct TimelineView: View { } } .listStyle(.plain) - .navigationTitle("Public Timeline: \(viewModel.serverName)") + .navigationTitle("\(viewModel.serverName)") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + timelineFilterButton + } + } .task { viewModel.client = client if !didAppear { @@ -54,4 +59,19 @@ public struct TimelineView: View { Spacer() } } + + private var timelineFilterButton: some View { + Menu { + ForEach(TimelineViewModel.TimelineFilter.allCases, id: \.self) { filter in + Button { + viewModel.timeline = filter + } label: { + Text(filter.rawValue) + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + + } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index f67be914..1ec1708d 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -13,10 +13,36 @@ class TimelineViewModel: ObservableObject { case error(error: Error) } - var client: Client = .init(server: "") + enum TimelineFilter: String, CaseIterable { + case pub = "Public" + case home = "Home" + + func endpoint(sinceId: String?) -> Timelines { + switch self { + case .pub: return .pub(sinceId: sinceId) + case .home: return .home(sinceId: sinceId) + } + } + } + + var client: Client = .init(server: "") { + didSet { + timeline = client.isAuth ? .home : .pub + } + } + private var statuses: [Status] = [] @Published var state: State = .loading + @Published var timeline: TimelineFilter = .pub { + didSet { + if oldValue != timeline { + Task { + await refreshTimeline() + } + } + } + } var serverName: String { client.server @@ -24,7 +50,8 @@ class TimelineViewModel: ObservableObject { func refreshTimeline() async { do { - statuses = try await client.fetch(endpoint: Timeline.pub(sinceId: nil)) + state = .loading + statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil)) state = .display(statuses: statuses, nextPageState: .hasNextPage) } catch { state = .error(error: error) @@ -35,7 +62,7 @@ class TimelineViewModel: ObservableObject { do { guard let lastId = statuses.last?.id else { return } state = .display(statuses: statuses, nextPageState: .loadingNextPage) - let newStatuses: [Status] = try await client.fetch(endpoint: Timeline.pub(sinceId: lastId)) + let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: lastId)) statuses.append(contentsOf: newStatuses) state = .display(statuses: statuses, nextPageState: .hasNextPage) } catch {