OAuth + Home timeline

This commit is contained in:
Thomas Ricouard 2022-12-01 09:05:26 +01:00
parent eb92379ac7
commit df2d383b8a
20 changed files with 528 additions and 73 deletions

View file

@ -13,8 +13,12 @@
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */; }; 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */; };
9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; }; 9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; };
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; }; 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; };
9F398AB329360A4C00A889F2 /* TimelineTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTabView.swift */; }; 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTab.swift */; };
9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesAppApp.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 */; }; 9FBFE641292A715600C250E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FBFE640292A715600C250E9 /* Assets.xcassets */; };
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; }; 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -27,9 +31,13 @@
9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = "<group>"; }; 9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = "<group>"; };
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; }; 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; };
9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = "<group>"; }; 9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = "<group>"; };
9F398AB229360A4C00A889F2 /* TimelineTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTabView.swift; sourceTree = "<group>"; }; 9F398AB229360A4C00A889F2 /* TimelineTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTab.swift; sourceTree = "<group>"; };
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = "<group>"; };
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountsManager.swift; sourceTree = "<group>"; };
9FBFE639292A715500C250E9 /* IceCubesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IceCubesApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = "<group>"; };
9FBFE640292A715600C250E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 9FBFE640292A715600C250E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; }; 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -39,6 +47,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
9F398AA92935FFDB00A889F2 /* Account in Frameworks */, 9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */, 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */, 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */,
@ -53,9 +62,10 @@
9F398AB429360A5800A889F2 /* App */ = { 9F398AB429360A5800A889F2 /* App */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */, 9FAE4ACF29379ACA00772766 /* AppAccounts */,
9FAE4AC9293783A200772766 /* Tabs */,
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */,
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */, 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */,
9F398AB229360A4C00A889F2 /* TimelineTabView.swift */,
); );
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
@ -69,6 +79,24 @@
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
9FAE4AC9293783A200772766 /* Tabs */ = {
isa = PBXGroup;
children = (
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
9FAE4ACA293783B000772766 /* SettingsTab.swift */,
);
path = Tabs;
sourceTree = "<group>";
};
9FAE4ACF29379ACA00772766 /* AppAccounts */ = {
isa = PBXGroup;
children = (
9FAE4AD029379AD600772766 /* AppAccount.swift */,
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */,
);
path = AppAccounts;
sourceTree = "<group>";
};
9FBFE630292A715500C250E9 = { 9FBFE630292A715500C250E9 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -94,6 +122,7 @@
9FBFE63B292A715500C250E9 /* IceCubesApp */ = { 9FBFE63B292A715500C250E9 /* IceCubesApp */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9FAE4AC8293774FF00772766 /* Info.plist */,
9F398AB429360A5800A889F2 /* App */, 9F398AB429360A5800A889F2 /* App */,
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */, 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */,
9F398AB529360A6100A889F2 /* Resources */, 9F398AB529360A6100A889F2 /* Resources */,
@ -130,6 +159,7 @@
9F398AA82935FFDB00A889F2 /* Account */, 9F398AA82935FFDB00A889F2 /* Account */,
9F398AAA2935FFDB00A889F2 /* Models */, 9F398AAA2935FFDB00A889F2 /* Models */,
9F24EEBA293619210042359D /* Routeur */, 9F24EEBA293619210042359D /* Routeur */,
9FAE4ACD29379A5A00772766 /* KeychainSwift */,
); );
productName = IceCubesApp; productName = IceCubesApp;
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */; productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
@ -159,6 +189,9 @@
Base, Base,
); );
mainGroup = 9FBFE630292A715500C250E9; mainGroup = 9FBFE630292A715500C250E9;
packageReferences = (
9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */,
);
productRefGroup = 9FBFE63A292A715500C250E9 /* Products */; productRefGroup = 9FBFE63A292A715500C250E9 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@ -185,9 +218,12 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( 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 */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */,
9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -316,6 +352,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesApp/Info.plist;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -354,6 +391,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesApp/Info.plist;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -402,6 +440,17 @@
}; };
/* End XCConfigurationList section */ /* 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 */ /* Begin XCSwiftPackageProductDependency section */
9F24EEBA293619210042359D /* Routeur */ = { 9F24EEBA293619210042359D /* Routeur */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
@ -419,6 +468,11 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Models; productName = Models;
}; };
9FAE4ACD29379A5A00772766 /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
9FBFE64D292A72BD00C250E9 /* Network */ = { 9FBFE64D292A72BD00C250E9 /* Network */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Network; productName = Network;

View file

@ -8,6 +8,15 @@
"revision" : "00d7a9744bbd1e7762c587bbd248775e16345a65", "revision" : "00d7a9744bbd1e7762c587bbd248775e16345a65",
"version" : "1.0.0" "version" : "1.0.0"
} }
},
{
"identity" : "keychain-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/evgenyneu/keychain-swift",
"state" : {
"branch" : "master",
"revision" : "32a99b537d1c6f3529a08257c28a5feb70c0c5af"
}
} }
], ],
"version" : 2 "version" : 2

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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: {})
}
}
}
}
}
}

View file

@ -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)
}
}

View file

@ -3,23 +3,14 @@ import Timeline
import Routeur import Routeur
import Network import Network
struct TimelineTabView: View { struct TimelineTab: View {
let tab: String
private let client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
init(tab: String) {
self.tab = tab
self.client = .init(server: tab)
}
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
TimelineView() TimelineView()
.withAppRouteur() .withAppRouteur()
} }
.environmentObject(routeurPath) .environmentObject(routeurPath)
.environmentObject(client)
} }
} }

19
IceCubesApp/Info.plist Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>icecubesapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>icecubesapp</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -8,7 +8,7 @@ class AccountDetailViewModel: ObservableObject {
var client: Client = .init(server: "") var client: Client = .init(server: "")
enum State { enum State {
case loading, data(account: Models.Account), error(error: Error) case loading, data(account: Account), error(error: Error)
} }
@Published var state: State = .loading @Published var state: State = .loading
@ -19,7 +19,7 @@ class AccountDetailViewModel: ObservableObject {
func fetchAccount() async { func fetchAccount() async {
do { 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 { } catch {
state = .error(error: error) state = .error(error: error)
} }

View file

@ -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
}

View file

@ -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
}

View file

@ -13,11 +13,15 @@ let package = Package(
name: "Network", name: "Network",
targets: ["Network"]), targets: ["Network"]),
], ],
dependencies: [], dependencies: [
.package(name: "Models", path: "../Models"),
],
targets: [ targets: [
.target( .target(
name: "Network", name: "Network",
dependencies: []), dependencies: [
.product(name: "Models", package: "Models")
]),
.testTarget( .testTarget(
name: "NetworkTests", name: "NetworkTests",
dependencies: ["Network"]), dependencies: ["Network"]),

View file

@ -1,34 +1,102 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Models
public class Client: ObservableObject { public class Client: ObservableObject {
public enum Version: String { public enum Version: String {
case v1 case v1
} }
public let server: String public enum OauthError: Error {
case missingApp
case invalidRedirectURL
}
public var server: String
public let version: Version public let version: Version
private let urlSession: URLSession private let urlSession: URLSession
private let decoder = JSONDecoder() 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.server = server
self.version = version self.version = version
self.urlSession = URLSession.shared self.urlSession = URLSession.shared
self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.oauthToken = oauthToken
} }
private func makeURL(endpoint: Endpoint) -> URL { private func makeURL(endpoint: Endpoint) -> URL {
var components = URLComponents() var components = URLComponents()
components.scheme = "https" components.scheme = "https"
components.host = server components.host = server
if type(of: endpoint) == Oauth.self {
components.path += "/\(endpoint.path())"
} else {
components.path += "/api/\(version.rawValue)/\(endpoint.path())" components.path += "/api/\(version.rawValue)/\(endpoint.path())"
}
components.queryItems = endpoint.queryItems() components.queryItems = endpoint.queryItems()
return components.url! return components.url!
} }
public func fetch<Entity: Codable>(endpoint: Endpoint) async throws -> Entity { private func makeURLRequest(url: URL, httpMethod: String) -> URLRequest {
let (data, _) = try await urlSession.data(from: makeURL(endpoint: endpoint)) var request = URLRequest(url: url)
request.httpMethod = httpMethod
if let oauthToken {
request.setValue("Bearer \(oauthToken.accessToken)", forHTTPHeaderField: "Authorization")
}
return request
}
public func get<Entity: Decodable>(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) return try decoder.decode(Entity.self, from: data)
} }
public func post<Entity: Decodable>(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) ?? "")
}
}
} }

View file

@ -1,12 +1,15 @@
import Foundation import Foundation
public enum Account: Endpoint { public enum Accounts: Endpoint {
case accounts(id: String) case accounts(id: String)
case verifyCredentials
public func path() -> String { public func path() -> String {
switch self { switch self {
case .accounts(let id): case .accounts(let id):
return "accounts/\(id)" return "accounts/\(id)"
case .verifyCredentials:
return "accounts/verify_credentials"
} }
} }

View file

@ -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")
]
}
}
}

View file

@ -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")
]
}
}
}

View file

@ -1,12 +1,15 @@
import Foundation import Foundation
public enum Timeline: Endpoint { public enum Timelines: Endpoint {
case pub(sinceId: String?) case pub(sinceId: String?)
case home(sinceId: String?)
public func path() -> String { public func path() -> String {
switch self { switch self {
case .pub: case .pub:
return "timelines/public" return "timelines/public"
case .home:
return "timelines/home"
} }
} }
@ -14,6 +17,8 @@ public enum Timeline: Endpoint {
switch self { switch self {
case .pub(let sinceId): case .pub(let sinceId):
return [.init(name: "max_id", value: sinceId)] return [.init(name: "max_id", value: sinceId)]
case .home(let sinceId):
return [.init(name: "max_id", value: sinceId)]
} }
} }
} }

View file

@ -33,8 +33,13 @@ public struct TimelineView: View {
} }
} }
.listStyle(.plain) .listStyle(.plain)
.navigationTitle("Public Timeline: \(viewModel.serverName)") .navigationTitle("\(viewModel.serverName)")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
timelineFilterButton
}
}
.task { .task {
viewModel.client = client viewModel.client = client
if !didAppear { if !didAppear {
@ -54,4 +59,19 @@ public struct TimelineView: View {
Spacer() 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")
}
}
} }

View file

@ -13,10 +13,36 @@ class TimelineViewModel: ObservableObject {
case error(error: Error) 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] = [] private var statuses: [Status] = []
@Published var state: State = .loading @Published var state: State = .loading
@Published var timeline: TimelineFilter = .pub {
didSet {
if oldValue != timeline {
Task {
await refreshTimeline()
}
}
}
}
var serverName: String { var serverName: String {
client.server client.server
@ -24,7 +50,8 @@ class TimelineViewModel: ObservableObject {
func refreshTimeline() async { func refreshTimeline() async {
do { 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) state = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch { } catch {
state = .error(error: error) state = .error(error: error)
@ -35,7 +62,7 @@ class TimelineViewModel: ObservableObject {
do { do {
guard let lastId = statuses.last?.id else { return } guard let lastId = statuses.last?.id else { return }
state = .display(statuses: statuses, nextPageState: .loadingNextPage) 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) statuses.append(contentsOf: newStatuses)
state = .display(statuses: statuses, nextPageState: .hasNextPage) state = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch { } catch {