Compare commits

...

3 Commits

8 changed files with 194 additions and 195 deletions

View File

@ -101,6 +101,19 @@ public class Client {
return task return task
} }
public func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
run(request) { response in
switch response {
case let .failure(error):
continuation.resume(throwing: error)
case let .success(result, pagination):
continuation.resume(returning: (result, pagination))
}
}
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path components.path = request.path
@ -117,24 +130,21 @@ public class Client {
} }
// MARK: - Authorization // MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) { public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil) async throws -> RegisteredApplication {
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([ let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
"client_name" => name, "client_name" => name,
"redirect_uris" => redirectURI, "redirect_uris" => redirectURI,
"scopes" => scopes.scopeString, "scopes" => scopes.scopeString,
"website" => website?.absoluteString "website" => website?.absoluteString
])) ]))
run(request) { result in let (application, _) = try await run(request)
defer { completion(result) } self.appID = application.id
guard case let .success(application, _) = result else { return } self.clientID = application.clientID
self.clientSecret = application.clientSecret
self.appID = application.id return application
self.clientID = application.clientID
self.clientSecret = application.clientSecret
}
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String) async throws -> LoginSettings {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([ let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID, "client_id" => clientID,
"client_secret" => clientSecret, "client_secret" => clientSecret,
@ -142,12 +152,9 @@ public class Client {
"code" => authorizationCode, "code" => authorizationCode,
"redirect_uri" => redirectURI "redirect_uri" => redirectURI
])) ]))
run(request) { result in let (settings, _) = try await run(request)
defer { completion(result) } self.accessToken = settings.accessToken
guard case let .success(loginSettings, _) = result else { return } return settings
self.accessToken = loginSettings.accessToken
}
} }
// MARK: - Self // MARK: - Self

View File

@ -2342,6 +2342,7 @@
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = Pachyderm/Info.plist; INFOPLIST_FILE = Pachyderm/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -2373,6 +2374,7 @@
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = Pachyderm/Info.plist; INFOPLIST_FILE = Pachyderm/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -2560,7 +2562,7 @@
CURRENT_PROJECT_VERSION = 19; CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -2593,7 +2595,7 @@
CURRENT_PROJECT_VERSION = 19; CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@ -99,6 +99,11 @@
value = "1" value = "1"
isEnabled = "NO"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable
key = "LIBDISPATCH_COOPERATIVE_POOL_STRICT"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction

View File

@ -2,7 +2,7 @@
"object": { "object": {
"pins": [ "pins": [
{ {
"package": "PLCrashReporter", "package": "plcrashreporter",
"repositoryURL": "https://github.com/microsoft/plcrashreporter", "repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": { "state": {
"branch": null, "branch": null,

View File

@ -46,8 +46,8 @@ class MastodonController: ObservableObject {
@Published private(set) var instance: Instance! @Published private(set) var instance: Instance!
private(set) var customEmojis: [Emoji]? private(set) var customEmojis: [Emoji]?
private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]() @MainActor private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]()
private var ownInstanceRequest: URLSessionTask? @MainActor private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool { var loggedIn: Bool {
accountInfo != nil accountInfo != nil
@ -65,28 +65,22 @@ class MastodonController: ObservableObject {
return client.run(request, completion: completion) return client.run(request, completion: completion)
} }
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { @discardableResult
guard client.clientID == nil, func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
client.clientSecret == nil else { return try await client.run(request)
completion(client.clientID!, client.clientSecret!)
return
}
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in
guard case let .success(app, _) = response else { fatalError() }
self.client.clientID = app.clientID
self.client.clientSecret = app.clientSecret
completion(app.clientID, app.clientSecret)
}
} }
func authorize(authorizationCode: String, completion: @escaping (_ accessToken: String) -> Void) { func registerApp() async -> (String, String) {
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in guard client.clientID == nil,
guard case let .success(settings, _) = response else { fatalError() } client.clientSecret == nil else {
self.client.accessToken = settings.accessToken return (client.clientID!, client.clientSecret!)
completion(settings.accessToken) }
} let app = try! await client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow])
return (app.clientID, app.clientSecret)
}
func authorize(authorizationCode: String) async -> String {
return try! await client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth").accessToken
} }
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
@ -118,14 +112,19 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnAccount() async throws -> Account {
return try await withCheckedThrowingContinuation { continuation in
getOwnAccount(completion: continuation.resume)
}
}
@MainActor
func getOwnInstance(completion: ((Instance) -> Void)? = nil) { func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0, completion: completion) getOwnInstanceInternal(retryAttempt: 0, completion: completion)
} }
@MainActor
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) { private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
if let instance = self.instance { if let instance = self.instance {
completion?(instance) completion?(instance)
} else { } else {

View File

@ -104,6 +104,14 @@ enum CompositionAttachmentData {
} }
} }
func getData() async -> (Data, String) {
return await withCheckedContinuation { continuation in
getData { data, mimeType in
continuation.resume(returning: (data, mimeType))
}
}
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) { private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) {
session.outputFileType = .mp4 session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")

View File

@ -19,7 +19,7 @@ struct ComposeView: View {
@State private var postProgress: Double = 0 @State private var postProgress: Double = 0
@State private var postTotalProgress: Double = 0 @State private var postTotalProgress: Double = 0
@State private var isShowingPostErrorAlert = false @State private var isShowingPostErrorAlert = false
@State private var postError: PostError? @State private var postError: Error?
private let stackPadding: CGFloat = 8 private let stackPadding: CGFloat = 8
@ -148,7 +148,11 @@ struct ComposeView: View {
} }
private var postButton: some View { private var postButton: some View {
Button(action: self.postStatus) { Button {
async {
await self.postStatus()
}
} label: {
Text("Post") Text("Post")
} }
.disabled(!postButtonEnabled) .disabled(!postButtonEnabled)
@ -190,7 +194,7 @@ struct ComposeView: View {
]) ])
} }
private func postStatus() { private func postStatus() async {
guard draft.hasContent else { return } guard draft.hasContent else { return }
isPosting = true isPosting = true
@ -205,70 +209,57 @@ struct ComposeView: View {
postTotalProgress = Double(2 + (draft.attachments.count * 2)) postTotalProgress = Double(2 + (draft.attachments.count * 2))
postProgress = 1 postProgress = 1
uploadAttachments { (result) in let uploadedAttachments: [Attachment]
switch result { do {
case let .failure(error): uploadedAttachments = try await uploadAttachments()
self.isShowingPostErrorAlert = true } catch {
self.postError = error self.isShowingPostErrorAlert = true
self.postProgress = 0 self.postError = error
self.postTotalProgress = 0 self.postProgress = 0
self.isPosting = false self.postTotalProgress = 0
self.isPosting = false
return
}
case let .success(uploadedAttachments): let request = Client.createStatus(text: draft.textForPosting,
let request = Client.createStatus(text: draft.textForPosting, contentType: Preferences.shared.statusContentType,
contentType: Preferences.shared.statusContentType, inReplyTo: draft.inReplyToID,
inReplyTo: draft.inReplyToID, media: uploadedAttachments,
media: uploadedAttachments, sensitive: sensitive,
sensitive: sensitive, spoilerText: contentWarning,
spoilerText: contentWarning, visibility: draft.visibility,
visibility: draft.visibility, language: nil,
language: nil, pollOptions: draft.poll?.options.map(\.text),
pollOptions: draft.poll?.options.map(\.text), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollMultiple: draft.poll?.multiple)
pollMultiple: draft.poll?.multiple) do {
self.mastodonController.run(request) { (response) in try await mastodonController.run(request)
switch response { self.postProgress += 1
case let .failure(error):
self.isShowingPostErrorAlert = true
self.postError = error
case .success(_, _): DraftsManager.shared.remove(self.draft)
self.postProgress += 1
DraftsManager.shared.remove(self.draft) // wait .25 seconds so the user can see the progress bar has completed
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
// wait .25 seconds so the user can see the progress bar has completed self.uiState.delegate?.dismissCompose(mode: .post)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
self.uiState.delegate?.dismissCompose(mode: .post)
}
}
}
} }
} catch {
self.isShowingPostErrorAlert = true
self.postError = error
} }
} }
private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) { private func uploadAttachments() async throws -> [Attachment] {
let group = DispatchGroup() guard !draft.attachments.isEmpty else {
return []
var attachmentDatas = [(Data, String)?]()
for (index, compAttachment) in draft.attachments.enumerated() {
group.enter()
attachmentDatas.append(nil)
compAttachment.data.getData { (data, mimeType) in
postProgress += 1
attachmentDatas[index] = (data, mimeType)
group.leave()
}
} }
group.notify(queue: .global(qos: .userInitiated)) { return try await withThrowingTaskGroup(of: (CompositionAttachment, (Data, String)).self) { taskGroup in
for compAttachment in draft.attachments {
var anyFailed = false postProgress += 1
var uploadedAttachments = [Result<Attachment, Error>?]() taskGroup.async {
return (compAttachment, await compAttachment.data.getData())
}
}
// Mastodon does not respect the order of the `media_ids` parameter in the create post request, // Mastodon does not respect the order of the `media_ids` parameter in the create post request,
// it determines attachment order by which was uploaded first. Since the upload attachment request // it determines attachment order by which was uploaded first. Since the upload attachment request
@ -277,67 +268,40 @@ struct ComposeView: View {
// posted status reflects order the user set. // posted status reflects order the user set.
// Pleroma does respect the order of the `media_ids` parameter. // Pleroma does respect the order of the `media_ids` parameter.
for (index, (data, mimeType)) in attachmentDatas.map(\.unsafelyUnwrapped).enumerated() { var anyFailed = false
group.enter() var uploaded = [Result<Attachment, Error>]()
let compAttachment = draft.attachments[index] for try await (compAttachment, (data, mimeType)) in taskGroup {
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
uploadedAttachments.append(.failure(error))
anyFailed = true
case let .success(attachment, _): do {
self.postProgress += 1 let (uploadedAttachment, _) = try await mastodonController.run(request)
uploadedAttachments.append(.success(attachment)) uploaded.append(.success(uploadedAttachment))
} postProgress += 1
} catch {
group.leave() uploaded.append(.failure(error))
anyFailed = true
} }
group.wait()
} }
if anyFailed { if anyFailed {
let errors = uploadedAttachments.map { (result) -> Error? in let errors = uploaded.map { result -> Error? in
if case let .failure(error) = result { if case let .failure(error) = result {
return error return error
} else { } else {
return nil return nil
} }
} }
completion(.failure(AttachmentUploadError(errors: errors))) throw AttachmentUploadError(errors: errors)
} else { } else {
let uploadedAttachments = uploadedAttachments.map { return uploaded.map { try! $0.get() }
try! $0!.get()
}
completion(.success(uploadedAttachments))
} }
} }
} }
} }
fileprivate protocol PostError: LocalizedError {} fileprivate struct AttachmentUploadError: LocalizedError {
extension PostError {
var localizedDescription: String {
if let self = self as? Client.Error {
return self.localizedDescription
} else if let self = self as? AttachmentUploadError {
return self.localizedDescription
} else {
return "Unknown Error"
}
}
}
extension Client.Error: PostError {}
fileprivate struct AttachmentUploadError: PostError {
let errors: [Error?] let errors: [Error?]
var localizedDescription: String { var localizedDescription: String {

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import AuthenticationServices import AuthenticationServices
import Pachyderm
protocol OnboardingViewControllerDelegate { protocol OnboardingViewControllerDelegate {
func didFinishOnboarding(account: LocalData.UserAccountInfo) func didFinishOnboarding(account: LocalData.UserAccountInfo)
@ -41,53 +42,19 @@ class OnboardingViewController: UINavigationController {
instanceSelector.delegate = self instanceSelector.delegate = self
} }
} private func doAuthenticationSession(url: URL) async throws -> URL {
return try await withCheckedThrowingContinuation { continuation in
extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate { self.authenticationSession = ASWebAuthenticationSession(url: url, callbackURLScheme: "tusker") { url, error in
func didSelectInstance(url instanceURL: URL) { defer {
let mastodonController = MastodonController(instanceURL: instanceURL) DispatchQueue.main.async {
mastodonController.registerApp { (clientID, clientSecret) in self.authenticationSession = nil
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
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: "tusker://oauth")
]
let authorizeURL = components.url!
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker") { url, error in
guard error == nil,
let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let authCode = item.value else { return }
mastodonController.authorize(authorizationCode: authCode) { (accessToken) in
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch it's own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
mastodonController.getOwnAccount { (result) in
DispatchQueue.main.async {
switch result {
case let .failure(error):
let alert = UIAlertController(title: "Unable to Verify Credentials", message: "Your account could not be fetched at this time: \(error.localizedDescription)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
self.present(alert, animated: true)
case let .success(account):
// this needs to happen on the main thread because it publishes a new value for the ObservableObject
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
}
}
} }
} }
if let url = url {
continuation.resume(returning: url)
} else {
continuation.resume(throwing: error!)
}
} }
DispatchQueue.main.async { DispatchQueue.main.async {
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance. // Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
@ -97,6 +64,53 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
} }
} }
} }
}
extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate {
func didSelectInstance(url instanceURL: URL) {
async {
let mastodonController = MastodonController(instanceURL: instanceURL)
let (clientID, clientSecret) = await mastodonController.registerApp()
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
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: "tusker://oauth")
]
guard let url = try? await self.doAuthenticationSession(url: components.url!),
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let authCode = item.value else {
return
}
let accessToken = await mastodonController.authorize(authorizationCode: authCode)
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
let account: Account
do {
account = try await mastodonController.getOwnAccount()
} catch {
let alert = UIAlertController(title: "Unable to Verify Credentials", message: "Your account fcould not be fetcheda this time: \(error.localizedDescription)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
self.present(alert, animated: true)
return
}
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
}
}
} }
extension OnboardingViewController: ASWebAuthenticationPresentationContextProviding { extension OnboardingViewController: ASWebAuthenticationPresentationContextProviding {