50 Commits
1.0.3 ... 2.0.1

Author SHA1 Message Date
1d5067212a Merge pull request '[fix] LCENavigationView: remove phantom glass button, center title safely' (#11) from fix/navigation/glass-duplicate-button into main
Reviewed-on: #11
2026-09-01 16:26:03 -03:00
Daniel Arantes Loverde
72a5b59c44 [fix] LCENavigationView: remove phantom glass button, center title safely
Dummy mirrored button used to balance title centering rendered as a
visible empty glass pill under iOS 26's automatic button chrome. Side
buttons now only render when actually configured, title centers via
ZStack independent of button widths, and title padding is derived from
measured button width so long titles truncate instead of overlapping.
Glass button style is opt-in via setGlassButtonsEnabled(_:), off by
default.
2026-09-01 16:20:19 -03:00
Daniel Arantes Loverde
d678965154 [docs] Trim README to Documentation links; fix signature image 2026-08-30 10:02:19 -03:00
Daniel Arantes Loverde
0cceda1ad9 [docs] Trim README to intro + install + Documentation links
Remove the legacy inline usage snippets (background thread, nav completion
handler, networking example, 'Another components'). Replace the dead
signature GIF with the public GitHub mark linking to the resume repo.
2026-08-30 10:02:18 -03:00
Daniel Arantes Loverde
2cd52d3d12 [docs] Split reference docs into Extensions / SwiftUI / UIKit guides
Full per-symbol reference for every public extension, SwiftUI component, and
UIKit-era helper, as collapsible sections. README gets a Documentation table
linking all four guides; footer trimmed to the resume link. Docs only - no
code changes.
2026-08-29 21:27:49 -03:00
Daniel Arantes Loverde
1743bc2d24 [docs] Cross-link API.md to the other reference guides 2026-08-29 19:36:03 -03:00
Daniel Arantes Loverde
872d70dc21 [docs] UIKit.md: Media & Components section (UIKit.md complete) 2026-08-29 19:34:51 -03:00
Daniel Arantes Loverde
6eb57f5c44 [docs] UIKit.md: Collections & Tables section 2026-08-29 19:33:59 -03:00
Daniel Arantes Loverde
ac9d010b8e [docs] UIKit.md: Navigation & Controllers section 2026-08-29 19:33:22 -03:00
Daniel Arantes Loverde
255e5cfe1a [docs] UIKit.md: Views & Controls section 2026-08-29 19:32:31 -03:00
Daniel Arantes Loverde
f57dc50975 [docs] UIKit.md: Layout & Constraints section 2026-08-29 19:31:43 -03:00
Daniel Arantes Loverde
971278fa3c [docs] SwiftUI.md: LCENavigationView (complete) 2026-08-29 19:30:30 -03:00
Daniel Arantes Loverde
ec3442ed1c [docs] Extensions.md: Encoding & Errors + Core sections (Extensions.md complete) 2026-08-29 19:29:42 -03:00
Daniel Arantes Loverde
4f6a83556d [docs] Extensions.md: Date, Data & Files section 2026-08-29 19:28:35 -03:00
Daniel Arantes Loverde
d16a42fddc [docs] Extensions.md: Numbers & Geometry section 2026-08-29 19:27:31 -03:00
Daniel Arantes Loverde
c459c9eaf6 [docs] Extensions.md: Collections & Sequences section 2026-08-29 19:26:46 -03:00
Daniel Arantes Loverde
1d2c595c78 [docs] Extensions.md: Strings & Text section 2026-08-29 19:25:15 -03:00
Daniel Arantes Loverde
9f1db7c121 [docs] Extensions.md: API overview + Crypto section 2026-08-29 19:23:23 -03:00
Daniel Arantes Loverde
705639b2f5 [docs] Scaffold reference guides + README Documentation section
- Documentation/{Extensions,SwiftUI,UIKit}.md shells with category headings + TOC
- README: Documentation table linking all 4 guides
- footer: drop the second github badge, keep the resume link
2026-08-29 19:22:12 -03:00
Daniel Arantes Loverde
757d81615b [api-upload-refactor] API networking refactor (v2.0.0)
Typed request bodies (HTTPBody), first-class MultipartForm uploads, API as an
actor, Decodable & Sendable responses, bounded persistConnection retries, zero
force operations, deployment target iOS 15. Breaking change - see
Documentation/API.md and decisions/2026-08-29-api-typed-body-upload.md.
2026-08-29 19:00:33 -03:00
Daniel Arantes Loverde
6acec0ff3a [api-upload-refactor] Set deployment target to iOS 15, simplify upload
- Package.swift: .iOS(.v13) -> .iOS(.v15), .watchOS(.v6) -> .v8
  (README already stated iOS 15; watchOS 8 needed for URLSession.upload(for:fromFile:))
- drop the iOS-13 uploadTask continuation bridge; both upload overloads now
  share one runUpload using session.upload(for:fromFile:delegate:)
- remove now-redundant @available(iOS 15) from the progress overload and test
- Documentation/API.md: drop iOS-version caveats
2026-08-29 18:51:49 -03:00
Daniel Arantes Loverde
20d761b361 [api-upload-refactor] Document API usage, link from README 2026-08-29 18:10:30 -03:00
Daniel Arantes Loverde
fb2e417104 [api-upload-refactor] Add iOS15 upload progress overload; toObjetct throws
- upload(..., onProgress: @Sendable (Double) -> Void) @available(iOS 15.0, *)
  via URLSession.upload(for:fromFile:delegate:) + UploadProgressDelegate;
  emits 1.0 on completion. Base upload stays iOS 13+
- move all upload code to LCEssentials+API+Upload.swift (buildUploadRequest /
  finishUpload / performUpload shared); API file 400 lines. Shared request
  helpers private -> internal
- Dictionary.toObjetct: try! -> throws
- APIUploadTests: +progress contract test (final 1.0, monotonic, decodes)
2026-08-29 17:46:41 -03:00
Daniel Arantes Loverde
3c64f364be [api-upload-refactor] Add multipart upload(), split logging to its own file
- upload<T>(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)
  serialises MultipartForm to a temp file, streams it via uploadTask(with:fromFile:)
  bridged to async (works on iOS 13+, unlike URLSession.upload(for:fromFile:))
- temp body file always removed via defer (success and throw)
- move requestLOG/responseLOG to LCEssentials+API+Logging.swift; extract
  logResponseError helper; keeps API file at 473 lines
- APIUploadTests: 4 cases
- progress-reporting overload deferred (no stub seam for didSendBodyData)
2026-08-29 17:16:16 -03:00
Daniel Arantes Loverde
bcd358cb09 [api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry
- request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)?
  and pathParams: [String: String]
- remove legacy magic-"file" multipart branch (-> upload() in T6) and the
  now-unused mimeTypeForPath
- decompose into makeURL / buildRequest / attach / send / classify /
  decodeResponse / friendlyError (each < 30 lines)
- headers now merge over defaults (custom wins per key) instead of replacing
- fix unbounded persistConnection recursion on permanent 4xx: cap at
  maxPersistRetries (3)
- APIRequestTests: 7 cases over StubURLProtocol
2026-08-29 17:12:52 -03:00
Daniel Arantes Loverde
9581e49ce0 [api-upload-refactor] Convert API to an actor, drop @MainActor
- API: @MainActor struct -> actor. certData/certPassword/persistConnectionDelay
  move from static var (data race) to actor-isolated instance state
- setupCertificationRequest -> setupCertification; add setPersistConnectionDelay
- add internal init(testConfiguration:) seam; makeSession() helper picks
  test / cert-delegate / shared session
- URLSessionDelegateHandler: final + @unchecked Sendable, immutable let certs,
  no @MainActor; challenge logic unchanged
- getIdentity(): remove as! SecIdentity, extract via typed Unmanaged bridging
- delete unused Result enum and defaultParams
- APIActorTests: actor conformance, delay round-trip, per-instance cert state
2026-08-29 17:09:09 -03:00
Daniel Arantes Loverde
e89935a69c [api-upload-refactor] Add test target, relax decode constraints, add HTTPBody + MultipartForm
- Package: new LCEssentialsTests target (runs on iOS simulator)
- JSONDecoder.decode: T: Codable -> T: Decodable & Sendable (4 overloads);
  remove try! in decode(fromURL:); honour encoding param in decode(_:using:)
- Propagate & Sendable to Data.object, Dictionary.toObjetct, API.request return
- LCEHTTPBody: HTTPBody protocol, JSONBody, FormURLEncodedBody, RawBody, factories
- LCEMultipartForm: multipart builder, OutputStream-streamed serialize(),
  in-memory + on-disk file parts, ported mimeType(for:)
- Tests: StubURLProtocol harness + 15 tests
2026-08-29 16:59:08 -03:00
db696b5a34 Merge pull request '[lececryptokit-1.0.4] Bump LCECryptoKitBinary to the release that actually contains the pepper-refactor methods' (#10) from chore/deps/lececryptokit-1.0.4 into main
Reviewed-on: #10
2026-08-22 11:27:43 -03:00
Daniel Arantes Loverde
4e5130a01c [lececryptokit-1.0.4] Bump LCECryptoKitBinary to the release that actually contains the pepper-refactor methods 2026-08-22 11:26:38 -03:00
5ae0f67bc6 Merge pull request '[lececryptokit-1.0.3] Bump LCECryptoKitBinary and expose pepper-refactor login primitives' (#9) from chore/deps/lececryptokit-1.0.3 into main
Reviewed-on: #9
2026-08-22 11:17:08 -03:00
Daniel Arantes Loverde
55e75760ec [lececryptokit-1.0.3] Bump LCECryptoKitBinary and expose pepper-refactor login primitives 2026-08-22 11:15:11 -03:00
a659a9399c Merge pull request 'Update LCEssentials+API.swift' (#8) from fix/api/mainactor-session-hang into main
Reviewed-on: #8
2026-07-07 13:21:09 -03:00
Daniel Arantes Loverde
03044604fd Update LCEssentials+API.swift 2026-07-07 13:15:45 -03:00
Daniel Arantes Loverde
2c781fbac6 Update LCEssentials+API.swift 2026-06-06 11:00:08 -03:00
Daniel Arantes Loverde
966b439277 Update LCEssentials+API.swift 2026-06-06 10:53:16 -03:00
Daniel Arantes Loverde
a24b79f443 only remote crypto kit 2026-05-24 10:26:25 -03:00
c0eb5f95c6 Merge pull request 'fix: update keyboard notification handling for Swift concurrency' (#7) from bugfix/snackbar_for_iOS26 into main
Reviewed-on: #7
2026-04-07 11:22:02 -03:00
a15d95ed59 Merge branch 'main' into bugfix/snackbar_for_iOS26 2026-04-07 11:21:55 -03:00
Daniel Arantes Loverde
40581d791a fix: update keyboard notification handling for Swift concurrency
Keep snackbar keyboard animations on the main actor and read UIKit keyboard values using the expected notification payload types.

Made-with: Cursor
2026-04-07 11:19:39 -03:00
57ce0dc87a Merge pull request 'iPhone UIDevice names updated' (#6) from feature/LCECryptoKit into main
Reviewed-on: #6
2026-02-11 20:57:39 +00:00
Daniel Arantes Loverde
d06a66226e iPhone UIDevice names updated 2026-02-11 17:56:53 -03:00
336b47ace2 Merge pull request 'Optional' (#5) from feature/LCECryptoKit into main
Reviewed-on: #5
2026-02-06 15:38:09 +00:00
b4a081c4d2 Merge branch 'main' into feature/LCECryptoKit 2026-02-06 15:37:58 +00:00
Daniel Arantes Loverde
57696e3036 Optional 2026-02-06 12:25:34 -03:00
560efd2c8b Merge pull request 'Added more options' (#4) from feature/LCECryptoKit into main
Reviewed-on: https://loverde_git.localhost:3334/Loverde-Company-LTDA/LCEssentials/pulls/4
2025-10-07 23:35:46 +00:00
7e24bdf3d5 Merge branch 'main' into feature/LCECryptoKit 2025-10-07 23:35:37 +00:00
Daniel Arantes Loverde
5df2499c4a Added more options 2025-10-07 20:34:33 -03:00
53ac2c7001 Merge pull request 'Fix LCECryptoKit methods' (#3) from feature/LCECryptoKit into main
Reviewed-on: https://loverde_git.localhost:3334/Loverde-Company-LTDA/LCEssentials/pulls/3
2025-10-07 23:22:04 +00:00
d909709254 Merge branch 'main' into feature/LCECryptoKit 2025-10-07 23:21:59 +00:00
Daniel Arantes Loverde
9a7981b845 Fix LCECryptoKit methods 2025-10-07 20:21:28 -03:00
58 changed files with 5172 additions and 704 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
/.build
/build
/Packages
xcuserdata/
DerivedData/

239
Documentation/API.md Normal file
View File

@@ -0,0 +1,239 @@
# `API` — networking for LCEssentials
Part of the reference set: [Extensions.md](Extensions.md) ·
[SwiftUI.md](SwiftUI.md) · [UIKit.md](UIKit.md).
`API` is an `actor` that wraps `URLSession` for JSON REST calls and multipart
uploads. One line to send a typed request, decode the response, and get a
consistent error — instead of re-writing the same `URLRequest` / status-code /
`JSONDecoder` boilerplate in every project.
```swift
import LCEssentials
struct User: Decodable, Sendable { let id: Int; let name: String }
let user: User = try await API.shared.request(
url: "https://api.example.com/users/{id}",
method: .get,
pathParams: ["id": "42"]
)
```
---
## Why not hand-rolled `URLSession`
| Hand-rolled `URLSession` | `API` |
|---|---|
| Build `URLRequest`, set method, headers, body, `Content-Type`, `Content-Length` every call | `request(url:method:body:)` — headers and content metadata handled |
| `switch` on `httpResponse.statusCode` in every call site, or forget to | 2xx decodes, 4xx/5xx throw a populated `NSError` (`.code` = HTTP status, failure reason = response body) |
| `JSONDecoder().decode(T.self, from:)` + custom error messages each time | `JSONDecoder.decode` with keyed / type-mismatch / missing-value diagnostics baked in |
| Multipart body assembled by hand with `\r\n` string concatenation and force-unwrapped `.data(using:)` | `MultipartForm` builder; body streamed from a temp file, never fully in memory |
| Large file upload loads the whole file into a `Data` | `form.file(_:url:)` streams from disk in 64 KB chunks |
| Retry logic copy-pasted, often unbounded | `persistConnection: true`, bounded by `API.maxPersistRetries` |
| Client-certificate (mTLS) needs a custom `URLSessionDelegate` per project | `setupCertification(certData:password:)` |
| Progress reporting needs a delegate + KVO wiring | `upload(..., onProgress:)` |
| `@MainActor` hops or manual `DispatchQueue` juggling | `actor`-isolated, `Sendable`-checked, runs off the main thread |
| Response types must be `Codable` even when only decoding | `T: Decodable & Sendable` |
`API` is not a replacement for a full networking stack (no interceptors,
caching policy DSL, or automatic token refresh). For simple typed REST it
removes the boilerplate and the easy-to-get-wrong parts.
---
## Requests
### GET
```swift
let items: [Item] = try await API.shared.request(
url: "https://api.example.com/items",
method: .get
)
```
### POST / PUT / PATCH with a JSON body
`body` takes any `HTTPBody`. `jsonBody(_:)` wraps an `Encodable & Sendable`
value; `Content-Type: application/json; charset=UTF-8` is set for you.
```swift
struct CreateUser: Encodable, Sendable { let name: String; let email: String }
let created: User = try await API.shared.request(
url: "https://api.example.com/users",
method: .post,
body: jsonBody(CreateUser(name: "Ana", email: "ana@example.com"))
)
```
### Form-url-encoded body
```swift
let token: TokenDTO = try await API.shared.request(
url: "https://api.example.com/oauth/token",
method: .post,
body: .form([
"grant_type": "password",
"username": "ana",
"password": "s3cr3t" // reserved chars are percent-escaped, never dropped
])
)
```
### Raw body (you control the bytes and content type)
```swift
body: RawBody(data: protobufData, contentType: "application/x-protobuf")
```
### Path parameters
`{name}` placeholders in `url` are filled from `pathParams`:
```swift
url: "https://api.example.com/teams/{team}/members/{member}",
pathParams: ["team": "42", "member": "7"]
```
### Custom headers
Merged over the defaults — your value wins per key, the other defaults stay.
```swift
headers: ["Authorization": "Bearer \(accessToken)"]
```
### Plain-text / string responses
When `T == String` the raw response body is returned without JSON decoding:
```swift
let csv: String = try await API.shared.request(url: "\(base)/export.csv", method: .get)
```
### Retry on transient 4xx
```swift
let data: Payload = try await API.shared.request(
url: "\(base)/flaky",
method: .get,
persistConnection: true // retries up to API.maxPersistRetries, then throws
)
```
### Other options
| Parameter | Default | Meaning |
|---|---|---|
| `debug` | `true` | Print request/response logs |
| `timeoutInterval` | `30` | Seconds |
| `networkServiceType` | `.default` | `URLRequest.NetworkServiceType` |
---
## Errors
Non-2xx responses throw an `NSError`:
```swift
do {
let u: User = try await API.shared.request(url: "\(base)/users/999", method: .get)
} catch let error as NSError {
error.code // HTTP status, e.g. 404
error.localizedDescription // from URLError
error.localizedFailureReason // pretty-printed response body
}
```
Transport failures surface as `URLError`. Malformed success bodies throw
`DecodingError` with a readable message (missing key, type mismatch, …).
---
## Uploads
### Build a multipart form
```swift
var form = MultipartForm()
form.field("caption", "Sunset")
form.file("thumbnail", data: jpegData, filename: "thumb.jpg") // in memory
form.file("video", url: localVideoURL) // streamed from disk
```
- `field(_:_:)` — plain text field.
- `file(_:data:filename:mime:)` — in-memory blob. MIME guessed from the
filename extension unless you pass `mime:`.
- `file(_:url:filename:mime:)` — on-disk file, streamed straight into the body
so a large file never becomes fully resident in memory.
### Send
```swift
let result: UploadResult = try await API.shared.upload(
url: "https://api.example.com/media",
form: form
)
```
The body is serialised to a temp file and always removed afterwards, on success
and on throw.
### With progress
```swift
let result: UploadResult = try await API.shared.upload(
url: "https://api.example.com/media",
form: form,
onProgress: { fraction in
Task { @MainActor in progressView.progress = Float(fraction) }
}
)
```
`onProgress` is called on an arbitrary queue with a value in `0.0...1.0`, then
`1.0` once the body has been fully sent. Hop to the main actor yourself before
touching UI.
---
## Client certificate (mutual TLS)
```swift
let p12 = try Data(contentsOf: certificateURL)
await API.shared.setupCertification(certData: p12, password: "cert-password")
// subsequent requests present the client certificate on TLS challenge
```
---
## Extending: custom body types
Conform to `HTTPBody`:
```swift
struct CSVBody: HTTPBody {
let rows: [[String]]
func encoded() throws -> (data: Data, contentType: String) {
let text = rows.map { $0.joined(separator: ",") }.joined(separator: "\n")
return (Data(text.utf8), "text/csv; charset=UTF-8")
}
}
try await API.shared.request(url: "\(base)/import", method: .post, body: CSVBody(rows: rows))
```
`API` needs no change to accept it.
---
## Notes
- `API` is an `actor`. Every call is `await`; config setters
(`setupCertification`, `setPersistConnectionDelay`) are `await` too.
- Response and body types must be `Sendable`. Value-type structs already are.
- The shared instance is `API.shared`. Tests build isolated instances with
`API(testConfiguration:)` and a stub `URLProtocol`.

2018
Documentation/Extensions.md Normal file

File diff suppressed because it is too large Load Diff

117
Documentation/SwiftUI.md Normal file
View File

@@ -0,0 +1,117 @@
# LCEssentials — SwiftUI
SwiftUI components and `View` helpers. Foundation/value-type helpers are in
[Extensions.md](Extensions.md); UIKit-era helpers in [UIKit.md](UIKit.md).
Every section is a collapsible block — click a heading to expand it.
## Contents
- [Navigation](#navigation)
- [View helpers](#view-helpers)
---
## Navigation
<details>
<summary><b>LCENavigationView</b> — customizable navigation bar (iOS 15+)</summary>
A drop-in replacement for the system navigation bar with left/right buttons,
title + subtitle, background colour, and a hide toggle. Configuration methods
return `self`, so they chain. **Per the workspace iOS standards, this component
is mandatory on new SwiftUI screens instead of a hand-rolled bar.**
`@available(iOS 15, *)`, iOS only.
### `init(title: (any View) = Text(""), subTitle: (any View) = Text(""), @ViewBuilder content: () -> Content)`
The `content` closure is everything shown **below** the bar.
```swift
LCENavigationView(title: Text("Profile")) {
ScrollView {
ProfileForm()
}
}
```
### `func setTitle(text: (any View) = Text(""), subTitle: (any View)? = nil) -> LCENavigationView`
Set (or replace) the title and optional subtitle. Passing no `subTitle` hides
the subtitle row.
```swift
LCENavigationView { Content() }
.setTitle(text: Text("Orders"), subTitle: Text("32 open"))
```
### `func setLeftButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
Configure the leading button. If the trailing button has no text/image yet, a
transparent placeholder is added on that side so the title stays centred.
```swift
.setLeftButton(text: Text("Back"), image: Image(systemName: "chevron.left")) {
dismiss()
}
```
### `func setRightButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
Configure the trailing button (same placeholder behaviour for the leading side).
```swift
.setRightButton(image: Image(systemName: "plus")) {
showingNewItem = true
}
```
### `func hideNavigationView(_ hide: Bool) -> LCENavigationView`
Show or hide the whole bar (the content stays).
```swift
.hideNavigationView(isFullScreenMedia)
```
### `func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView`
Bar background colour (extends into the top safe area). Defaults to `.clear`.
```swift
.setNavigationBarBackgroundColor(.blue.opacity(0.1))
```
### Full example
```swift
struct OrdersScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var showingNew = false
var body: some View {
LCENavigationView(title: Text("Orders")) {
OrdersList()
}
.setLeftButton(text: Text("Back"),
image: Image(systemName: "chevron.left")) { dismiss() }
.setRightButton(image: Image(systemName: "plus")) { showingNew = true }
.setNavigationBarBackgroundColor(Color(.systemBackground))
}
}
```
> `LCENavigationState` (the `@Published` backing store) and the reflection-based
> `Text.string` / tag helpers in `View+Ext.swift` are `internal` implementation
> details — not part of the public API.
</details>
---
## View helpers
There are currently no public standalone `View` extensions — the `getTag` /
`extractTag` reflection utilities in `SwiftUI/View+Ext.swift` are `internal` and
exist only to support `LCENavigationView`'s subtitle handling.

815
Documentation/UIKit.md Normal file
View File

@@ -0,0 +1,815 @@
# LCEssentials — UIKit
UIKit-era helpers: programmatic layout & constraints, view/control extensions,
navigation, table/collection helpers, and drop-in components (image picker, image
zoom, snackbar, GIF loading).
Foundation/value-type helpers are in [Extensions.md](Extensions.md); SwiftUI
helpers in [SwiftUI.md](SwiftUI.md).
Every section is a collapsible block — click a heading to expand it.
## Contents
- [Layout & Constraints](#layout--constraints)
- [Views & Controls](#views--controls)
- [Navigation & Controllers](#navigation--controllers)
- [Collections & Tables](#collections--tables)
- [Media & Components](#media--components)
---
## Layout & Constraints
<details>
<summary><b>UIView</b> — programmatic constraints, chainable anchors</summary>
### `enum AnchorType`
The anchor to pin when using `setConstraintsTo`. Cases: `all`, `top`, `bottom`,
`leading`, `trailing`, `left`, `right`, `centerX`, `centerY`, `width`, `heigth`
*(sic)*, `topToBottom`, `bottomToTop`, `leadingToTrailing`, `trailingToLeading`,
plus `…GreaterThanOrEqualTo` / `…LessThanOrEqualTo` variants.
### `@discardableResult func setConstraintsTo(parentView: UIView, anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> Self`
Activate one constraint against `parentView`. Remembers `parentView` (in
`viewReference`) so follow-up calls can omit it. `safeArea: true` pins top/bottom
to the safe-area guide (iOS 11+).
```swift
card.addSubview(label)
label.setConstraintsTo(parentView: card, anchorType: .top, value: 12, safeArea: false)
.setConstraints(.leading, 16)
.setConstraints(.trailing, -16)
```
### `@discardableResult func setConstraintsTo(_ parentView:_ anchorType:_ value:_ safeArea: Bool = false) -> Self`
Positional-argument shorthand for the above.
### `@discardableResult func setConstraintsTo(anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> UIView`
Reuse the last `parentView` (`viewReference`). **Traps** with `fatalError` if no
`parentView` was set first.
### `@discardableResult func setConstraints(_ anchorType: AnchorType, _ value: CGFloat, _ safeArea: Bool = false) -> UIView`
Positional shorthand for the reuse form — the one you chain.
### `func setConstraints(_ toScrollView: UIScrollView, direction: UICollectionView.ScrollDirection = .vertical)`
Pin `self` as the single content view of a scroll view (edges + matching
width/height, with a low-priority constraint on the scroll axis). Traps if
`self` is itself a `UIScrollView`.
```swift
scrollView.addSubview(content)
content.setConstraints(scrollView, direction: .vertical)
```
### Constraint accessors
`widthConstraint`, `heightConstraint`, `leadingConstraint`, `trailingConstraint`,
`topConstraint`, `bottomConstraint`, `centerXConstraints`, `centerYConstraints`
the first matching `NSLayoutConstraint` found by walking up the hierarchy.
```swift
box.widthConstraint?.constant = 120
UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() }
```
### `func findConstraint(attribute: NSLayoutConstraint.Attribute, for view: UIView) -> NSLayoutConstraint?`
Search self and ancestors for a constraint on `attribute` involving `view`.
### `func constraints(on anchor:) -> [NSLayoutConstraint]`
Constraints touching a given `NSLayoutYAxisAnchor` / `XAxisAnchor` / `Dimension` of `self`.
### `@discardableResult func setHeight(size:) -> Self` / `setHeight(min:)` / `setWidth(size:)` / `setWidth(min:)`
Activate a fixed or minimum dimension constraint.
```swift
avatar.setWidth(size: 44).setHeight(size: 44)
```
### `func setWidth(_ toView: UIView? = nil, constant: CGFloat, _ multiplier: CGFloat = 0) -> Self` / `setHeight(...)`
Dimension relative to another view (`multiplier`) or fixed (`multiplier == 0`).
### Frame setters
`setX(x:)`, `setY(y:)`, `setFrameWidth(width:)`, `setFrameHeight(height:)` — mutate
`self.frame` directly (manual layout, not Auto Layout).
</details>
<details>
<summary><b>UIView</b> — hierarchy, appearance, effects</summary>
### `static var className: String`
`String(describing: self)`.
```swift
UIButton.className // "UIButton"
```
### `func addSubview(_ subview:, translatesAutoresizingMaskIntoConstraints: Bool = false)` / `addSubviews(_ subviews: [UIView], …)`
Add a view (or many) and set `translatesAutoresizingMaskIntoConstraints` in one call.
```swift
view.addSubviews([header, body, footer]) // all ready for Auto Layout
```
### `func subviews<T>(ofType _: T.Type) -> [T]`
All descendants of a type, recursively.
```swift
formView.subviews(ofType: UITextField.self)
```
### `func findAView<T>(_ ofType: T.Type) -> T?`
First descendant of a type.
### `var parentViewController: UIViewController?`
Nearest owning view controller (walks the responder chain).
### `var borderColor: UIColor?` / `var borderWidth: CGFloat` / `var cornerRadius: CGFloat`
Layer border/corner shortcuts (`cornerRadius` also sets `masksToBounds`).
```swift
card.cornerRadius = 12
card.borderWidth = 1
card.borderColor = .separator
```
### `func setRadius(top: Bool, bottom: Bool, radius: CGFloat = 8)`
Round only the top and/or bottom corners.
```swift
sheet.setRadius(top: true, bottom: false, radius: 16)
```
### `func applyShadow(color:offSet:radius:opacity:shouldRasterize: Bool = true, rasterizationScaleTo: = UIScreen.main.scale)` / `func removeShadow()`
Layer shadow with rasterisation on by default.
```swift
card.applyShadow(color: .black, offSet: CGSize(width: 0, height: 2),
radius: 6, opacity: 0.15)
```
### `func insertBlurView(style: UIBlurEffect.Style, color: UIColor = .black, alpha: CGFloat = 0.9)`
Insert a full-bleed `UIVisualEffectView` behind the content.
### `func drawCircle(inCoord x:y:with radius:strokeColor: = .red, fillColor: = .gray, isEmpty: Bool = false) -> [String: Any]`
Add a circular `CAShapeLayer`; returns `["path": UIBezierPath, "layer": CAShapeLayer]`.
### `var isRightToLeft: Bool`
Effective RTL layout direction.
### `var screenshot: UIImage?` / `func asImage() -> UIImage`
Render the view to an image (`screenshot` via the legacy context, `asImage` via `UIGraphicsImageRenderer`).
```swift
let png = chartView.asImage().pngData()
```
### `var globalPoint: CGPoint?` / `var globalFrame: CGRect?` / `var absolutePosition: CGRect`
Origin/frame converted to window coordinates.
### `func fadeIn(withDuration: TimeInterval = 1, withDelay: TimeInterval = 0, completionHandler: @escaping (Bool) -> Void)` / `func fadeOut(...)`
Animate `alpha` to 1 / 0. *(Completion-handler API — pre-dates async/await.)*
```swift
overlay.fadeOut { _ in overlay.removeFromSuperview() }
```
### `var viewReference: UIView?`
Scratch reference used internally by the chained `setConstraintsTo` calls.
</details>
<details>
<summary><b>GradientOrientation / EnumBorderSide</b></summary>
### `enum GradientOrientation`
`topRightBottomLeft`, `topLeftBottomRight`, `horizontal`, `vertical` — maps to a
`CAGradientLayer` start/end point pair. Passed to gradient helpers elsewhere in
the package.
### `enum EnumBorderSide`
`top`, `bottom`, `left`, `right` — which edge to draw a single-side border on.
</details>
<details>
<summary><b>UIStackView</b> — convenience init, bulk arranged-subview ops (iOS 9+)</summary>
### `convenience init(arrangedSubviews: [UIView]? = nil, axis: = .vertical, spacing: = 0, alignment: = .fill, distribution: = .fill, layoutMargins: = .zero, isMarginsRelative: Bool = true)`
One-call configured stack view.
```swift
let stack = UIStackView(arrangedSubviews: [title, subtitle],
axis: .vertical, spacing: 4)
```
### `func addArrangedSubviews(_ views: [UIView], translateAutoresizing: Bool = false)`
Add many arranged subviews, setting their autoresizing flag.
### `func removeAllArrangedSubviews(deactivateConstraints: Bool = true)`
Remove and dispose every arranged subview.
### `func removeSubview(view: UIView, deactivateConstraints: Bool = true)`
Remove one arranged subview (optionally deactivating its constraints and removing from the hierarchy).
### `func addSpace(_ size: CGFloat, backgroundColor: UIColor = .clear)`
Insert a fixed-size spacer view sized along the stack's axis.
```swift
stack.addArrangedSubviews([row1, row2])
stack.addSpace(24)
stack.addArrangedSubview(row3)
```
</details>
> `NSLayoutConstraint` helpers (`constraintWithMultiplier`, `matches(view:anchor:)`)
> and `[NSLayoutConstraint].filtered(view:anchor:)` are `internal` — used by the
> `UIView.constraints(on:)` accessors above, not called directly.
## Views & Controls
<details>
<summary><b>UIButton</b> — per-state accessors, all-state setters</summary>
### Per-state properties
`imageForNormal` / `imageForHighlighted` / `imageForSelected` / `imageForDisabled`,
`titleForNormal` / `…Highlighted` / `…Selected` / `…Disabled`,
`titleColorForNormal` / `…Highlighted` / `…Selected` / `…Disabled` — get/set
shortcuts for the matching `UIControl.State` (also `@IBInspectable`).
```swift
button.titleForNormal = "Save"
button.titleColorForDisabled = .tertiaryLabel
```
### `func setTitleForAllStates(_:)` / `func setTitleColorForAllStates(_:)` / `func setImageForAllStates(_:)`
Apply one value to `.normal`, `.selected`, `.highlighted`, `.disabled` at once.
```swift
button.setTitleColorForAllStates(.white)
```
### `func centerTextAndImage(spacing: CGFloat)`
Balance title/image edge insets so text + icon sit centred with `spacing` between them.
```swift
button.centerTextAndImage(spacing: 8)
```
</details>
<details>
<summary><b>UILabel</b></summary>
### `func lineNumbers() -> Int`
Rendered line count at the current width.
```swift
if bodyLabel.lineNumbers() > 3 { showMoreButton() }
```
### `var getEstimatedHeight: CGFloat`
Height the label would need to show its full text/attributed text unclipped.
</details>
<details>
<summary><b>UITextField</b></summary>
### `var placeholderColor: UIColor`
Get/set the placeholder text colour (rebuilds `attributedPlaceholder`; setter is a
no-op if no placeholder text is set).
```swift
field.placeholder = "Email"
field.placeholderColor = .secondaryLabel
```
### `func addPaddingLeft(_ padding: CGFloat)`
Inset the text from the left with an empty spacer view.
### `func addPaddingLeftIcon(_ image: UIImage, padding: CGFloat)`
Left view = an icon plus trailing padding.
```swift
field.addPaddingLeftIcon(UIImage(systemName: "magnifyingglass")!, padding: 8)
```
</details>
<details>
<summary><b>UIImageView</b> (<code>@MainActor</code>)</summary>
### `func changeColorOfImage(_ color: UIColor, image: UIImage?) -> UIImageView`
Set a template-rendered image tinted to `color`; returns `self`.
```swift
iconView.changeColorOfImage(.systemBlue, image: UIImage(named: "star"))
```
### `var encodeToBase64: String?`
JPEG (quality 0.6) of the current image as a Base64 string.
### `func addAspectRatioConstraint()` / `func removeAspectRatioConstraint()`
Add / remove a width-to-height constraint matching the current image's ratio.
```swift
photoView.image = photo
photoView.addAspectRatioConstraint()
```
</details>
<details>
<summary><b>UIImage</b> — recolour, resize, thumbnails, masks, init</summary>
### `func imageWithColor(color: UIColor) -> UIImage` / `func tintImage(color: UIColor) -> UIImage`
Return a copy filled / tinted with `color` (keeps the alpha shape).
```swift
let redIcon = icon.tintImage(color: .systemRed)
```
### `func backgroundColorTransparent(initialColor: UIColor, finalColor: UIColor) -> UIImage?`
Make pixels in a colour range transparent.
### `class func outlinedEllipse(size: CGSize, color: UIColor, lineWidth: CGFloat = 1) -> UIImage?`
Generate a stroked-ellipse image.
```swift
UIImage.outlinedEllipse(size: CGSize(width: 24, height: 24), color: .label)
```
### `func resizeImage(newWidth: CGFloat) -> UIImage`
Scale to `newWidth`, keeping the aspect ratio.
### `func createThumbnail(_ maxPixelSize: UInt) -> UIImage`
Fast down-sampled thumbnail via `CGImageSource`.
```swift
let thumb = fullImage.createThumbnail(200)
```
### `func maskWithAlphaImage(maskImage: UIImage) -> UIImage`
Use another image's alpha as a mask.
### `func isAnimated() -> Bool`
Whether the image has more than one frame.
### `init?(base64String: String, scale: CGFloat = 1)`
Decode a Base64 string to an image.
### `init(view: UIView)` — *`@MainActor`*
Rasterise a view into an image.
```swift
let snapshot = UIImage(view: cardView)
```
</details>
<details>
<summary><b>UIColor</b> — hex, components</summary>
### `convenience init(hex: String)`
Parse `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` (with or without `#`).
```swift
view.backgroundColor = UIColor(hex: "#1E88E5")
UIColor(hex: "FF0000CC") // red at 80% alpha
```
### `var hexString: String?`
`#RRGGBB` (or `#RRGGBBAA` when alpha < 1).
### `var redValue` / `var greenValue` / `var blueValue` / `var alphaValue`
Individual channel values (`CGFloat`, via `CIColor`).
```swift
UIColor.systemBlue.redValue // 0.01.0
```
</details>
<details>
<summary><b>UIScrollView</b> — snapshot, visible rect, paged scrolling</summary>
### `var snapshot: UIImage?`
Image of the **entire** content size (not just the visible part) — works on
`UITableView` / `UICollectionView` too.
### `var visibleRect: CGRect`
The currently visible content region.
### `var offsetInPage: CGFloat`
Fractional position within the current page height (`0.0``1.0`).
### `func scrollUp(animated:)` / `scrollDown(animated:)` / `scrollLeft(animated:)` / `scrollRight(animated:)`
Move one page (respects `isPagingEnabled`). `animated` defaults to `true`.
```swift
nextButton.onTap = { scrollView.scrollRight() }
```
### `enum orientation`
`horizontal` / `vertical` — helper enum used by scroll utilities.
</details>
<details>
<summary><b>UITapGestureRecognizer</b></summary>
### `func didTapAttributedTextInLabel(label: UILabel, textToTouch: String) -> Bool`
Whether the tap landed on a given substring of a label's attributed text — for
making part of a label tappable.
```swift
@objc func handleTap(_ g: UITapGestureRecognizer) {
if g.didTapAttributedTextInLabel(label: termsLabel, textToTouch: "Terms of Use") {
openTerms()
}
}
```
</details>
## Navigation & Controllers
<details>
<summary><b>UINavigationController</b> — completion-handler push/pop, transparent bar</summary>
### `func pushViewController(_:animated: Bool = true, completion: (() -> Void)? = nil)`
### `func popViewController(animated: Bool = true, _ completion: (() -> Void)? = nil)`
### `func popToViewController(_:animated: Bool = true, _ completion: (() -> Void)? = nil)`
The standard transitions with a completion block (wrapped in a `CATransaction`).
```swift
navigationController?.pushViewController(detail) {
print("detail is on screen")
}
```
### `func pushViewController(_:hidesBottomBar: Bool = false, animated: Bool = true)` — *not tvOS*
Push while setting `hidesBottomBarWhenPushed`.
### `func makeTransparent(withTint tint: UIColor = .white)`
Clear background + shadow, translucent, tinted bar/title.
```swift
navigationController?.makeTransparent(withTint: .label)
```
</details>
<details>
<summary><b>UIViewController</b> — state, instantiation, dismissal, toasts, keyboard, observers</summary>
### `var isVisible: Bool` / `var isLoaded: Bool`
View is loaded **and** in a window. (`isLoaded` is an alias.)
### `var isModal: Bool`
Whether the controller is presented modally (vs. pushed).
### `static var className: String` / `static var identifier: String` / `static var segueID: String`
`"MyVC"`, `"idMyVC"`, `"idSegueMyVC"`.
### `static func instantiate<T>(storyBoard: String, identifier: String? = nil, bundle: Bundle? = …) -> T`
Load a controller from a storyboard by (default) `T.identifier`.
```swift
let vc: ProfileVC = ProfileVC.instantiate(storyBoard: "Main")
```
### `static func instatiate<T>(nibName: String, bundle: Bundle? = nil) -> T` *(sic — "instatiate")*
Load from a nib and force an initial layout pass.
### `func present(viewControllerToPresent:completion: @escaping () -> Void)`
`present(_:animated:)` with a completion block.
### `func closeController(jumpToController: UIViewController? = nil, completion: @escaping () -> Void)`
Dismiss if modal, else pop (to `jumpToController` if given), then call `completion`.
```swift
closeController { self.refreshList() }
```
### `func show(toastWith message:, font: = system 12, toastPosition: ToastPosition, backgroundColor: = .black, textColor: = .white, duration: = 3)`
Show a temporary rounded toast label. `ToastPosition` is `.top` / `.down`
(notch-aware at the top).
```swift
show(toastWith: "Saved", toastPosition: .down)
```
### `func addNotificationObserver(name:selector:)` / `func removeNotificationObserver(name:)` / `func removeNotificationsObserver()`
`NotificationCenter` registration shortcuts (last one removes all).
### `@objc func dismissSystemKeyboard(_ sender: UITapGestureRecognizer)`
Ready-made selector for a tap-to-dismiss-keyboard gesture.
```swift
view.addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(dismissSystemKeyboard(_:))))
```
</details>
<details>
<summary><b>UITabBarController</b> — badges, animated tab switching</summary>
### `func setBadges(badgeValues: [Int], font: UIFont = Helvetica-Light 11)`
Set numeric badges for every tab at once (`0` = no badge). Custom-drawn
(`CustomTabBadge` label), so they position above each tab item.
```swift
tabBarController?.setBadges(badgeValues: [0, 3, 0, 12])
```
### `func addBadge(index:value:color:font:)`
Add a single custom badge.
### `func setSelectedView(atIndex:withAnimation: Bool = false, completion:)`
Select a tab (pops that tab's nav stack to root first).
### `func setSelectedView(withNoPop atIndex:withAnimation: Bool = false, completion:)`
Same, without popping to root.
### `func animateToTab(toIndex: Int)`
Slide-transition between tabs.
### `func changeViewControllerToItem(withViewController:Item:)`
Replace a tab's root controller then switch to that tab.
```swift
tabBarController?.changeViewControllerToItem(withViewController: NewHome(), Item: 0)
```
### `class CustomTabBadge: UILabel`
The badge label type used above (`init(font:)`).
</details>
<details>
<summary><b>UIApplication</b> — environment, app info, open URL</summary>
### `enum Environment` + `static var inferredEnvironment: Environment`
`.debug` / `.testFlight` / `.appStore`, inferred from build config, simulator, and
the provisioning/receipt files.
```swift
if UIApplication.inferredEnvironment == .appStore { enableAnalytics() }
```
### `static var displayName: String?` / `static var buildNumber: String?` / `static var version: String?`
Bundle info values. (`LCEssentials.appVersion` etc. forward to these.)
### `static func openURL(urlStr: String)`
Open a URL string if it can be opened.
```swift
UIApplication.openURL(urlStr: "https://loverde.com.br")
```
</details>
<details>
<summary><b>UIResponder</b></summary>
### `var getParentViewController: UIViewController?`
Walk the responder chain to the owning view controller.
```swift
someView.getParentViewController?.present(alert, animated: true)
```
</details>
<details>
<summary><b>UIDevice</b> — notch metrics, model name</summary>
### `static var topNotch: CGFloat` / `static var bottomNotch: CGFloat` / `static var hasNotch: Bool`
Safe-area top/bottom insets and whether the device has a notch / Dynamic Island.
```swift
let headerY: CGFloat = UIDevice.hasNotch ? 44 : 20
```
### `var modelName: String`
Marketing name from the hardware identifier (`"iPhone 15 Pro"`, `"iPad Air (5th generation)"`, …); falls back to the raw identifier for unknown devices. Reads `SIMULATOR_MODEL_IDENTIFIER` on the simulator.
```swift
UIDevice.current.modelName // "iPhone 16 Pro"
```
</details>
## Collections & Tables
<details>
<summary><b>UITableView</b> — typed dequeue, safe indexing, cell animations</summary>
### `func dequeueReusableCell<T: UITableViewCell>(withClass name: T.Type) -> T` / `…(withClass:for indexPath:) -> T`
Dequeue a cell by its class name as the identifier. **Traps** if the cell isn't registered.
```swift
let cell = tableView.dequeueReusableCell(withClass: OrderCell.self, for: indexPath)
```
### `func dequeueReusableHeaderFooterView<T: UITableViewHeaderFooterView>(withClass name: T.Type) -> T`
Same, for section header/footer views.
### `func dequeueCell<T: UITableViewCell>(indexPath: IndexPath) -> T`
Dequeue using `T.identifier` (`"id" + class name`).
### `func reloadData(_ completion: @escaping () -> Void)`
`reloadData()` with a callback for when layout settles.
### `func isValidIndexPath(_:) -> Bool`
Bounds check against the current section/row counts.
### `func safeScrollToRow(at:at scrollPosition:animated:)`
`scrollToRow` that silently no-ops for an out-of-range index path.
### `func makeMoveUpWithFadeAnimation(rowHeight:duration:delayFactor:) -> UITableViewCellAnimation`
Build a staggered slide-up + fade-in cell animation closure.
```swift
let animator = UITableViewAnimator(
animation: tableView.makeMoveUpWithFadeAnimation(rowHeight: 64, duration: 0.35, delayFactor: 0.03)
)
func tableView(_ t: UITableView, willDisplay cell: UITableViewCell, forRowAt ip: IndexPath) {
animator.animate(cell: cell, at: ip, in: t)
}
```
### `typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void`
### `class UITableViewAnimator`
`init(animation:)` + `animate(cell:at:in:)` — runs a cell animation closure.
### `UITableViewCell.identifier` / `UITableViewCell.prepareDisclosureIndicator()`
`"id" + class name`; and re-tint the disclosure chevron to a template image so it
picks up `tintColor`.
</details>
<details>
<summary><b>UICollectionView</b> — setup, counts, safe indexing, carousel layout</summary>
### `static var identifier: String`
`"id" + class name`.
### `func setupCollectionView(flowLayout: = UICollectionViewFlowLayout(), spacings: = 0, direction: = .horizontal, edgesInset: = .zero, allowMulpleSelection: Bool = false, automaticSize: CGSize? = nil)`
Configure layout spacing/direction/insets, multi-selection, and self-sizing in one call.
```swift
collectionView.setupCollectionView(spacings: 8, direction: .vertical,
edgesInset: .init(top: 12, left: 16, bottom: 12, right: 16))
```
### `func reloadData(_ completion: @escaping () -> Void)`
Reload with a completion callback.
### `func numberOfItems() -> Int`
Total items across all sections.
### `var lastSection: Int` / `var indexPathForLastItem: IndexPath?` / `func indexPathForLastItem(inSection:) -> IndexPath?`
Last section index; index path of the last item overall or in a section.
### `func isValidIndexPath(_:) -> Bool` / `func safeScrollToItem(at:at scrollPosition:animated:)`
Bounds check; scroll that no-ops on an invalid index path.
### `enum CollectionViewFlowLayoutSpacingMode`
`.fixed(spacing:)` / `.overlap(visibleOffset:)` — spacing strategy for the carousel layout below.
### `open class CollectionViewFlowLayout: UICollectionViewFlowLayout`
A centred, paginated "cover-flow" style layout: the centred item is full size,
side items scale and fade. Tunables: `sideItemScale` (0.6), `sideItemAlpha`
(0.6), `sideItemShift` (0), `spacingMode` (`.fixed(40)`).
```swift
let layout = CollectionViewFlowLayout()
layout.itemSize = CGSize(width: 240, height: 320)
layout.sideItemScale = 0.7
layout.spacingMode = .overlap(visibleOffset: 30)
collectionView.collectionViewLayout = layout
```
</details>
## Media & Components
<details>
<summary><b>LCSnackBarView</b> — in-app notification banner</summary>
A chainable banner shown from the top or bottom of a controller. Configure with
`configure(...)` calls, then `present()`.
### `init(style: LCSnackBarViewType = .default, orientation: LCSnackBarOrientation = .top, delegate: LCSnackBarViewDelegate? = nil)`
### Enums
- `LCSnackBarViewType``.default` (rectangular) / `.rounded`
- `LCSnackBarOrientation``.top` / `.bottom`
- `LCSnackBarTimer: CGFloat``.infinity` (0, manual dismiss), `.minimum` (2s), `.medium` (5s), `.maximum` (10s)
### Configuration (each returns `Self`)
| Method | Sets |
|---|---|
| `configure(text: String)` | the message |
| `configure(textColor: UIColor)` | text colour |
| `configure(textFont: UIFont, alignment: NSTextAlignment = .center)` | font + alignment |
| `configure(backgroundColor: UIColor)` | banner background |
| `configure(exibition timer: LCSnackBarTimer)` | how long it stays |
| `configure(imageIconBefore icon: UIImageView, withTintColor: UIColor? = nil)` | leading icon |
### `func present(completion: (() -> Void)? = nil)`
Show on the top-most view controller.
### `weak var delegate: LCSnackBarViewDelegate?`
`snackbar(didStartExibition:)`, `snackbar(didTouchOn:)`, `snackbar(didEndExibition:)` — all optional.
### Full example
```swift
LCSnackBarView(style: .rounded, orientation: .bottom)
.configure(text: "Profile saved")
.configure(backgroundColor: .systemGreen)
.configure(exibition: .minimum)
.present()
```
</details>
<details>
<summary><b>ImagePickerController</b> — camera / photo-library picker with permissions</summary>
Wraps `UIImagePickerController`, handling camera & photo-library authorization
(including the "go to Settings" path) and presenting a source-choice alert.
### `init()`
### `weak var delegate: ImagePickerControllerDelegate?`
### `var isEditable: Bool` — allow in-picker cropping (default `false`)
### `func openImagePicker()`
Check permissions, then present the camera/library choice.
### `protocol ImagePickerControllerDelegate: AnyObject`
`imagePicker(didSelect image: UIImage?)` — the picked (or edited) image, or `nil` on cancel/failure.
```swift
let picker = ImagePickerController()
picker.delegate = self
picker.isEditable = true
present(picker, animated: false) { picker.openImagePicker() }
// ImagePickerControllerDelegate
func imagePicker(didSelect image: UIImage?) {
avatarView.image = image
}
```
</details>
<details>
<summary><b>ImageZoomController</b> — full-screen pinch-zoom / pan viewer</summary>
### `init(_ withImage: UIImage)`
### `var minimumZoomScale: CGFloat` (default `1.0`) / `var maximumZoomScale: CGFloat` (default `6.0`)
### `var addGestureToDismiss: Bool` (default `true`) — drag down to close
### `weak var delegate: ImageZoomControllerDelegate?`
### `func present(completion: (() -> Void)? = nil)` / `func dismiss(completion: (() -> Void)? = nil)`
Present from / dismiss to the top-most controller.
### `@objc protocol ImageZoomControllerDelegate`
`imageZoomController(controller:didZoom:)`, `imageZoomController(controller:didClose:)` — both optional.
```swift
let viewer = ImageZoomController(photo)
viewer.maximumZoomScale = 4
viewer.present()
```
</details>
<details>
<summary><b>GifHelper</b> — animated GIF loading</summary>
### `UIImageView.loadGif(name: String)` / `UIImageView.loadGif(asset: String)` *(iOS 9+)*
Decode a bundled `.gif` (or an asset-catalog data set) off the main thread and
assign the resulting animated `UIImage`.
```swift
bannerView.loadGif(name: "loading") // loading.gif in the bundle
bannerView.loadGif(asset: "confetti") // NSDataAsset "confetti"
```
### `UIImage.gif(data: Data) -> UIImage?` / `gif(url: String) -> UIImage?` / `gif(name: String) -> UIImage?` / `gif(asset: String) -> UIImage?`
Build an animated `UIImage` from GIF bytes / a URL string / a bundled file / an
asset-catalog entry. Frame delays are honoured (via the GCD of per-frame delays).
```swift
let spinner = UIImage.gif(name: "spinner")
imageView.image = spinner
```
</details>

View File

@@ -22,3 +22,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Autor: Daniel Arantes Loverde

View File

@@ -1,13 +1,13 @@
{
"originHash" : "0cae429bbd156715149253da2c3f23815f5e41836679a585dd496e0d48155975",
"originHash" : "6e3fcf8724d6b7d2d0bbffc25b4ec3b9d8b65a6ef53d157498d5447fd26cf5db",
"pins" : [
{
"identity" : "lcecryptokitbinary",
"kind" : "remoteSourceControl",
"location" : "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git",
"state" : {
"revision" : "efd58c115c41bd8c28a1458a9150778806953bd0",
"version" : "1.0.1"
"revision" : "11f66b1b9785b4965e027c2fa4742f61e8453403",
"version" : "1.0.4"
}
}
],

View File

@@ -2,32 +2,45 @@
import PackageDescription
import Foundation
let isLocalDevelopment = FileManager.default.fileExists(atPath: "../LCECryptoKit/PrivateLib/LCECryptoKitBinary")
let isLocalDevelopment = false //FileManager.default.fileExists(atPath: "../LCECryptoKit/PrivateLib/LCECryptoKitBinary")
let enableCryptoBinary = ProcessInfo.processInfo.environment["LCE_ENABLE_CRYPTO_BINARY"] != "0"
let cryptoPackageURL = isLocalDevelopment
? "../LCECryptoKit/PrivateLib/LCECryptoKitBinary"
: "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git"
let packageDependencies: [Package.Dependency] = enableCryptoBinary
? [
.package(url: cryptoPackageURL, exact: "1.0.4")
]
: []
let targetDependencies: [Target.Dependency] = enableCryptoBinary
? [
.product(name: "LCECryptoKit", package: "lcecryptokitbinary")
]
: []
let package = Package(
name: "LCEssentials",
platforms: [
.iOS(.v13),
.iOS(.v15),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v6)
.watchOS(.v8)
],
products: [
.library(
name: "LCEssentials",
targets: ["LCEssentials"]),
],
dependencies: [
.package(
url: isLocalDevelopment ?
"../LCECryptoKit/PrivateLib/LCECryptoKitBinary" :
"https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git",
exact: "1.0.1"
)
],
dependencies: packageDependencies,
targets: [
.target(
name: "LCEssentials",
dependencies: [.product(name: "LCECryptoKit", package: "lcecryptokitbinary")]),
dependencies: targetDependencies),
.testTarget(
name: "LCEssentialsTests",
dependencies: ["LCEssentials"]),
]
)

View File

@@ -1,70 +1,43 @@
![](loverde_company_logo_full.png)
Loverde Co. Essentials Swift Scripts
----
This is a repository of essential scripts written in Swift for Loverde Co. used to save time on re-writing and keeping it on all other projects. So this Cocoapods will evolve with Swift and will improve with every release!
# Loverde Co. Essentials
Essential Swift scripts, extensions, SwiftUI components, and UIKit-era helpers,
shared across Loverde Co. projects. Evolves with Swift, improves every release.
## Requirements
- iOS 15.* or newer, Swift 5.* or newer.
## Features
- [x] Many usefull scripts extensions
- iOS 15 or newer · Swift 5 or newer
## Installation — Swift Package Manager
Installation
----
#### Swift Package Manager (SPM)
```swift
dependencies: [
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials", .upToNextMajor(from: "1.0.0"))
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials", .upToNextMajor(from: "2.0.0"))
]
```
You can also add it via XCode SPM editor with URL:
Or add it in Xcode via **File ▸ Add Package Dependencies…** with the URL:
``` swift
```
https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials
```
## Usage example
* Background Trhead
```swift
LCEssentials.backgroundThread(delay: 0.6, background: {
//Do something im background
}) {
//When finish, update UI
}
```
* NavigationController with Completion Handler
```swift
self.navigationController?.popViewControllerWithHandler(completion: {
//Do some stuff after pop
})
//or more simple
self.navigationController?.popViewControllerWithHandler {
//Do some stuff after pop
}
```
## Another components
> LCESnackBarView - **great way to send feedback to user**
And then import `LCEssentials ` wherever you import UIKit or SwiftUI
```swift
import LCEssentials
```
Author:
----
## Documentation
Any question or doubts, please send thru email
| Guide | Covers |
| --- | --- |
| **[API.md](Documentation/API.md)** | `API` networking — typed requests, multipart uploads, client certificates, error handling, and why it beats a hand-rolled `URLSession` |
| **[Extensions.md](Documentation/Extensions.md)** | Foundation / value-type / string / collection / numeric / date / crypto extensions and the `LCEssentials` namespace |
| **[SwiftUI.md](Documentation/SwiftUI.md)** | SwiftUI components (`LCENavigationView`) and `View` helpers |
| **[UIKit.md](Documentation/UIKit.md)** | Programmatic layout & constraints, view/control extensions, navigation, tables, and drop-in components (`LCSnackBarView`, image picker/zoom, GIF loading) |
Daniel Arantes Loverde - <daniel@loverde.com.br>
---
[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "My Resume")](https://github.com/loverde-co/resume/)
[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "Loverde Co. Github")](https://github.com/loverde-co)
Daniel Arantes Loverde — <daniel@loverde.com.br>
[<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" width="28" alt="GitHub">](https://github.com/loverde-co/resume/)

View File

@@ -5,8 +5,10 @@
// Created by iOSDevCenters on 11/12/15.
// Copyright © 2016 iOSDevCenters. All rights reserved.
//
#if canImport(UIKit)
import UIKit
#if os(iOS) || os(macOS)
#endif
#if canImport(UIKit)
import ImageIO
//let jeremyGif = UIImage.gifWithName("jeremy")

View File

@@ -21,21 +21,120 @@
import Foundation
#if canImport(LCECryptoKit)
import LCECryptoKit
public final class LCECrypto {
public final class LCECryptoKitManager {
private let hashKey: String
init(privateKey: String){
public init() {
self.hashKey = ""
}
public init(privateKey: String){
self.hashKey = privateKey
}
func encodeOTP(email: String, password: String) -> String? {
public static func generateKey() -> String {
LCECryptoKit.generateRandomAESKeyString()
}
public func encodeTP(email: String, password: String) -> String? {
return LCECryptoKit.encodeSeed(email: email, password: password)
}
public func decodeOTP(_ otpHash: String) -> String? {
return LCECryptoKit.decodeSeed(otpKey: otpHash)
}
// MARK: Need hashKey to decode
public func encodeOTPWithKey(email: String, password: String) -> String? {
return LCECryptoKit.encodeSeed(email: email, password: password, hashKey: self.hashKey)
}
func decodeOTP(_ otpHash: String) -> Bool {
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
LCECryptoKit.decodeSeed(otpKey: otpHash, hashKey: self.hashKey)
}
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
public static func generateSalt() -> String {
LCECryptoKit.generateSalt()
}
public static func computeClientHash(email: String, password: String, salt: String) -> String {
LCECryptoKit.computeClientHash(email: email, password: password, salt: salt)
}
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
LCECryptoKit.computeLoginBearerToken(userId: userId, clientHash: clientHash)
}
public static func otpEncode(_ plainText: String) -> String? {
LCECryptoKit.otpEncode(plainText)
}
public static func otpDecode(_ otpEncoded: String) -> String? {
LCECryptoKit.otpDecode(otpEncoded)
}
}
#else
public final class LCECryptoKitManager {
private let hashKey: String
public init() {
self.hashKey = ""
}
public init(privateKey: String){
self.hashKey = privateKey
}
public static func generateKey() -> String {
""
}
public func encodeTP(email: String, password: String) -> String? {
nil
}
public func decodeOTP(_ otpHash: String) -> String? {
nil
}
public func encodeOTPWithKey(email: String, password: String) -> String? {
nil
}
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
false
}
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
public static func generateSalt() -> String {
""
}
public static func computeClientHash(email: String, password: String, salt: String) -> String {
""
}
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
nil
}
public static func otpEncode(_ plainText: String) -> String? {
nil
}
public static func otpDecode(_ otpEncoded: String) -> String? {
nil
}
}
#endif

View File

@@ -0,0 +1,116 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
/// A request payload plus the `Content-Type` it implies.
///
/// Adopt this to teach `API.request` a new body encoding without changing `API`.
public protocol HTTPBody: Sendable {
/// - Returns: the encoded bytes and the `Content-Type` header value to send with them.
func encoded() throws -> (data: Data, contentType: String)
}
// MARK: - JSON
/// JSON-encodes an `Encodable` payload.
public struct JSONBody<Payload: Encodable & Sendable>: HTTPBody {
public let payload: Payload
private let encoder: @Sendable () -> JSONEncoder
/// - Parameters:
/// - payload: the value to encode.
/// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder.
public init(_ payload: Payload,
encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) {
self.payload = payload
self.encoder = encoderProvider
}
public func encoded() throws -> (data: Data, contentType: String) {
(try encoder().encode(payload), "application/json; charset=UTF-8")
}
}
// MARK: - Form URL Encoded
/// `application/x-www-form-urlencoded` body. Every value is percent-escaped
/// nothing is silently dropped for containing reserved characters.
public struct FormURLEncodedBody: HTTPBody {
public let fields: [String: String]
public init(_ fields: [String: String]) {
self.fields = fields
}
public func encoded() throws -> (data: Data, contentType: String) {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved
let pairs: [String] = fields.map { key, value in
let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
return "\(k)=\(v)"
}
let body = Data(pairs.joined(separator: "&").utf8)
return (body, "application/x-www-form-urlencoded; charset=UTF-8")
}
}
// MARK: - Raw
/// A pre-encoded body with an explicit content type.
public struct RawBody: HTTPBody {
public let data: Data
public let contentType: String
public init(data: Data, contentType: String) {
self.data = data
self.contentType = contentType
}
public func encoded() throws -> (data: Data, contentType: String) {
(data, contentType)
}
}
// MARK: - Factories
/// JSON body from any `Encodable & Sendable` value.
public func jsonBody<T: Encodable & Sendable>(_ value: T) -> JSONBody<T> {
JSONBody(value)
}
/// Form-url-encoded body from a string dictionary.
public func formBody(_ fields: [String: String]) -> FormURLEncodedBody {
FormURLEncodedBody(fields)
}
public extension HTTPBody where Self == FormURLEncodedBody {
/// Call-site sugar for `request(body: .form([...]))`.
static func form(_ fields: [String: String]) -> FormURLEncodedBody {
FormURLEncodedBody(fields)
}
}

View File

@@ -0,0 +1,190 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
/// A `multipart/form-data` body builder.
///
/// Text fields and small in-memory blobs are held as `Data`; on-disk files are
/// referenced by `URL` and streamed straight into the serialised body, so a
/// large upload never becomes fully resident in memory.
public struct MultipartForm: Sendable {
/// Where a part's content comes from.
public enum Source: Sendable {
case data(Data)
case file(URL)
}
struct Part: Sendable {
let name: String
let filename: String?
let mimeType: String?
let source: Source
/// `true` plain form field: no `filename` / `Content-Type` header lines.
let isField: Bool
}
/// Error thrown while serialising the body.
public enum SerializationError: Error {
case cannotCreateTempFile(URL)
case cannotOpenOutput(URL)
case cannotOpenInput(URL)
case writeFailed(underlying: Error?)
case readFailed(URL)
}
private(set) var parts: [Part] = []
public let boundary: String
private static let chunkSize = 64 * 1024
private static let crlf = "\r\n"
/// - Parameter boundary: multipart boundary token. Defaults to a random value.
public init(boundary: String = "LCEssentials-\(UUID().uuidString)") {
self.boundary = boundary
}
// MARK: - Building
/// Appends a plain text field.
public mutating func field(_ name: String, _ value: String) {
parts.append(Part(name: name, filename: nil, mimeType: nil,
source: .data(Data(value.utf8)), isField: true))
}
/// Appends an in-memory file part.
public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) {
parts.append(Part(name: name, filename: filename,
mimeType: mime ?? Self.mimeType(for: filename),
source: .data(data), isField: false))
}
/// Appends an on-disk file part. The file is streamed at serialisation time.
public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) {
let resolvedName = filename ?? url.lastPathComponent
parts.append(Part(name: name, filename: resolvedName,
mimeType: mime ?? Self.mimeType(for: resolvedName),
source: .file(url), isField: false))
}
// MARK: - Serialisation
/// Writes the whole body to a temporary file.
///
/// - Returns: the temp file URL (caller must delete it once the upload
/// finishes) and the `multipart/form-data; boundary=` content type.
public func serialize() throws -> (fileURL: URL, contentType: String) {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp")
guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else {
throw SerializationError.cannotCreateTempFile(fileURL)
}
guard let output = OutputStream(url: fileURL, append: false) else {
throw SerializationError.cannotOpenOutput(fileURL)
}
output.open()
defer { output.close() }
for part in parts {
try write(Data(header(for: part).utf8), to: output)
switch part.source {
case .data(let data):
try write(data, to: output)
case .file(let url):
try stream(fileAt: url, to: output)
}
try write(Data(Self.crlf.utf8), to: output)
}
try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output)
return (fileURL, "multipart/form-data; boundary=\(boundary)")
}
private func header(for part: Part) -> String {
var header = "--\(boundary)\(Self.crlf)"
header += "Content-Disposition: form-data; name=\"\(part.name)\""
if let filename = part.filename, !part.isField {
header += "; filename=\"\(filename)\""
}
header += Self.crlf
if !part.isField, let mime = part.mimeType {
header += "Content-Type: \(mime)\(Self.crlf)"
}
header += Self.crlf
return header
}
private func write(_ data: Data, to output: OutputStream) throws {
guard !data.isEmpty else { return }
var bytesRemaining = data
while !bytesRemaining.isEmpty {
let written = bytesRemaining.withUnsafeBytes { raw -> Int in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 }
return output.write(base, maxLength: bytesRemaining.count)
}
guard written > 0 else {
throw SerializationError.writeFailed(underlying: output.streamError)
}
bytesRemaining.removeFirst(written)
}
}
private func stream(fileAt url: URL, to output: OutputStream) throws {
guard let input = InputStream(url: url) else {
throw SerializationError.cannotOpenInput(url)
}
input.open()
defer { input.close() }
var buffer = [UInt8](repeating: 0, count: Self.chunkSize)
while input.hasBytesAvailable {
let read = input.read(&buffer, maxLength: buffer.count)
if read == 0 { break }
guard read > 0 else { throw SerializationError.readFailed(url) }
try write(Data(buffer[0..<read]), to: output)
}
}
// MARK: - MIME
/// Best-effort MIME type from a file name's extension.
/// Falls back to `application/octet-stream`.
public static func mimeType(for path: String) -> String {
let ext = (path as NSString).pathExtension.lowercased()
return mimeTypes[ext] ?? "application/octet-stream"
}
private static let mimeTypes: [String: String] = [
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif",
"pdf": "application/pdf", "txt": "text/plain", "html": "text/html", "htm": "text/html",
"json": "application/json", "xml": "application/xml", "zip": "application/zip",
"mp3": "audio/mpeg", "mp4": "video/mp4", "mov": "video/quicktime",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
}

View File

@@ -0,0 +1,94 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
#if os(iOS) || os(watchOS)
@available(iOS 13.0.0, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
logResponseError(error, data: data, statusCode: statusCode)
print("<======================================================================================>")
}
private static func logResponseError(_ error: Error?, data: Data?, statusCode: Int) {
if let error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
} else if let data, statusCode != 200 {
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
} else {
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
}
}
#endif

View File

@@ -0,0 +1,179 @@
//
// Copyright (c) 2020 Loverde Co.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
#if os(iOS) || os(watchOS)
@available(iOS 13.0.0, *)
public extension API {
/// Uploads a `multipart/form-data` body and decodes the JSON response.
///
/// The body is serialised to a temporary file and streamed from disk, so a
/// large file never becomes fully resident in memory. The temp file is
/// always removed before returning, on success and on throw.
///
/// - Parameters:
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
/// - method: The HTTP method. Defaults to `.post`.
/// - form: The multipart body (see ``MultipartForm``).
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
/// - headers: Custom headers, merged over the defaults (custom wins). The
/// `Content-Type` is always set to the multipart type.
/// - debug: Print request/response debug logs. Defaults to `true`.
/// - timeoutInterval: Request timeout in seconds. Defaults to `120`.
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
/// - Returns: `T` decoded from the response body.
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
/// status for non-2xx responses, or `DecodingError` on a malformed body.
func upload<T: Decodable & Sendable>(
url: String,
method: httpMethod = .post,
form: MultipartForm,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
debug: Bool = true,
timeoutInterval: TimeInterval = 120,
networkServiceType: URLRequest.NetworkServiceType = .default
) async throws -> T {
try await runUpload(url: url, method: method, form: form, pathParams: pathParams,
headers: headers, debug: debug, timeout: timeoutInterval,
serviceType: networkServiceType, progressDelegate: nil)
}
/// Multipart upload that reports progress.
///
/// Same as ``upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)``
/// but calls `onProgress` with a fraction in `0.0...1.0` as bytes are sent,
/// then `1.0` once the body has been fully transmitted.
///
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
/// actor yourself before touching UI.
func upload<T: Decodable & Sendable>(
url: String,
method: httpMethod = .post,
form: MultipartForm,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
debug: Bool = true,
timeoutInterval: TimeInterval = 120,
networkServiceType: URLRequest.NetworkServiceType = .default,
onProgress: @escaping @Sendable (Double) -> Void
) async throws -> T {
let result: T = try await runUpload(url: url, method: method, form: form,
pathParams: pathParams, headers: headers, debug: debug,
timeout: timeoutInterval, serviceType: networkServiceType,
progressDelegate: UploadProgressDelegate(onProgress: onProgress))
onProgress(1.0)
return result
}
}
// MARK: - Shared internals
@available(iOS 13.0.0, *)
extension API {
private func runUpload<T: Decodable & Sendable>(
url: String,
method: httpMethod,
form: MultipartForm,
pathParams: [String: String],
headers: [String: String],
debug: Bool,
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType,
progressDelegate: UploadProgressDelegate?
) async throws -> T {
let prepared = try buildUploadRequest(url: url, method: method, form: form,
pathParams: pathParams, headers: headers,
timeout: timeout, serviceType: serviceType)
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
if debug { API.requestLOG(method: method, request: prepared.request) }
let (session, mustInvalidate) = makeSession()
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
let (data, response) = try await session.upload(for: prepared.request,
fromFile: prepared.bodyFile,
delegate: progressDelegate)
return try API.finishUpload(data: data, response: response,
method: method, request: prepared.request, debug: debug)
}
private func buildUploadRequest(
url: String,
method: httpMethod,
form: MultipartForm,
pathParams: [String: String],
headers: [String: String],
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType
) throws -> (request: URLRequest, bodyFile: URL) {
let resolvedURL = try makeURL(url, pathParams: pathParams)
var request = buildRequest(url: resolvedURL, method: method, headers: headers,
timeout: timeout, serviceType: serviceType)
let serialized = try form.serialize()
request.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type")
return (request, serialized.fileURL)
}
fileprivate static func finishUpload<T: Decodable & Sendable>(
data: Data,
response: URLResponse,
method: httpMethod,
request: URLRequest,
debug: Bool
) throws -> T {
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
switch classify(code: code, data: data, method: method, request: request, debug: debug) {
case .success:
return try decodeResponse(data)
case .clientError, .otherError:
throw friendlyError(code: code, data: data)
}
}
}
/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable
/// after `init`, safe to hand to `URLSession` as a task delegate.
@available(iOS 13.0.0, *)
private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress
super.init()
}
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
guard totalBytesExpectedToSend > 0 else { return }
let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
onProgress(min(max(fraction, 0), 1))
}
}
#endif

View File

@@ -29,14 +29,6 @@ import Security
import UIKit
#endif
/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`.
public enum Result<Value, Error: Swift.Error> {
/// Indicates a successful operation with an associated `Value`.
case success(Value)
/// Indicates a failed operation with an associated `Error`.
case failure(Error)
}
/// Enumeration defining common HTTP methods.
public enum httpMethod: String {
/// The POST method.
@@ -45,227 +37,206 @@ public enum httpMethod: String {
case get = "GET"
/// The PUT method.
case put = "PUT"
/// The PATCH method.
case patch = "PATCH"
/// The DELETE method.
case delete = "DELETE"
}
/// Loverde Co.: API generic struct for simple requests.
/// Loverde Co.: API entry point for simple requests.
///
/// This struct provides a convenient way to perform network requests with various configurations,
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication.
/// An `actor`, so callers are never forced onto the main thread. It performs
/// network requests with various configurations, handling different HTTP
/// methods, typed request bodies, multipart uploads, and certificate-based
/// authentication.
@available(iOS 13.0.0, *)
@MainActor
public struct API {
public actor API {
private static var certData: Data?
private static var certPassword: String?
/// Client-certificate data (`.p12`) for mutual-TLS, if configured.
private var certData: Data?
/// Password for `certData`, if any.
private var certPassword: String?
/// Session configuration used to build `URLSession`s. `nil` in production
/// (the shared session / a dedicated cert session is used); injected by
/// tests to register a stub `URLProtocol`.
private let sessionConfiguration: URLSessionConfiguration?
/// The default error used when an unexpected issue occurs during a request.
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
nonisolated static var defaultError: NSError {
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
}
/// The delay in seconds before retrying a persistent connection request.
public static var persistConnectionDelay: Double = 3
/// Default parameters that will be included in all requests unless explicitly overridden.
public static var defaultParams: [String:Any] = [String: Any]()
public private(set) var persistConnectionDelay: Double = 3
/// Default HTTP headers for requests.
///
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
var defaultHeaders: [String: String] = ["Accept": "application/json",
nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
"Content-Type": "application/json; charset=UTF-8",
"Accept-Encoding": "gzip"]
/// The shared singleton instance of the `API` struct.
/// The shared singleton instance of `API`.
public static let shared = API()
private init(){}
private init() {
self.sessionConfiguration = nil
}
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
/// Test-only. Builds an isolated instance whose `URLSession`s use
/// `configuration` register a stub `URLProtocol` on it. Never call from
/// production code; `shared` state is untouched.
init(testConfiguration configuration: URLSessionConfiguration) {
self.sessionConfiguration = configuration
}
/// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``.
var hasClientCertificateConfigured: Bool { certData != nil }
/// Overrides ``persistConnectionDelay``.
public func setPersistConnectionDelay(_ seconds: Double) {
persistConnectionDelay = seconds
}
/// Maximum number of extra attempts made when `persistConnection` is set and
/// the server keeps returning a 4xx. Bounds what was previously an unbounded
/// recursion on a permanent client error.
public static let maxPersistRetries = 3
/// Performs an asynchronous network request and decodes the JSON response.
///
/// - Parameters:
/// - url: The URL string for the request.
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`).
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
/// - Returns: An instance of the `T` type, decoded from the response data.
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
public func request<T: Codable>(url: String,
params: Any? = nil,
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
/// `Content-Length` are set automatically.
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
/// - headers: Custom headers, merged over the defaults (custom wins).
/// - debug: Print request/response debug logs. Defaults to `true`.
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
/// status for non-2xx responses, or `DecodingError` on a malformed body.
public func request<T: Decodable & Sendable>(
url: String,
method: httpMethod,
body: (any HTTPBody)? = nil,
pathParams: [String: String] = [:],
headers: [String: String] = [:],
jsonEncoding: Bool = true,
debug: Bool = true,
timeoutInterval: TimeInterval = 30,
networkServiceType: URLRequest.NetworkServiceType = .default,
persistConnection: Bool = false) async throws -> T {
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
if method == .post || method == .put || method == .delete {
if let params = params as? [String: Any],
let pathFile = params["file"] as? String,
let fileURL = URL(string: pathFile) {
let boundary = UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
// Add additional fields (if any)
for (key, value) in params where key != "file" {
let stringValue = "\(value)"
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(stringValue)\r\n".data(using: .utf8)!)
persistConnection: Bool = false
) async throws -> T {
let resolvedURL = try makeURL(url, pathParams: pathParams)
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
timeout: timeoutInterval, serviceType: networkServiceType)
if let body {
try Self.attach(body: body, to: &urlRequest)
}
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
}
// Add the file
let fileName = fileURL.lastPathComponent
let mimeType = mimeTypeForPath(path: fileName)
printInfo(title: "Body size before", msg: "\(body.count) bytes")
/// Sends a fully-built request, handles the response, and applies bounded
/// `persistConnection` retries.
private func send<T: Decodable & Sendable>(
_ urlRequest: URLRequest,
method: httpMethod,
debug: Bool,
persistConnection: Bool,
retriesLeft: Int
) async throws -> T {
if debug { API.requestLOG(method: method, request: urlRequest) }
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
printInfo(title: "Body size after", msg: "\(body.count) bytes")
} catch {
printError(title: "Upload File", msg: error.localizedDescription)
throw error
let (session, mustInvalidate) = makeSession()
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
let (data, response) = try await session.data(for: urlRequest)
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
case .success:
return try Self.decodeResponse(data)
case .clientError where persistConnection && retriesLeft > 0:
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
case .clientError, .otherError:
throw Self.friendlyError(code: code, data: data)
}
}
do {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
let fileDataCopy = Data(fileData)
body.append(fileDataCopy)
let dataUTF8 = "\r\n".data(using: .utf8)!
body.append(dataUTF8)
printInfo(title: "Body size after", msg: "\(body.count) bytes")
// MARK: - Request building
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
return url
}
// Finalize the request body
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
// Debug logs
printLog(title: "Boundary", msg: boundary)
if let bodyString = String(data: body, encoding: .utf8) {
printLog(title: "Body Content", msg: bodyString)
}
} else if jsonEncoding, let params = params as? [String: Any] {
let requestObject = try JSONSerialization.data(withJSONObject: params)
request.httpBody = requestObject
} else if let params = params as? [String: Any] {
var bodyComponents = URLComponents()
params.forEach({ (key, value) in
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
})
request.httpBody = bodyComponents.query?.data(using: .utf8)
} else if let params = params as? Data {
request.httpBody = params
}
}
func buildRequest(url: URL,
method: httpMethod,
headers: [String: String],
timeout: TimeInterval,
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.httpMethod = method.rawValue
request.timeoutInterval = timeoutInterval
request.networkServiceType = networkServiceType
request.timeoutInterval = timeout
request.networkServiceType = serviceType
defaultHeaders.merging(headers) { _, custom in custom }
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
return request
}
// - Put Default Headers together with user defined params
if !headers.isEmpty {
// - Add it to request
headers.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
}else{
defaultHeaders.forEach { (key, value) in
request.addValue(value, forHTTPHeaderField: key)
}
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
/// content type wins over any set through `headers`.
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
let (data, contentType) = try body.encoded()
request.httpBody = data
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
}
// MARK: - Response handling
enum ResponseDisposition { case success, clientError, otherError }
static func classify(code: Int, data: Data, method: httpMethod,
request: URLRequest, debug: Bool) -> ResponseDisposition {
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
if debug {
API.requestLOG(method: method, request: request)
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let session = URLSession(
configuration: .default,
delegate: URLSessionDelegateHandler(
certData: API.certData,
password: API.certPassword
),
delegateQueue: nil
)
do {
let (data, response) = try await session.data(for: request)
var code: Int = LCEssentials.DEFAULT_ERROR_CODE
let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse()
code = httpResponse.statusCode
let error = URLError(URLError.Code(rawValue: code))
switch code {
case 200..<300:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil)
case 200..<300: return .success
case 400..<500: return .clientError
default: return .otherError
}
}
// - Check if is JSON result and try decode it
if let string = data.string as? T, T.self == String.self {
static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
return string
}
// - Normal decoding
do {
return try JSONDecoder.decode(data: data)
} catch {
printError(title: "JSONDecoder", msg: error.localizedDescription)
throw error
}
case 400..<500:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
if persistConnection {
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
// Recursive call for persistence
let persist: T = try await self.request(
url: url,
params: params,
method: method,
headers: headers,
jsonEncoding: jsonEncoding,
debug: debug,
timeoutInterval: timeoutInterval,
networkServiceType: networkServiceType,
persistConnection: persistConnection
)
return persist
} else {
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
default:
// - Debug LOG
if debug {
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
}
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
throw friendlyError
}
} catch {
throw error
}
}
throw API.defaultError
static func friendlyError(code: Int, data: Data) -> NSError {
let urlError = URLError(URLError.Code(rawValue: code))
return NSError.createErrorWith(code: code,
description: urlError.localizedDescription,
reasonForError: data.prettyJson ?? "")
}
/// Sets up client certificate data and an optional password for authentication.
@@ -273,21 +244,52 @@ public struct API {
/// - Parameters:
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file).
/// - password: The password for the certificate, if required. Defaults to an empty string.
public func setupCertificationRequest(certData: Data, password: String = "") {
API.certData = certData
API.certPassword = password
public func setupCertification(certData: Data, password: String = "") {
self.certData = certData
self.certPassword = password
}
/// Builds the `URLSession` for one request.
///
/// - A test configuration (if injected) always wins, carrying the cert
/// delegate when one is configured.
/// - Otherwise a dedicated, delegate-backed session is created only when a
/// client certificate is configured, and must be invalidated afterwards.
/// - The common no-cert case reuses `URLSession.shared`.
///
/// - Returns: the session and whether the caller must invalidate it.
func makeSession() -> (session: URLSession, mustInvalidate: Bool) {
#if canImport(Security)
let delegate: URLSessionDelegateHandler? = certData != nil
? URLSessionDelegateHandler(certData: certData, password: certPassword)
: nil
#else
let delegate: URLSessionDelegate? = nil
#endif
if let configuration = sessionConfiguration {
return (URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), true)
}
if delegate != nil {
return (URLSession(configuration: .default, delegate: delegate, delegateQueue: nil), true)
}
return (URLSession.shared, false)
}
}
#if canImport(Security)
/// A custom `URLSessionDelegate` handler for managing URL session challenges,
/// particularly for client and server trust authentication.
///
/// Immutable after `init`, so it is safe to hand to `URLSession` and have its
/// challenge callback invoked on any thread. The auth logic touches only the
/// Security framework, which is thread-safe.
@available(iOS 13.0.0, *)
@MainActor
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
private var certData: Data?
private var certPass: String?
private let certData: Data?
private let certPass: String?
/// Initializes a new `URLSessionDelegateHandler` instance.
///
@@ -295,9 +297,9 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
/// - certData: Optional `Data` for the client certificate.
/// - password: Optional password for the client certificate.
init(certData: Data? = nil, password: String? = nil) {
super.init()
self.certData = certData
self.certPass = password
super.init()
}
/// Handles URL session authentication challenges.
@@ -344,16 +346,29 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
// Import the .p12 certificate to get the identity
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
if status == errSecSuccess,
let item = (items as? [[String: Any]])?.first,
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?,
CFGetTypeID(identityRef) == SecIdentityGetTypeID() {
return (identityRef as! SecIdentity)
} else {
print("Erro ao importar a identidade do certificado: \(status)")
guard status == errSecSuccess,
let items,
CFArrayGetCount(items) > 0,
let itemPtr = CFArrayGetValueAtIndex(items, 0) else {
printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))")
return nil
}
// Read the identity straight out of the CF result via typed `Unmanaged`
// bridging no `as!`. `SecPKCS12Import` boxes a CoreFoundation ref that
// Swift will not let us downcast without a force operation.
let itemDict = Unmanaged<CFDictionary>.fromOpaque(itemPtr).takeUnretainedValue()
let identityKey = Unmanaged.passUnretained(kSecImportItemIdentity).toOpaque()
guard let identityPtr = CFDictionaryGetValue(itemDict, identityKey) else {
printError(title: "Certificate", msg: "Imported .p12 contained no client identity")
return nil
}
let identity = Unmanaged<SecIdentity>.fromOpaque(identityPtr).takeUnretainedValue()
guard CFGetTypeID(identity) == SecIdentityGetTypeID() else {
printError(title: "Certificate", msg: "Imported .p12 entry is not a SecIdentity")
return nil
}
return identity
}
/// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.
@@ -382,133 +397,4 @@ extension Error {
}
}
@available(iOS 13.0.0, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the request.
/// - request: The `URLRequest` object.
fileprivate static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the original request.
/// - request: The `URLRequest` object that generated this response.
/// - data: The data received in the response.
/// - statusCode: The HTTP status code of the response.
/// - error: An optional `Error` object if the request failed.
fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
///
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
//
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
//
if let error = error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
}else if let data = data, statusCode != 200 {
// - Check if is JSON result
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
}else{
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
//
print("<======================================================================================>")
}
/// Determines the MIME type for a given file path based on its extension.
///
/// - Parameter path: The file path string.
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
func mimeTypeForPath(path: String) -> String {
let url = URL(fileURLWithPath: path)
let pathExtension = url.pathExtension.lowercased()
// Dictionary of common extensions and MIME types
let mimeTypes: [String: String] = [
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"pdf": "application/pdf",
"txt": "text/plain",
"html": "text/html",
"htm": "text/html",
"json": "application/json",
"xml": "application/xml",
"zip": "application/zip",
"mp3": "audio/mpeg",
"mp4": "video/mp4",
"mov": "video/quicktime",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
return mimeTypes[pathExtension] ?? "application/octet-stream"
}
}
#endif

View File

@@ -21,8 +21,12 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AVFoundation)
import AVFoundation
#endif
#if os(watchOS)
import WatchKit
#endif
@@ -155,7 +159,7 @@ public struct LCEssentials {
diskPath: "file_cache"
)
#if os(iOS) || os(macOS)
#if canImport(UIKit)
/// Extract the file name from the file path
///
/// - Parameter filePath: Full file path in bundle
@@ -266,7 +270,11 @@ public struct LCEssentials {
/// - LoverdeCo: Check if app is running in debug mode.
@MainActor
public static var isInDebuggingMode: Bool {
#if canImport(UIKit)
return UIApplication.inferredEnvironment == .debug
#else
return false
#endif
}
#if !os(macOS)
@@ -416,7 +424,7 @@ public struct LCEssentials {
// MARK: - Methods
public extension LCEssentials {
#if os(iOS) || os(macOS)
#if canImport(UIKit)
/// - LoverdeCo: Share link with message
///
/// - Parameters:

View File

@@ -20,7 +20,9 @@
// THE SOFTWARE.
import Foundation
#if canImport(UIKit)
import UIKit
#endif
@objc public protocol LCESingletonDelegate: AnyObject {
@objc optional func singleton(object: Any?, withData: Any)

View File

@@ -21,7 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Methods (Equatable)
public extension Array where Element: Equatable {
@@ -148,6 +150,7 @@ public extension Array where Element: Equatable {
}
}
#if canImport(UIKit)
extension Array where Element == NSLayoutConstraint {
@MainActor
@@ -169,3 +172,5 @@ extension Array where Element == NSLayoutConstraint {
}
}
}
#endif

View File

@@ -21,8 +21,12 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#endif
#if canImport(CommonCrypto)
import CommonCrypto
#endif
public extension Data {
@@ -71,17 +75,25 @@ public extension Data {
@available(iOS 13.0, *)
func HMACSHA512(key: Data) -> Data {
#if canImport(CryptoKit)
var hmac = HMAC<SHA512>.init(key: SymmetricKey(data: key))
hmac.update(data: self)
return Data(hmac.finalize())
#else
return Data()
#endif
}
func SHA512() -> Data {
#if canImport(CommonCrypto)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
self.withUnsafeBytes {
_ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest)
}
return Data(digest)
#else
return Data()
#endif
}
func XOR(with other: Data) -> Data {
@@ -89,14 +101,18 @@ public extension Data {
}
func SHA256() -> Data {
#if canImport(CommonCrypto)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
self.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest)
}
return Data(digest)
#else
return Data()
#endif
}
func object<T: Codable>() -> T? {
func object<T: Codable & Sendable>() -> T? {
do {
let outPut: T = try JSONDecoder.decode(data: self)
return outPut
@@ -124,9 +140,13 @@ public extension Data {
///print("md5Hex: \(md5Hex)")
@available(iOS 13.0, *)
static func MD5(string: String) -> Data {
#if canImport(CryptoKit)
let messageData = string.data(using: .utf8)!
let digestData = Insecure.MD5.hash (data: messageData)
let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32))
return Data(digestHex.utf8)
#else
return Data()
#endif
}
}

View File

@@ -21,7 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
public extension Dictionary {
@@ -135,11 +137,10 @@ public extension Dictionary {
/// - LoverdeCo: Convert Dictonary to Object
///
/// - returns: Object: Codable/Decodable
func toObjetct<T: Codable>() -> T {
let jsonString = self.convertToJSON
let output: T = try! JSONDecoder.decode(jsonString)
return output
/// - returns: Object: Decodable & Sendable
/// - throws: `DecodingError` when the dictionary does not match `T`.
func toObjetct<T: Codable & Sendable>() throws -> T {
try JSONDecoder.decode(self.convertToJSON)
}
/// Check if key exists in dictionary.

View File

@@ -44,8 +44,8 @@ extension JSONDecoder {
/// - LoverdeCo: Decode JSON Data to Object
///
/// - Parameter data: Data
/// - returns: Object: Codable/Decodable
public static func decode<T: Codable>(data: Data) throws -> T {
/// - returns: Object: Decodable & Sendable
public static func decode<T: Decodable & Sendable>(data: Data) throws -> T {
var error = NSError(domain: "", code: 0)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .useDefaultKeys
@@ -70,28 +70,24 @@ extension JSONDecoder {
/// - LoverdeCo: Decode JSON String to Object
///
/// - Parameter json: String
/// - returns: Object: Codable/Decodable
public static func decode<T: Codable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
var error = NSError()
if let jsonData = json.data(using: .utf8) {
do {
/// - returns: Object: Decodable & Sendable
public static func decode<T: Decodable & Sendable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
guard let jsonData = json.data(using: encoding) else {
let msg = "Could not convert string to \(encoding) data for \(T.self)"
throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg)
}
return try decode(data: jsonData)
} catch {
throw error
}
}
throw error
}
/// - LoverdeCo: Decode JSON URL to Object
///
/// - Parameter url: URL
/// - returns: Object: Codable/Decodable
public static func decode<T: Codable>(fromURL url: URL) throws -> T {
return try decode(data: try! Data(contentsOf: url))
/// - returns: Object: Decodable & Sendable
public static func decode<T: Decodable & Sendable>(fromURL url: URL) throws -> T {
return try decode(data: Data(contentsOf: url))
}
public static func decode<T: Codable>(dictionary: Any) throws -> T {
public static func decode<T: Decodable & Sendable>(dictionary: Any) throws -> T {
do {
let json = try JSONSerialization.data(withJSONObject: dictionary)
let decoder = JSONDecoder()

View File

@@ -21,7 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
public extension FileManager {
@@ -54,7 +56,7 @@ public extension FileManager {
}
}
#if os(iOS) || os(macOS)
#if canImport(UIKit)
func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool {
let data = imagem.pngData()

View File

@@ -21,8 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(UIKit)
extension NSLayoutConstraint {
func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint {
@@ -72,3 +75,4 @@ extension NSLayoutConstraint {
return false
}
}
#endif

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension NSMutableAttributedString {
@discardableResult func customize(_ text: String,

View File

@@ -20,7 +20,9 @@
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#endif
public extension Optional {
/// Get self of default value (if self is nil).

View File

@@ -21,7 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CommonCrypto)
import CommonCrypto
#endif
@@ -48,10 +50,14 @@ public extension String {
}
var convertToHTML: NSAttributedString? {
#if canImport(UIKit)
return convertHtmlToAttributedStringWithCSS(font: nil,
csscolor: "",
lineheight: 0,
csstextalign: "")
#else
return convertHtmlToNSAttributedString
#endif
}
/// Check if string is a valid URL.
@@ -646,7 +652,7 @@ public extension String {
return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone)
}
#if os(iOS) || os(macOS)
#if canImport(UIKit)
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
@@ -757,7 +763,7 @@ public extension String {
}
return self
}
#if canImport(UIKit)
/// Converte String para HTML com CSS.
///
/// - Parameters:
@@ -801,6 +807,7 @@ public extension String {
return nil
}
}
#endif
/// Float value from string (if applicable).
///

View File

@@ -19,8 +19,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit) && os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension UIApplication {
/// Application running environment.

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
// MARK: - Properties
public extension UIButton {

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension UICollectionView {

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
public extension UIColor {
var redValue: CGFloat{ return CIColor(color: self).red }

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
public extension UIDevice {
static var topNotch: CGFloat {
@@ -78,7 +80,6 @@ public extension UIDevice {
case "iPhone8,2": return "iPhone 6s Plus"
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
case "iPhone8,4", "iPhone12,8": return "iPhone SE"
case "iPhone10,1", "iPhone10,4": return "iPhone 8"
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
case "iPhone10,3", "iPhone10,6": return "iPhone X"
@@ -92,30 +93,76 @@ public extension UIDevice {
case "iPhone13,2": return "iPhone 12"
case "iPhone13,3": return "iPhone 12 Pro"
case "iPhone13,4": return "iPhone 12 Pro Max"
case "iPhone14,4": return "iPhone 13 mini"
case "iPhone14,5": return "iPhone 13"
case "iPhone14,2": return "iPhone 13 Pro"
case "iPhone14,3": return "iPhone 13 Pro Max"
case "iPhone14,7": return "iPhone 14"
case "iPhone14,8": return "iPhone 14 Plus"
case "iPhone15,2": return "iPhone 14 Pro"
case "iPhone15,3": return "iPhone 14 Pro Max"
case "iPhone15,4": return "iPhone 15"
case "iPhone15,5": return "iPhone 15 Plus"
case "iPhone16,1": return "iPhone 15 Pro"
case "iPhone16,2": return "iPhone 15 Pro Max"
case "iPhone17,3": return "iPhone 16"
case "iPhone17,4": return "iPhone 16 Plus"
case "iPhone17,1": return "iPhone 16 Pro"
case "iPhone17,2": return "iPhone 16 Pro Max"
case "iPhone17,5": return "iPhone 16e"
case "iPhone18,3": return "iPhone 17"
case "iPhone18,4": return "iPhone Air"
case "iPhone18,1": return "iPhone 17 Pro"
case "iPhone18,2": return "iPhone 17 Pro Max"
case "iPhone8,4": return "iPhone SE"
case "iPhone12,8": return "iPhone SE (2nd generation)"
case "iPhone14,6": return "iPhone SE (3rd generation)"
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2"
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad 3"
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad 4"
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)"
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)"
case "iPad6,11", "iPad6,12": return "iPad (5th generation)"
case "iPad7,5", "iPad7,6": return "iPad (6th generation)"
case "iPad7,11", "iPad7,12": return "iPad (7th generation)"
case "iPad11,6", "iPad11,7": return "iPad (8th generation)"
case "iPad12,1", "iPad12,2": return "iPad (9th generation)"
case "iPad13,18", "iPad13,19": return "iPad (10th generation)"
case "iPad15,7", "iPad15,8": return "iPad (11th generation)"
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
case "iPad5,3", "iPad5,4": return "iPad Air 2"
case "iPad6,11", "iPad6,12": return "iPad 5"
case "iPad7,5", "iPad7,6": return "iPad 6"
case "iPad7,11", "iPad7,12": return "iPad 7"
case "iPad11,4", "iPad11,5": return "iPad Air (3rd generation)"
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad Mini"
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad Mini 2"
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad Mini 3"
case "iPad5,1", "iPad5,2": return "iPad Mini 4"
case "iPad11,1", "iPad11,2": return "iPad Mini 5"
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)"
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)"
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)"
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)"
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)"
case "iPad15,3", "iPad15,4": return "iPad Air (11-inch) (M3)"
case "iPad15,5", "iPad15,6": return "iPad Air (13-inch) (M3)"
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini"
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2"
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3"
case "iPad5,1", "iPad5,2": return "iPad mini 4"
case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)"
case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)"
case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)"
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch)"
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)"
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4":return "iPad Pro (11-inch)"
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)"
case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)"
case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)"
case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)"
case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)"
case "iPad17,1", "iPad17,2": return "iPad Pro (11-inch) (M5)"
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)"
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)"
case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)"
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)"
case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)"
case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)"
case "iPad17,3", "iPad17,4": return "iPad Pro (13-inch) (M5)"
case "AppleTV5,3": return "Apple TV"
case "AppleTV6,2": return "Apple TV 4K"
case "AudioAccessory1,1": return "HomePod"
case "i386", "x86_64": return "Simulator \(identifier)"
case "AudioAccessory5,1": return "HomePod mini"
default: return identifier
}
#endif

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
public extension UIImage {
//Extension Required by RoundedButton to create UIImage from UIColor
func imageWithColor(color: UIColor) -> UIImage {

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
@MainActor
public extension UIImageView {

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
public extension UILabel {
func lineNumbers() -> Int{

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
import QuartzCore
public extension UINavigationController {

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension UIResponder {

View File

@@ -21,7 +21,9 @@
#if canImport(UIKit) && !os(watchOS)
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Methods
public extension UIScrollView {

View File

@@ -21,7 +21,9 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS)
// MARK: - Initializers

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public class CustomTabBadge: UILabel {

View File

@@ -21,9 +21,11 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
//MARK: - UITableView Animation Cell
public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void

View File

@@ -21,8 +21,10 @@
import Foundation
#if canImport(UIKit) && os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension UITapGestureRecognizer {

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
public extension UITextField {

View File

@@ -21,8 +21,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint)

View File

@@ -22,8 +22,10 @@
import Foundation
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
import QuartzCore
public enum ToastPosition {

View File

@@ -20,8 +20,10 @@
// THE SOFTWARE.
#if os(iOS) || os(macOS)
#if canImport(UIKit)
#if canImport(UIKit)
import UIKit
#endif
import AVFoundation
import Photos

View File

@@ -19,9 +19,11 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(macOS)
#if canImport(UIKit)
/// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events.
@objc public protocol ImageZoomControllerDelegate {
/// Called when the image in the controller is zoomed.

View File

@@ -20,6 +20,8 @@
// THE SOFTWARE.
// MARK: - Framework headers
#if canImport(UIKit)
import Foundation
import UIKit
// MARK: - Protocols
@@ -231,54 +233,37 @@ public extension LCSnackBarView {
/// Handles the `keyboardWillShowNotification` to adjust the snackbar's position.
/// - Parameter notification: The `Notification` object containing keyboard information.
@objc private func keyboardWillShow(_ notification: Notification?) -> Void {
if let info = notification?.userInfo {
@MainActor
@objc private func keyboardWillShow(_ notification: Notification?) {
guard let info = notification?.userInfo else { return }
systemKeyboardVisible = true
//
let curveUserInfoKey = UIResponder.keyboardAnimationCurveUserInfoKey
let durationUserInfoKey = UIResponder.keyboardAnimationDurationUserInfoKey
let frameEndUserInfoKey = UIResponder.keyboardFrameEndUserInfoKey
//
var animationCurve: UIView.AnimationOptions = .curveEaseOut
var animationDuration: TimeInterval = 0.25
var height:CGFloat = 0.0
// Getting keyboard animation.
if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions {
animationCurve = curve
}
let animationCurveRaw = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
?? UInt(UIView.AnimationCurve.easeOut.rawValue)
let animationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue
?? 0.25
let keyboardFrame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
?? .zero
let height = keyboardFrame.height
let animationCurve = UIView.AnimationOptions(rawValue: animationCurveRaw << 16)
// Getting keyboard animation duration
if let duration = info[durationUserInfoKey] as? TimeInterval {
animationDuration = duration
}
// Getting UIKeyboardSize.
if let kbFrame = info[frameEndUserInfoKey] as? CGRect {
height = kbFrame.size.height
}
DispatchQueue.main.async { [weak self] in
UIView.animate(withDuration: animationDuration,
UIView.animate(
withDuration: animationDuration,
delay: 0,
options: animationCurve,
animations: {
options: animationCurve
) { [weak self] in
self?.frame.origin.y += height
})
}
}
}
/// Handles the `keyboardWillHideNotification`.
/// - Parameter notification: The `Notification` object.
@objc private func keyboardWillHide(_ notification: Notification?) -> Void {
DispatchQueue.main.async { [weak self] in
self?.systemKeyboardVisible = false
@MainActor
@objc private func keyboardWillHide(_ notification: Notification?) {
systemKeyboardVisible = false
// keyboard is hidded
}
}
/// Updates the snackbar's style properties, such as width and corner radius, based on `_style`.
private func updateStyle() {
@@ -512,3 +497,5 @@ public extension LCSnackBarView {
}
}
}
#endif

View File

@@ -18,7 +18,7 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(SwiftUI)
#if canImport(SwiftUI) && os(iOS)
import SwiftUI
/// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`.
@@ -47,6 +47,41 @@ class LCENavigationState: ObservableObject {
@Published var title: (any View) = Text("")
/// The subtitle view of the navigation bar.
@Published var subTitle: (any View) = Text("")
/// The background color of the navigation bar.
@Published var navigationBarBackgroundColor: Color = .clear
/// Whether a left button was actually configured via `setLeftButton`.
@Published var hasLeftButton: Bool = false
/// Whether a right button was actually configured via `setRightButton`.
@Published var hasRightButton: Bool = false
/// Whether buttons should opt into the system Liquid Glass button style (iOS 26+). Off by default.
@Published var useGlassButtons: Bool = false
}
/// `PreferenceKey` used to measure the widest side button so the title can be padded symmetrically and stay centered without overlapping either button.
@available(iOS 15, *)
private struct LCENavButtonWidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
@available(iOS 15, *)
private extension View {
/// Applies `.glass` button style on iOS 26+ when enabled, otherwise falls back to `.plain` the navigation bar is not designed around glass, but stays opt-in ready.
@ViewBuilder
func lce_applyGlassIfEnabled(_ enabled: Bool) -> some View {
if enabled {
if #available(iOS 26.0, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.plain)
}
} else {
self.buttonStyle(.plain)
}
}
}
/// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar.
@@ -59,6 +94,9 @@ public struct LCENavigationView<Content: View>: View {
/// The content view displayed below the navigation bar.
let content: Content
/// The measured width of the widest side button, used to pad the title so it never overlaps either button.
@State private var maxButtonWidth: CGFloat = 0
/// Initializes a new `LCENavigationView` instance.
/// - Parameters:
/// - title: The title view for the navigation bar. Defaults to an empty `Text`.
@@ -79,7 +117,7 @@ public struct LCENavigationView<Content: View>: View {
/// The body of the `LCENavigationView`.
public var body: some View {
VStack {
VStack(spacing: 0) {
if !state.hideNavigationBar {
NavigationBarView
}
@@ -90,18 +128,29 @@ public struct LCENavigationView<Content: View>: View {
/// The private `NavigationBarView` that lays out the navigation bar components.
private var NavigationBarView: some View {
HStack {
NavLeftButton
Spacer()
ZStack {
TitleView
.padding(.horizontal, maxButtonWidth)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
HStack {
if state.hasLeftButton {
NavLeftButton
}
Spacer()
if state.hasRightButton {
NavRightButton
}
}
}
.font(.headline)
.padding()
.background {
Color.clear.ignoresSafeArea(edges: .top)
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top)
}
.onPreferenceChange(LCENavButtonWidthPreferenceKey.self) { maxButtonWidth = $0 }
}
/// The private `TitleView` that displays the title and subtitle.
@@ -114,7 +163,7 @@ public struct LCENavigationView<Content: View>: View {
}
}
/// The private `NavLeftButton` view.
/// The private `NavLeftButton` view. Only rendered when `setLeftButton` was actually called never a hidden placeholder.
private var NavLeftButton: some View {
Button(action: state.leftButtonAction) {
HStack {
@@ -124,9 +173,15 @@ public struct LCENavigationView<Content: View>: View {
state.leftButtonText
}
}
.lce_applyGlassIfEnabled(state.useGlassButtons)
.background(
GeometryReader { proxy in
Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width)
}
)
}
/// The private `NavRightButton` view.
/// The private `NavRightButton` view. Only rendered when `setRightButton` was actually called never a hidden placeholder.
private var NavRightButton: some View {
Button(action: state.rightButtonAction) {
HStack {
@@ -136,6 +191,12 @@ public struct LCENavigationView<Content: View>: View {
}
}
}
.lce_applyGlassIfEnabled(state.useGlassButtons)
.background(
GeometryReader { proxy in
Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width)
}
)
}
/// Sets the configuration for the right button of the navigation bar.
@@ -156,13 +217,7 @@ public struct LCENavigationView<Content: View>: View {
}
state.rightButtonText = text
state.rightButtonAction = action
if let string = state.leftButtonText.string, string.isEmpty {
state.leftButtonText = text.foregroundColor(.clear)
}
if state.leftButtonImage == nil {
state.leftButtonImage = image?.foregroundColor(.clear) as? AnyView
}
state.hasRightButton = true
return self
}
@@ -185,14 +240,17 @@ public struct LCENavigationView<Content: View>: View {
}
state.leftButtonText = text
state.leftButtonAction = action
state.hasLeftButton = true
if let string = state.rightButtonText.string, string.isEmpty {
state.rightButtonText = text.foregroundColor(.clear)
}
if state.rightButtonImage == nil {
state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView
return self
}
/// Opts the navigation bar's buttons into the system Liquid Glass `.glass` button style on iOS 26+.
/// The navigation bar is designed as a plain, non-glass component by default; call this to enable glass explicitly.
/// - Parameter enabled: Whether buttons should use the glass style.
/// - Returns: The `LCENavigationView` instance for chaining.
public func setGlassButtonsEnabled(_ enabled: Bool) -> LCENavigationView {
state.useGlassButtons = enabled
return self
}
@@ -217,100 +275,13 @@ public struct LCENavigationView<Content: View>: View {
state.hideNavigationBar = hide
return self
}
}
/// Extension to `FormatStyle` to format any value as a string.
@available(iOS 15.0, *)
extension FormatStyle {
/// Formats an input value if it matches the `FormatInput` type.
/// - Parameter value: The value to format as `Any`.
/// - Returns: The formatted output, or `nil` if the value type does not match.
func format(any value: Any) -> FormatOutput? {
if let v = value as? FormatInput {
return format(v)
}
return nil
}
}
/// Extension to `LocalizedStringKey` to resolve localized strings.
@available(iOS 15.0, *)
extension LocalizedStringKey {
/// Resolves the localized string key into a `String`.
/// - Returns: The resolved string, or `nil` if resolution fails.
var resolved: String? {
let mirror = Mirror(reflecting: self)
guard let key = mirror.descendant("key") as? String else {
return nil
}
guard let args = mirror.descendant("arguments") as? [Any] else {
return nil
}
let values = args.map { arg -> Any? in
let mirror = Mirror(reflecting: arg)
if let value = mirror.descendant("storage", "value", ".0") {
return value
}
guard let format = mirror.descendant("storage", "formatStyleValue", "format") as? any FormatStyle,
let input = mirror.descendant("storage", "formatStyleValue", "input") else {
return nil
}
return format.format(any: input)
}
let va = values.compactMap { arg -> CVarArg? in
switch arg {
case let i as Int: return i
case let i as Int64: return i
case let i as Int8: return i
case let i as Int16: return i
case let i as Int32: return i
case let u as UInt: return u
case let u as UInt64: return u
case let u as UInt8: return u
case let u as UInt16: return u
case let u as UInt32: return u
case let f as Float: return f
case let f as CGFloat: return f
case let d as Double: return d
case let o as NSObject: return o
default: return nil
}
}
if va.count != values.count {
return nil
}
return String.localizedStringWithFormat(key, va)
}
}
/// Extension to `Text` to retrieve its string content.
@available(iOS 15.0, *)
extension Text {
/// Returns the string representation of the `Text` view.
/// - Returns: The string content, or `nil` if it cannot be extracted.
var string: String? {
let mirror = Mirror(reflecting: self)
if let s = mirror.descendant("storage", "verbatim") as? String {
return s
} else if let attrStr = mirror.descendant("storage", "anyTextStorage", "str") as? AttributedString {
return String(attrStr.characters)
} else if let key = mirror.descendant("storage", "anyTextStorage", "key") as? LocalizedStringKey {
return key.resolved
} else if let format = mirror.descendant("storage", "anyTextStorage", "storage", "format") as? any FormatStyle,
let input = mirror.descendant("storage", "anyTextStorage", "storage", "input") {
return format.format(any: input) as? String
} else if let formatter = mirror.descendant("storage", "anyTextStorage", "formatter") as? Formatter,
let object = mirror.descendant("storage", "anyTextStorage", "object") {
return formatter.string(for: object)
}
return nil
/// Sets the background color for the navigation bar.
/// - Parameter color: The color to use as the navigation bar background.
/// - Returns: The `LCENavigationView` instance for chaining.
public func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView {
state.navigationBarBackgroundColor = color
return self
}
}

View File

@@ -0,0 +1,26 @@
import XCTest
@testable import LCEssentials
final class APIActorTests: XCTestCase {
func testAPIIsAnActorNotMainActorBound() async {
let isActor = (API.shared as Any) is any Actor
XCTAssertTrue(isActor, "API must be an actor so callers are not forced onto the main thread")
}
func testPersistConnectionDelayRoundTrips() async {
let api = API(testConfiguration: .ephemeral)
await api.setPersistConnectionDelay(9)
let value = await api.persistConnectionDelay
XCTAssertEqual(value, 9)
}
func testIsolatedTestInstanceDoesNotTouchSharedCertState() async {
let api = API(testConfiguration: .ephemeral)
await api.setupCertification(certData: Data([0x01, 0x02]), password: "pw")
let sharedHasCert = await API.shared.hasClientCertificateConfigured
let instanceHasCert = await api.hasClientCertificateConfigured
XCTAssertFalse(sharedHasCert)
XCTAssertTrue(instanceHasCert)
}
}

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import LCEssentials
private struct Echo: Decodable, Sendable, Equatable {
let id: Int
let name: String
}
private struct CreateDTO: Encodable, Sendable {
let name: String
}
final class APIRequestTests: XCTestCase {
private var api: API!
override func setUp() {
super.setUp()
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
api = API(testConfiguration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
var stub = StubURLProtocol.Stub()
stub.statusCode = 200
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
StubURLProtocol.setStub(stub)
let result: Echo = try await api.request(
url: "https://api.example.com/users",
method: .post,
body: jsonBody(CreateDTO(name: "x"))
)
XCTAssertEqual(result, Echo(id: 10, name: "x"))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
}
func testCustomHeaderOverridesDefault() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/x",
method: .get,
headers: ["Accept": "application/xml"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
"application/xml")
}
func testPathParamsSubstitution() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/users/{id}/posts",
method: .get,
pathParams: ["id": "42"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
"https://api.example.com/users/42/posts")
}
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
StubURLProtocol.setStub(.init(statusCode: 200,
headers: ["Content-Type": "text/plain"],
body: Data("plain hello".utf8)))
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
XCTAssertEqual(text, "plain hello")
}
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
StubURLProtocol.setStub(.init(statusCode: 422,
body: Data(#"{"error":"invalid"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
body: jsonBody(CreateDTO(name: "")))
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 422)
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
"reason: \(error.localizedFailureReason ?? "nil")")
}
}
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x",
method: .get, persistConnection: true)
XCTFail("expected throw after retries exhausted")
} catch let error as NSError {
XCTAssertEqual(error.code, 400)
} catch {
XCTFail("unexpected error type: \(error)")
}
// retried, but did NOT loop forever
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
}
func testNoPersistConnectionDoesNotRetry() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
}

View File

@@ -0,0 +1,123 @@
import XCTest
@testable import LCEssentials
private struct UploadEcho: Decodable, Sendable, Equatable {
let ok: Bool
}
/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue).
private final class ProgressBox: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Double] = []
func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() }
var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage }
}
final class APIUploadTests: XCTestCase {
private var api: API!
override func setUp() {
super.setUp()
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
api = API(testConfiguration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func tempCountInDir(_ dir: URL) -> Int {
(try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter {
$0.hasPrefix("lce-multipart-")
}.count) ?? -1
}
func testUploadSendsMultipartBodyAndDecodesResponse() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
var form = MultipartForm()
form.field("caption", "hi")
form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png")
let expectedBody = try Data(contentsOf: form.serialize().fileURL)
let result: UploadEcho = try await api.upload(
url: "https://api.example.com/media",
form: form
)
XCTAssertEqual(result, UploadEcho(ok: true))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")?
.hasPrefix("multipart/form-data; boundary=") ?? false)
// body framing matches a fresh serialize() (boundary differs per form
// instance, so compare structure, not bytes, by re-serialising the SAME form)
XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count)
}
func testTempBodyFileRemovedAfterSuccess() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTAssertEqual(tempCountInDir(dir), before)
}
func testTempBodyFileRemovedAfterThrow() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch {
// expected
}
XCTAssertEqual(tempCountInDir(dir), before)
}
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
var form = MultipartForm()
form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin")
let progressBox = ProgressBox()
let result: UploadEcho = try await api.upload(
url: "https://api.example.com/x",
form: form,
onProgress: { progressBox.record($0) }
)
XCTAssertEqual(result, UploadEcho(ok: true))
let values = progressBox.values
XCTAssertEqual(values.last, 1.0, "final progress must be 1.0")
XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 })
XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing")
}
func testUploadServerErrorThrowsWithStatus() async {
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 413)
} catch {
XCTFail("wrong error: \(error)")
}
}
}

View File

@@ -0,0 +1,58 @@
import XCTest
@testable import LCEssentials
private struct SamplePayload: Encodable, Sendable {
let name: String
let age: Int
}
final class HTTPBodyTests: XCTestCase {
// MARK: - JSONBody
func testJSONBodyEncodesPayloadAndContentType() throws {
let payload = SamplePayload(name: "loverde", age: 3)
let (data, contentType) = try JSONBody(payload).encoded()
XCTAssertEqual(contentType, "application/json; charset=UTF-8")
let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data)
XCTAssertEqual(decoded["name"]?.value as? String, "loverde")
XCTAssertEqual(decoded["age"]?.value as? Int, 3)
}
func testJSONBodyFactory() throws {
let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded()
XCTAssertFalse(data.isEmpty)
}
// MARK: - FormURLEncodedBody
func testFormURLEncodedEncodesPairsAndPercentEscapes() throws {
let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded()
XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8")
let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init))
XCTAssertEqual(pairs, ["a=1", "b=two%20words"])
}
func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws {
let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded()
let body = String(data: data, encoding: .utf8)!
XCTAssertEqual(body, "q=a%26b%3Dc")
}
}
/// Minimal type-erased decoder for asserting JSON shape in tests.
struct AnyDecodable: Decodable {
let value: Any
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let i = try? c.decode(Int.self) { value = i }
else if let s = try? c.decode(String.self) { value = s }
else if let b = try? c.decode(Bool.self) { value = b }
else if let d = try? c.decode(Double.self) { value = d }
else { value = "" }
}
}

View File

@@ -0,0 +1,26 @@
import XCTest
@testable import LCEssentials
private struct OnlyDecodable: Decodable, Sendable, Equatable {
let id: Int
let name: String
}
final class JSONDecoderDecodeTests: XCTestCase {
func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws {
let data = Data(#"{"id":7,"name":"loverde"}"#.utf8)
let value: OnlyDecodable = try JSONDecoder.decode(data: data)
XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde"))
}
func testDecodeFromStringOverload() throws {
let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#)
XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a"))
}
func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() {
let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json")
XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable)
}
}

View File

@@ -0,0 +1,106 @@
import XCTest
@testable import LCEssentials
final class MultipartFormTests: XCTestCase {
private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) {
let result = try form.serialize()
let data = try Data(contentsOf: result.fileURL)
return (data, result.contentType, result.fileURL)
}
private func addCleanup(_ url: URL) {
addTeardownBlock { try? FileManager.default.removeItem(at: url) }
}
// MARK: - Fields
func testFieldPartHasNoContentTypeLine() throws {
var form = MultipartForm()
form.field("caption", "hello world")
let (body, contentType, url) = try readSerialized(form)
addCleanup(url)
let text = String(data: body, encoding: .utf8)!
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
XCTAssertEqual(text, [
"--\(boundary)\r\n",
"Content-Disposition: form-data; name=\"caption\"\r\n",
"\r\n",
"hello world\r\n",
"--\(boundary)--\r\n"
].joined())
}
// MARK: - In-memory data file
func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws {
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF])
var form = MultipartForm()
form.file("photo", data: png, filename: "p.png")
let (body, contentType, url) = try readSerialized(form)
addCleanup(url)
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
let text = String(data: body, encoding: .isoLatin1)!
XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n"))
XCTAssertTrue(text.contains("Content-Type: image/png\r\n"))
XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n"))
// raw bytes appear verbatim between the blank line and the trailing CRLF
let marker = Data("\r\n\r\n".utf8)
let range = body.range(of: marker)!
let afterHeader = body[range.upperBound...]
XCTAssertTrue(afterHeader.starts(with: png))
}
func testExplicitMimeOverridesGuess() throws {
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream")
let (body, _, url) = try readSerialized(form)
addCleanup(url)
XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n"))
}
// MARK: - Disk file, streamed
func testDiskFilePartIsStreamedAndContentMatches() throws {
let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) })
let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin")
try big.write(to: src)
addCleanup(src)
var form = MultipartForm()
form.field("kind", "raw")
form.file("doc", url: src)
let (body, contentType, out) = try readSerialized(form)
addCleanup(out)
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
// header uses the source filename
XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)!
.contains("filename=\"\(src.lastPathComponent)\""))
// the 2 MB payload is present verbatim
let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8)
let r = body.range(of: marker)!
let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))]
XCTAssertEqual(Data(payload), big)
XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)!
.hasSuffix("--\(boundary)--\r\n"))
}
// MARK: - MIME lookup
func testMimeTypeLookup() {
XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg")
XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf")
XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream")
}
}

View File

@@ -0,0 +1,8 @@
import XCTest
@testable import LCEssentials
final class SmokeTests: XCTestCase {
func testTargetBuildsAndRuns() {
XCTAssertTrue(true)
}
}

View File

@@ -0,0 +1,116 @@
import Foundation
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
/// configured with it, records the outgoing `URLRequest`, and replays a canned
/// response or error supplied by the test.
///
/// Register via:
/// ```
/// let cfg = URLSessionConfiguration.ephemeral
/// cfg.protocolClasses = [StubURLProtocol.self]
/// let session = URLSession(configuration: cfg)
/// ```
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
struct Stub {
var statusCode: Int = 200
var headers: [String: String] = ["Content-Type": "application/json"]
var body: Data = Data()
var error: Error?
/// Bytes reported through `URLSession`'s upload progress, in order.
var uploadProgressChunks: [Int] = []
}
// MARK: - Test-facing state (guarded)
private static let lock = NSLock()
// Access is serialised through `lock`; the unsafe opt-out is the documented
// pattern for lock-guarded mutable statics under strict concurrency.
nonisolated(unsafe) private static var _stub = Stub()
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
static func setStub(_ stub: Stub) {
lock.lock(); defer { lock.unlock() }
_stub = stub
_capturedRequests = []
_capturedBodies = []
}
static func reset() { setStub(Stub()) }
static var capturedRequests: [URLRequest] {
lock.lock(); defer { lock.unlock() }
return _capturedRequests
}
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
/// stream bodies, so this reads `httpBodyStream` when needed.
static var lastCapturedBody: Data? {
lock.lock(); defer { lock.unlock() }
return _capturedBodies.last
}
static var requestCount: Int {
lock.lock(); defer { lock.unlock() }
return _capturedRequests.count
}
private static func currentStub() -> Stub {
lock.lock(); defer { lock.unlock() }
return _stub
}
private static func record(_ request: URLRequest, body: Data) {
lock.lock(); defer { lock.unlock() }
_capturedRequests.append(request)
_capturedBodies.append(body)
}
// MARK: - URLProtocol
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let stub = Self.currentStub()
Self.record(request, body: Self.bodyData(from: request))
guard let client = client else { return }
if let error = stub.error {
client.urlProtocol(self, didFailWithError: error)
return
}
let url = request.url ?? URL(string: "https://stub.invalid")!
let response = HTTPURLResponse(url: url,
statusCode: stub.statusCode,
httpVersion: "HTTP/1.1",
headerFields: stub.headers)!
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocol(self, didLoad: stub.body)
client.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
// MARK: - Body extraction
private static func bodyData(from request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 64 * 1024
var buffer = [UInt8](repeating: 0, count: bufferSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}

View File

@@ -0,0 +1,47 @@
import XCTest
final class StubURLProtocolTests: XCTestCase {
private func makeSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
return URLSession(configuration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
super.tearDown()
}
func testReplaysCannedResponseAndCapturesRequest() async throws {
var stub = StubURLProtocol.Stub()
stub.statusCode = 201
stub.body = Data(#"{"ok":true}"#.utf8)
StubURLProtocol.setStub(stub)
var request = URLRequest(url: URL(string: "https://example.com/things")!)
request.httpMethod = "POST"
request.httpBody = Data(#"{"name":"x"}"#.utf8)
let (data, response) = try await makeSession().data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST")
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
}
func testReplaysError() async {
var stub = StubURLProtocol.Stub()
stub.error = URLError(.notConnectedToInternet)
StubURLProtocol.setStub(stub)
do {
_ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!))
XCTFail("expected throw")
} catch {
XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
}
}
}