ios+mac: tidy seven build warnings (no behaviour change) (#595)
* ios: defer composer caret write out of the SwiftUI view update
ComposerField's textViewDidChangeSelection fires synchronously when
updateUIView sets `tv.text` (a programmatic text change moves the
caret), so writing the `caret` binding there landed mid-view-update —
Xcode's purple "Modifying state during view update, this will cause
undefined behavior." Snapshot the caret and publish it next runloop,
the same DispatchQueue.main.async idiom the first-responder wiring
already uses.
textViewDidChange is deliberately left synchronous: it fires only for
user edits (a programmatic text set doesn't call it), so it never runs
inside a view update — and deferring its `text` write would open a
runloop window where tv.text and the binding disagree, letting an
unrelated re-render clobber a just-typed character.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ios: tidy simple Swift / strict-concurrency warnings
Three low-risk warning fixes, no behaviour change:
- Crypto.swift: drop the redundant `public` on Data.base64urlEncodedString()
— the enclosing extension is already `public extension Data`.
- ChatFeedModel.ProgressFetch: mark the mutable delegate state
(buffer/expected/ok) `nonisolated(unsafe)`. The URLSessionDataDelegate
protocol requires Sendable, but a session serialises its delegate
callbacks on its delegateQueue (nil → a private serial queue), so the
state is only ever touched one at a time; the compiler can't prove that.
- SchemaEngine.template: `chars` is only read, never mutated — var → let.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ios+mac: fix two concurrency/builder warnings in extension & Mac UI
- LChatShare/ShareViewController: un-nest the NSItemProvider file/data
loads in save() into two awaited steps, so the data fallback no longer
sits inside — and captures `provider` in — the file load's @Sendable
completion (NSItemProvider isn't Sendable). Behaviour preserved: data is
tried only when no file representation was offered, not when a file copy
failed.
- LChatMac/RootView: drop a stray @ViewBuilder left dangling above
chatInitials() by an earlier refactor — it returns String, so the
attribute did nothing but emit "result builder disabled by explicit
return". No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* mac: use the async UNUserNotificationCenter.add alternative
Called inside a Task.detached, so the compiler flagged the completion
-handler overload ("consider using asynchronous alternative"). Switch to
`try? await …add(request)`; the original passed no completion handler and
so ignored delivery errors — `try?` preserves that.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
8 files changed,
+70
−23
ios/LChat/Views/ComposerField.swift+14 −1ios/LChatCore/.swiftpm/xcode/xcuserdata/dermotdaly.xcuserdatad/xcschemes/xcschememanagement.plist+14 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+7 −3ios/LChatCore/Sources/LChatCore/Crypto.swift+1 −1ios/LChatCore/Sources/LChatCore/SchemaEngine.swift+1 −1ios/LChatMac/NativeChatView.swift+1 −1ios/LChatMac/RootView.swift+0 −3ios/LChatShare/ShareViewController.swift+32 −13
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| index 400a079..1cd2f51 100644 |
| --- a/ios/LChat/Views/ComposerField.swift |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -76,13 +76,26 @@ struct ComposerField: UIViewRepresentable { |
| init(_ parent: ComposerField) { self.parent = parent } |
| func textViewDidChange(_ tv: UITextView) { |
| + // Kept SYNCHRONOUS on purpose. This fires only for user-driven edits |
| + // (a programmatic `tv.text` set does NOT call it), so it never runs |
| + // inside a SwiftUI view update — no "modifying state during update". |
| + // And the text write MUST stay sync: deferring it would open a |
| + // runloop window where tv.text and the binding disagree, and an |
| + // unrelated re-render landing there (socket/presence churn) would let |
| + // updateUIView clobber the just-typed character. |
| parent.text = tv.text |
| parent.caret?.wrappedValue = tv.selectedRange.location |
| (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| } |
| func textViewDidChangeSelection(_ tv: UITextView) { |
| - parent.caret?.wrappedValue = tv.selectedRange.location |
| + // This DOES fire synchronously when updateUIView sets `tv.text` (a |
| + // programmatic text change moves the selection), so writing the caret |
| + // binding here lands mid-view-update — undefined behaviour |
| + // ("Modifying state during view update"). Defer it to the next |
| + // runloop, the same async idiom the first-responder wiring uses. |
| + let caret = tv.selectedRange.location |
| + DispatchQueue.main.async { self.parent.caret?.wrappedValue = caret } |
| } |
| func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
diff --git a/ios/LChatCore/.swiftpm/xcode/xcuserdata/dermotdaly.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/LChatCore/.swiftpm/xcode/xcuserdata/dermotdaly.xcuserdatad/xcschemes/xcschememanagement.plist| new file mode 100644 |
| index 0000000..b90ab12 |
| --- /dev/null |
| +++ b/ios/LChatCore/.swiftpm/xcode/xcuserdata/dermotdaly.xcuserdatad/xcschemes/xcschememanagement.plist |
| @@ -0,0 +1,14 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>SchemeUserState</key> |
| + <dict> |
| + <key>LChatCore.xcscheme_^#shared#^_</key> |
| + <dict> |
| + <key>orderHint</key> |
| + <integer>4</integer> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 7c62f98..6517af3 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -369,9 +369,13 @@ public final class ChatFeedModel: ObservableObject { |
| private final class ProgressFetch: NSObject, URLSessionDataDelegate { |
| private let onProgress: @MainActor (Double) -> Void |
| private let done: (Data?) -> Void |
| - private var buffer = Data() |
| - private var expected: Int64 = 0 |
| - private var ok = false |
| + // A URLSession serialises its delegate callbacks on its delegateQueue |
| + // (nil → a private serial queue), so these are only ever touched one at |
| + // a time — despite the delegate protocol requiring Sendable. The |
| + // compiler can't prove that serialisation, hence nonisolated(unsafe). |
| + private nonisolated(unsafe) var buffer = Data() |
| + private nonisolated(unsafe) var expected: Int64 = 0 |
| + private nonisolated(unsafe) var ok = false |
| init(onProgress: @escaping @MainActor (Double) -> Void, done: @escaping (Data?) -> Void) { |
| self.onProgress = onProgress |
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift| index b069012..313ef64 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Crypto.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift |
| @@ -220,7 +220,7 @@ public extension Data { |
| self.init(base64Encoded: str) |
| } |
| - public func base64urlEncodedString() -> String { |
| + func base64urlEncodedString() -> String { |
| base64EncodedString() |
| .replacingOccurrences(of: "+", with: "-") |
| .replacingOccurrences(of: "/", with: "_") |
diff --git a/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift b/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift| index 7b542f5..ea9c23b 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift |
| @@ -232,7 +232,7 @@ public enum SchemaEngine { |
| /// Interpolates "{path}" / "{path|filter}" against the scope; "{{" escapes. |
| public static func template(_ tpl: String, _ scope: Scope) -> String { |
| var out = "" |
| - var chars = Array(tpl) |
| + let chars = Array(tpl) |
| var i = 0 |
| while i < chars.count { |
| if chars[i] == "{" { |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 7c1c2f4..2029f69 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1325,7 +1325,7 @@ struct NativeChatView: View { |
| messageText: previewText(kind: "dm", payload: p)) { |
| deliver = styled |
| } |
| - UNUserNotificationCenter.current().add( |
| + try? await UNUserNotificationCenter.current().add( |
| UNNotificationRequest(identifier: UUID().uuidString, content: deliver, trigger: nil)) |
| } |
| } |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index 38da5f1..a15f54a 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -530,9 +530,6 @@ struct CommandPalette: View { |
| } |
| } |
| - @ViewBuilder |
| - |
| - |
| // Chat-name initials, mirroring MacAppState.initials(): one letter for a |
| // single-word name, first letters of the first two words otherwise. |
| private func chatInitials(_ name: String) -> String { |
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift| index 407bd19..3ce15b1 100644 |
| --- a/ios/LChatShare/ShareViewController.swift |
| +++ b/ios/LChatShare/ShareViewController.swift |
| @@ -58,22 +58,41 @@ final class ShareViewController: UIViewController { |
| return saved |
| } |
| + // Prefer a file representation (keeps the original filename); fall back to |
| + // raw data only when NO file was offered — a file that WAS offered but |
| + // couldn't be copied is a hard failure, not a data retry (original |
| + // behaviour). The two loads are separate awaited steps rather than nested, |
| + // so the fallback never sits inside — and captures `provider` in — the |
| + // first load's @Sendable completion closure (NSItemProvider isn't Sendable). |
| private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? { |
| + switch await loadFile(provider, as: type, into: inbox) { |
| + case .saved(let dest): return dest |
| + case .failed: return nil |
| + case .absent: return await loadData(provider, as: type, into: inbox) |
| + } |
| + } |
| + |
| + private enum FileLoad { case saved(URL); case failed; case absent } |
| + |
| + private func loadFile(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> FileLoad { |
| await withCheckedContinuation { cont in |
| provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, _ in |
| - if let url { |
| - let dest = inbox.appendingPathComponent( |
| - UUID().uuidString + "-" + url.lastPathComponent) |
| - cont.resume(returning: |
| - (try? FileManager.default.copyItem(at: url, to: dest)) != nil ? dest : nil) |
| - return |
| - } |
| - provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in |
| - guard let data else { return cont.resume(returning: nil) } |
| - let ext = type == .movie ? "mp4" : "jpg" |
| - let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext) |
| - cont.resume(returning: (try? data.write(to: dest)) != nil ? dest : nil) |
| - } |
| + guard let url else { return cont.resume(returning: .absent) } |
| + let dest = inbox.appendingPathComponent( |
| + UUID().uuidString + "-" + url.lastPathComponent) |
| + cont.resume(returning: |
| + (try? FileManager.default.copyItem(at: url, to: dest)) != nil ? .saved(dest) : .failed) |
| + } |
| + } |
| + } |
| + |
| + private func loadData(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? { |
| + let ext = type == .movie ? "mp4" : "jpg" |
| + return await withCheckedContinuation { cont in |
| + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in |
| + guard let data else { return cont.resume(returning: nil) } |
| + let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext) |
| + cont.resume(returning: (try? data.write(to: dest)) != nil ? dest : nil) |
| } |
| } |
| } |