eleven / ocho Public

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>
Dermot Daly 0dd18fb46d3decc187d399df92080e3f9f4df535 parent 499dc22
8 files changed, +70 −23
  • ios/LChat/Views/ComposerField.swift +14 −1
  • ios/LChatCore/.swiftpm/xcode/xcuserdata/dermotdaly.xcuserdatad/xcschemes/xcschememanagement.plist +14 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +7 −3
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/SchemaEngine.swift +1 −1
  • ios/LChatMac/NativeChatView.swift +1 −1
  • ios/LChatMac/RootView.swift +0 −3
  • ios/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)
}
}
}