Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

v2.5.1 #61

Merged
merged 60 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
be35efb
xcode junk
juliand665 May 14, 2022
2a22329
tiny updates
juliand665 May 14, 2022
655e853
fix wipe on second launch
juliand665 May 14, 2022
dd2b193
version bump
juliand665 May 14, 2022
a3ed888
stop trying to upload images for deleted issues
juliand665 Jul 24, 2022
7e182df
version bump
juliand665 Jul 24, 2022
d7b6d11
reword error details viewer
juliand665 Oct 29, 2022
02b0c66
xcode update
juliand665 Oct 29, 2022
07f1ae6
update packages
juliand665 Oct 29, 2022
a5f5152
version bump
juliand665 Oct 29, 2022
79018eb
allow toggling client mode in map view
juliand665 Jan 15, 2023
9d5d297
use the static convenience methods
juliand665 Jan 17, 2023
f7542d2
filter by craftsman!
juliand665 Jan 17, 2023
14820db
apple accent color to swiftui previews too
juliand665 Jan 18, 2023
762f0f4
allow changing issue client mode
juliand665 Jan 18, 2023
4eea98f
sort craftsmen by name
juliand665 Jan 18, 2023
b67f42e
correctly tint switch
juliand665 Jan 18, 2023
c21c759
allow filtering issues without craftsman
juliand665 Jan 18, 2023
e8f5af5
issue repositioning!
juliand665 Jan 19, 2023
8722e6b
allow switching client mode for registered issues too
juliand665 Jan 20, 2023
1916cad
update CI
juliand665 Jan 20, 2023
6ea170d
version bump
juliand665 Jan 28, 2023
c72be2e
faster pdf loading
juliand665 Mar 17, 2023
c82ae8a
localization updates
juliand665 Mar 17, 2023
b0eafc7
simplify keyboard avoidance
juliand665 Dec 28, 2023
902f468
switch to async/await!
juliand665 Jan 27, 2024
df3fc1f
version bump
juliand665 Jan 27, 2024
7d68e33
haptics on qr scan
juliand665 Jan 27, 2024
2a9d5b1
improve scrolling suggestions to visible
juliand665 Jan 27, 2024
f3feebe
remove no-longer-needed observation
juliand665 Jan 27, 2024
0c9787b
switch to swift-algorithms for n-max algorithm
juliand665 Jan 27, 2024
af46cbd
remove some "Any" protocols
juliand665 Jan 27, 2024
8388955
fix issues not saving immediately
juliand665 Jan 29, 2024
728764e
address todo
juliand665 Jan 29, 2024
d103904
update to GRDB 6
juliand665 Jan 29, 2024
dec0a79
don't treat communication errors as per-issue errors
juliand665 Jan 29, 2024
fe9cabd
tiny fix
juliand665 Jan 29, 2024
869a6b9
update github actions
juliand665 Jan 29, 2024
f57dab3
actually use latest macOS lmao
juliand665 Jan 29, 2024
94945ef
minor tweaks
juliand665 Feb 2, 2024
9d7537a
switch to package for communication
juliand665 Feb 2, 2024
b4bbc14
activate closest issue when tapping near it
juliand665 Feb 2, 2024
a288b8c
request configuration adjustments
juliand665 Feb 2, 2024
0f699d8
update github actions version
juliand665 Mar 29, 2024
661f214
tiny xcode thing
juliand665 Mar 29, 2024
604ebf3
get rid of Repository singleton!
juliand665 Mar 29, 2024
fc77ca9
enable in-memory databases
juliand665 Mar 29, 2024
c3e2102
update grdb
juliand665 Mar 29, 2024
70865aa
also get rid of client singleton
juliand665 Mar 30, 2024
2a46cfc
version bump
juliand665 Mar 30, 2024
7108e2b
make issue map correctly non-nil
juliand665 Mar 30, 2024
bc016d0
tiny renames
juliand665 Apr 2, 2024
5dd6d95
file handling fixes
juliand665 Apr 2, 2024
881789f
add some unit tests!
juliand665 Apr 2, 2024
e943093
add new privacy info
juliand665 Apr 3, 2024
0a34e1d
improve handling of unpositioned issues
juliand665 Apr 4, 2024
190254a
further tweaks to unpositioned handling
juliand665 Apr 4, 2024
0d35161
validate issue dependencies before inserting into DB
Jun 23, 2024
9bceaa2
include deleted objects in issue validation check
Jun 23, 2024
ac3c825
fix whitespace
Jun 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ on: [push, pull_request]

jobs:
build:
runs-on: macos-11
runs-on: macos-14
timeout-minutes: 30
steps:
- name: Check out
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: true
- name: Run build script
Expand Down
165 changes: 73 additions & 92 deletions Issue Manager/Back End/Client.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
// Created by Julian Dunskus

import Foundation
import Promise
import UserDefault
import Protoquest
import HandyOperators

typealias TaskResult = (data: Data, response: HTTPURLResponse)

@MainActor
final class Client {
static let shared = Client()
static let dateFormatter = ISO8601DateFormatter()

private static let baseServerURL = URL(string: "https://app.baupen.ch")!
nonisolated static let dateFormatter = ISO8601DateFormatter()

@UserDefault("client.loginInfo") var loginInfo: LoginInfo?

Expand All @@ -20,111 +18,103 @@ final class Client {

var isLoggedIn: Bool { loginInfo != nil && localUser != nil }

private let urlSession = URLSession.shared

private let requestEncoder = JSONEncoder() <- {
$0.dateEncodingStrategy = .iso8601
}
private let responseDecoder = JSONDecoder() <- {
$0.dateDecodingStrategy = .iso8601
}

/// any dependent requests are executed on this queue, so as to avoid bad interleavings and races and such
private let linearQueue = DispatchQueue(label: "dependent request execution")
private let _makeContext: @Sendable (Client, LoginInfo?) -> any RequestContext

func assertOnLinearQueue() {
dispatchPrecondition(condition: .onQueue(linearQueue))
nonisolated init(
makeContext: @escaping @Sendable (Client, LoginInfo?) -> any RequestContext = {
DefaultRequestContext(client: $0, loginInfo: $1)
} // can't use pointfree reference to DefaultRequestContext.init because it's not sendable
) {
self._makeContext = makeContext
}

private init() {}

func wipeAllData() {
loginInfo = nil
localUser = nil
}

func send<R: Request>(_ request: R) -> Future<R.Response> {
Future { try urlRequest(for: request) }
.flatMap { rawRequest in
let bodyDesc = rawRequest.httpBody
.map { "\"\(debugRepresentation(of: $0))\"" }
?? "request without body"
print("\(request.path): \(rawRequest.httpMethod!)ing \(bodyDesc) to \(rawRequest.url!)")

return self.send(rawRequest)
.catch { _ in print("\(request.path): failed!") }
}
.map { try self.extractData(from: $0, for: request) }
@discardableResult
func updateLocalUser(from repository: Repository) -> ConstructionManager? {
localUser = localUser.flatMap { repository.object($0.id) }
return localUser
}

func pushChangesThen<T>(perform task: @escaping () throws -> T) -> Future<T> {
Future(asyncOn: linearQueue) {
let errors = self.synchronouslyPushLocalChanges()
guard errors.isEmpty else {
throw RequestError.pushFailed(errors)
/// Provides a context for performing authenticated requests.
///
/// - Note: This is a method (not a property) to encourage reusing it when important, since the `await` that would usually notify of the main actor hop is already expected for the `send` that usually follows.
/// If you're not on the main actor, getting this context cannot be done synchronously and would thus silently introduce a main actor hop with every `await client.send(...)`.
func makeContext() -> any RequestContext {
_makeContext(self, loginInfo)
}
}

protocol RequestContext: Sendable {
var client: Client { get }
var loginInfo: LoginInfo? { get }

func send<R: BaupenRequest>(_ request: R) async throws -> R.Response
}

struct DefaultRequestContext: RequestContext {
let client: Client
let loginInfo: LoginInfo?

func send<R: BaupenRequest>(_ request: R) async throws -> R.Response {
let layer = Protolayer.urlSession(baseURL: loginInfo?.origin ?? baseServerURL)
.wrapErrors(RequestError.communicationError(_:))
.printExchanges()
.transformRequest { request in
if let token = loginInfo?.token {
request.setValue(token, forHTTPHeaderField: "X-Authentication")
}
}
return try task()
.readResponse(handleErrors(in:))

do {
return try await layer.send(request)
} catch {
print("\(request.path): failed!")
throw error
}
}

private func extractData<R: Request>(from taskResult: TaskResult, for request: R) throws -> R.Response {
let (data, response) = taskResult
print("\(request.path): status code: \(response.statusCode), body: \(debugRepresentation(of: data))")

switch response.statusCode {
private func handleErrors(in response: Protoresponse) throws {
switch response.httpMetadata!.statusCode {
case 200..<300:
return try request.decode(from: data, using: responseDecoder)
break // success
case 401:
throw RequestError.notAuthenticated
case let statusCode:
var hydraError: HydraError?
if
let hydraError: HydraError? = if
response.contentType?.hasPrefix("application/ld+json") == true,
let metadata = try? responseDecoder.decode(HydraMetadata.self, from: data),
let metadata = try? response.decodeJSON(as: HydraMetadata.self, using: responseDecoder),
metadata.type == HydraError.type
{
hydraError = try? responseDecoder.decode(from: data)
}
try? response.decodeJSON(using: responseDecoder)
} else { nil }
throw RequestError.apiError(hydraError, statusCode: statusCode)
}
}

private func urlRequest<R: Request>(for request: R) throws -> URLRequest {
try URLRequest(url: apiURL(for: request)) <- { rawRequest in
rawRequest.httpMethod = R.httpMethod
try request.encode(using: requestEncoder, into: &rawRequest)
if let token = loginInfo?.token {
rawRequest.setValue(token, forHTTPHeaderField: "X-Authentication")
}
if let contentType = R.contentType {
rawRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
}
}

private func apiURL<R: Request>(for request: R) -> URL {
(URLComponents(
url: request.baseURLOverride ?? loginInfo?.origin ?? Self.baseServerURL,
resolvingAgainstBaseURL: false
)! <- {
$0.percentEncodedPath += request.path
$0.queryItems = request.collectURLQueryItems()
.map { URLQueryItem(name: $0, value: "\($1)") }
.nonEmptyOptional
}).url!
}

private func send(_ request: URLRequest) -> Future<TaskResult> {
urlSession.dataTask(with: request)
.transformError { _, error in throw RequestError.communicationError(error) }
}
}

extension HTTPURLResponse {
var contentType: String? {
allHeaderFields["Content-Type"] as? String
}
protocol BaupenRequest: Request, Sendable where Response: Sendable {}
extension BaupenRequest where Self: JSONEncodingRequest {
var encoderOverride: JSONEncoder? { requestEncoder }
}
extension BaupenRequest where Self: MultipartEncodingRequest {
var encoderOverride: JSONEncoder? { requestEncoder }
}
extension BaupenRequest where Self: JSONDecodingRequest {
var decoderOverride: JSONDecoder? { responseDecoder }
}

private let requestEncoder = JSONEncoder() <- {
$0.dateEncodingStrategy = .iso8601
}
private let responseDecoder = JSONDecoder() <- {
$0.dateDecodingStrategy = .iso8601
}
private let baseServerURL = URL(string: "https://app.baupen.ch")!

/// An error that occurs while interfacing with the server.
enum RequestError: Error {
Expand All @@ -140,14 +130,5 @@ enum RequestError: Error {
// TODO: reimplement outdated client logic
}

fileprivate func debugRepresentation(of data: Data, maxLength: Int = 5000) -> String {
guard data.count <= maxLength else { return "<\(data.count) bytes>" }

return String(bytes: data, encoding: .utf8)?
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
?? "<\(data.count) bytes not UTF-8 decodable data>"
}

extension LoginInfo: DefaultsValueConvertible {}
extension ConstructionManager: DefaultsValueConvertible {}
12 changes: 6 additions & 6 deletions Issue Manager/Back End/Concrete Requests/DownloadFile.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct FileDownloadRequest: GetDataRequest {
private struct FileDownloadRequest: GetDataRequest, BaupenRequest {
var path: String

var size: String? = "full"
Expand All @@ -12,15 +12,15 @@ private struct FileDownloadRequest: GetDataRequest {
path = file.urlPath
}

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let size = size {
("size", size)
}
}
}

extension Client {
func download(_ file: AnyFile) -> Future<Data> {
send(FileDownloadRequest(file: file))
extension RequestContext {
func download(_ file: AnyFile) async throws -> Data {
try await send(FileDownloadRequest(file: file))
}
}
11 changes: 6 additions & 5 deletions Issue Manager/Back End/Concrete Requests/GetObjects.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Protoquest

struct GetObjectRequest<Object: StoredObject>: GetJSONRequest {
struct GetObjectRequest<Object: StoredObject>: GetJSONRequest, BaupenRequest {
typealias Response = APIObject<Object.Model>

var path: String
Expand All @@ -14,15 +15,15 @@ extension GetObjectRequest {
}
}

struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest {
struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest, BaupenRequest {
typealias Response = HydraCollection<APIObject<Object.Model>>

var path: String { Object.apiPath }

var constructionSite: ConstructionSite.ID?
var minLastChangeTime: Date?

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let constructionSite = constructionSite {
("constructionSite", constructionSite.apiString)
}
Expand All @@ -33,7 +34,7 @@ struct GetObjectsRequest<Object: StoredObject>: GetJSONRequest {
}
}

struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest {
struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest, BaupenRequest {
typealias Response = PagedHydraCollection<APIObject<Model>>

var path: String { Model.Object.apiPath }
Expand All @@ -43,7 +44,7 @@ struct GetPagedObjectsRequest<Model: APIModel>: GetJSONRequest {
var page = 1
var itemsPerPage = 1000

func collectURLQueryItems() -> [(String, Any)] {
var urlParams: [URLParameter] {
if let constructionSite = constructionSite {
("constructionSite", constructionSite.apiString)
}
Expand Down
37 changes: 22 additions & 15 deletions Issue Manager/Back End/Concrete Requests/Login.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct SelfRequest: GetJSONRequest {
private struct SelfRequest: GetJSONRequest, BaupenRequest {
let path = "/api/me"

struct Response: Decodable {
Expand All @@ -12,19 +12,26 @@ private struct SelfRequest: GetJSONRequest {
}

extension Client {
func logIn(with loginInfo: LoginInfo) -> Future<Void> {
func logIn(with loginInfo: LoginInfo, repository: Repository) async throws {
self.loginInfo = loginInfo
return send(SelfRequest())
.map { GetObjectRequest(for: $0.constructionManagerIri.makeID()) }
.flatMap(send)
.map {
self.loginInfo = loginInfo // set again in case something else changed it since
let user = $0.makeObject()
self.localUser = user
Repository.shared.signedIn(as: user)
}
.catch { _ in
self.loginInfo = nil
}
do {
let context = makeContext()
let userID = try await context.send(SelfRequest()).constructionManagerIri.makeID()
let user = try await context.send(GetObjectRequest(for: userID)).makeObject()
self.loginInfo = loginInfo // set again in case something else changed it since
self.localUser = user
repository.signedIn(as: user)
} catch {
self.loginInfo = nil
throw error
}
}

#if DEBUG
func mockLogIn(loginInfo: LoginInfo, user: ConstructionManager, repository: Repository) {
self.loginInfo = loginInfo
self.localUser = user
repository.signedIn(as: user)
}
#endif
}
8 changes: 4 additions & 4 deletions Issue Manager/Back End/Concrete Requests/Register.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Created by Julian Dunskus

import Foundation
import Promise
import Protoquest

private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest {
private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest, BaupenRequest {
var baseURLOverride: URL?
var path: String { ConstructionManager.apiPath }

Expand All @@ -15,8 +15,8 @@ private struct RegisterRequest: JSONEncodingRequest, StatusCodeRequest {
}

extension Client {
func register(asEmail email: String, at domain: URL) -> Future<Void> {
func register(asEmail email: String, at domain: URL) async throws {
wipeAllData()
return send(RegisterRequest(baseURLOverride: domain, body: .init(email: email)))
return try await makeContext().send(RegisterRequest(baseURLOverride: domain, body: .init(email: email)))
}
}
Loading