Mac Catalyst: persist saved chats, fix composer Return/focus, fix bubble truncation (#12)
* ios: swipe-right to reply on mobile message rows Mobile (touch) had no swipe affordance for replying — only the long-press context menu — while pointer devices get the hover button. Add a WhatsApp/Messages-style swipe-right-to-reply on the message row, mirroring the web (attachSwipeReply in chat.js): 56pt trigger, 80pt cap, a reply arrow that fades in on the leading edge, and a medium haptic on fire. Gated out of the Mac Catalyst build with #if !targetEnvironment(macCatalyst). Uses .simultaneousGesture plus a horizontal-dominance lock so it coexists with the feed's vertical scroll and the left-edge back-swipe rather than fighting them. * ios: fix Mac Catalyst keychain persistence + composer Return/focus Saved chats never survived a relaunch on the Mac. The SecItem queries omitted kSecUseDataProtectionKeychain, so Catalyst used the legacy file-based macOS keychain instead of the iOS-style data-protection one; in the sandbox those writes fail silently (the SecItemAdd status was ignored), so the saved-chats list came back empty every launch. Set the flag on all three queries (load/save/clear) — a no-op on iOS, the fix on Catalyst. Also fix the Mac composer: a bare Return now sends on the first press. The send button's .keyboardShortcut(.return) was swallowed by the focused multiline TextField (hence the "hit Enter twice" behaviour); intercept the key on the field itself with .onKeyPress instead, with Shift+Return still inserting a newline and touch keyboards keeping Return-as-newline. The composer keeps focus after each send (re-asserted on the next runloop tick so a send-button tap doesn't drop it) and auto-focuses on entry on the Mac. Co-Authored-By: Claude <noreply@anthropic.com> * docs: document headless iOS/macOS builds with predictable output paths Add a native iOS & macOS section to the root README and flesh out the ios/ build notes: build both variants from the command line without launching Xcode, with -derivedDataPath build so products land in-repo at a known path instead of a hashed DerivedData folder, plus a gitignored LChat-*.app symlink to launch the deep Mac path. Also correct the stale "no Enter-to-send on Mac" note now that Return sends. Co-Authored-By: Claude <noreply@anthropic.com> ---------
Co-authored-by: Claude <noreply@anthropic.com>
5 files changed,
+127
−11
README.md+47 −0ios/.gitignore+2 −0ios/LChat/Keychain.swift+9 −0ios/LChat/Views/ChatView.swift+24 −3ios/README.md+45 −8
diff --git a/README.md b/README.md| index 2d027d4..e3d7761 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -59,6 +59,53 @@ Copy `lchat` to your server and run it behind a TLS-terminating reverse proxy |
| (Caddy/nginx). **HTTPS is required in production** — the invite token travels to |
| the server, and the fragment-key model assumes the transport is encrypted. |
| +## Native iOS & macOS app |
| + |
| +There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on |
| +iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same |
| +end-to-end encryption contract). The `.xcodeproj` is generated by |
| +[`xcodegen`](https://github.com/yonsm/XcodeGen), not committed. |
| + |
| +You can build both variants from the command line **without launching Xcode** |
| +(handy for CI or a quick compile check) — `CODE_SIGNING_ALLOWED=NO` skips the |
| +need for a signing team: |
| + |
| +```sh |
| +cd ios |
| +xcodegen generate # writes LChat.xcodeproj from project.yml |
| + |
| +# iOS (compiles against the Simulator SDK — no device or signing needed): |
| +xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| + -destination 'generic/platform=iOS Simulator' \ |
| + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| + |
| +# macOS (Mac Catalyst — a native build that doesn't touch the iOS Simulator): |
| +xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| + -destination 'platform=macOS,variant=Mac Catalyst' \ |
| + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| +``` |
| + |
| +`-derivedDataPath build` keeps the products inside the repo (under the |
| +gitignored `ios/build/`) instead of a hashed folder buried in |
| +`~/Library/Developer/Xcode/DerivedData/`. The built apps land at: |
| + |
| +- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app` |
| +- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app |
| + |
| +That Mac path is still a mouthful, so symlink it somewhere handy and launch it |
| +(the `LChat-*.app` symlink names are gitignored): |
| + |
| +```sh |
| +ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app |
| +open LChat-mac.app # runs the Mac (Catalyst) build |
| +``` |
| + |
| +A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination |
| +can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly |
| +instead: `-sdk iphonesimulator` in place of the `-destination` flag. See |
| +[`ios/README.md`](ios/README.md) for signing, running on a device, and how the |
| +client maps to the server. |
| + |
| ## Deploy to Miren |
| [Miren](https://miren.dev) builds the container, runs it, and handles routing + |
diff --git a/ios/.gitignore b/ios/.gitignore| index bf19373..ec8c35f 100644 |
| --- a/ios/.gitignore |
| +++ b/ios/.gitignore |
| @@ -2,5 +2,7 @@ |
| LChat.xcodeproj/ |
| build/ |
| DerivedData/ |
| +# Convenience symlinks into build/ from the README's headless-build recipe. |
| +LChat-*.app |
| *.xcuserstate |
| .DS_Store |
diff --git a/ios/LChat/Keychain.swift b/ios/LChat/Keychain.swift| index 81407af..c310b2e 100644 |
| --- a/ios/LChat/Keychain.swift |
| +++ b/ios/LChat/Keychain.swift |
| @@ -4,6 +4,12 @@ import Security |
| // Keychain-backed JSON storage. The saved chats live here (not UserDefaults) |
| // because their invite URLs carry the group key — the "key is load-bearing" rule |
| // from CLAUDE.md. |
| +// |
| +// Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same |
| +// iOS-style data-protection keychain instead of the legacy file-based macOS |
| +// keychain. Without it the sandboxed Mac app's writes fail silently and nothing |
| +// persists — the saved-chats list comes back empty on every launch. (On iOS the |
| +// flag is a no-op; the data-protection keychain is already the only one.) |
| enum Keychain { |
| private static let service = "com.lchat.client" |
| @@ -12,6 +18,7 @@ enum Keychain { |
| kSecClass as String: kSecClassGenericPassword, |
| kSecAttrService as String: service, |
| kSecAttrAccount as String: account, |
| + kSecUseDataProtectionKeychain as String: true, |
| kSecReturnData as String: true, |
| kSecMatchLimit as String: kSecMatchLimitOne, |
| ] |
| @@ -29,6 +36,7 @@ enum Keychain { |
| kSecClass as String: kSecClassGenericPassword, |
| kSecAttrService as String: service, |
| kSecAttrAccount as String: account, |
| + kSecUseDataProtectionKeychain as String: true, |
| ] |
| SecItemDelete(base as CFDictionary) |
| var add = base |
| @@ -42,6 +50,7 @@ enum Keychain { |
| kSecClass as String: kSecClassGenericPassword, |
| kSecAttrService as String: service, |
| kSecAttrAccount as String: account, |
| + kSecUseDataProtectionKeychain as String: true, |
| ] as CFDictionary) |
| } |
| } |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 4571b5b..e4952ee 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -21,6 +21,7 @@ struct ChatView: View { |
| @State private var showLeave = false |
| @State private var flashID: Int64? |
| @State private var dropTargeted = false |
| + @FocusState private var composerFocused: Bool |
| let chat: SavedChat |
| @@ -48,6 +49,11 @@ struct ChatView: View { |
| feed |
| composer |
| } |
| + // On the Mac there's no soft keyboard to worry about, so focus the |
| + // composer on entry — the Return-to-send shortcut then works without |
| + // first clicking into the field. On iOS we leave it unfocused so the |
| + // keyboard doesn't immediately cover the feed. |
| + .onAppear { if isMac { composerFocused = true } } |
| // On the Mac the name shows in the native titlebar, so don't repeat it |
| // in the nav bar (which carries just the back chevron + menu there). |
| .navigationTitle(isMac ? "" : store.groupName) |
| @@ -170,10 +176,23 @@ struct ChatView: View { |
| TextField("Message", text: $text, axis: .vertical) |
| .textFieldStyle(.plain) |
| .lineLimit(1...6) |
| + .focused($composerFocused) |
| .padding(.horizontal, 12) |
| .padding(.vertical, 7) |
| .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18)) |
| .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color(.separator).opacity(0.5), lineWidth: 0.5)) |
| + // Desktop convention (matches the web composer): a bare Return |
| + // sends, Shift+Return inserts a newline. We intercept the key on |
| + // the field itself rather than via a Button keyboardShortcut — |
| + // a focused multiline field swallows the Button's shortcut, which |
| + // is what made Return need a second press. Touch keyboards keep |
| + // Return as a newline (the send button sends there), so only the |
| + // Mac opts in. |
| + .onKeyPress(.return, phases: .down) { press in |
| + guard isMac, press.modifiers.isEmpty else { return .ignored } |
| + if canSend { send() } |
| + return .handled |
| + } |
| Button(action: send) { |
| Image(systemName: "arrow.up.circle.fill") |
| @@ -182,9 +201,6 @@ struct ChatView: View { |
| } |
| .buttonStyle(.plain) |
| .disabled(!canSend) |
| - // Hardware keyboard (Mac/iPad): Return sends. Shift+Return isn't |
| - // matched, so the multiline field inserts a newline instead. |
| - .keyboardShortcut(.return, modifiers: []) |
| } |
| } |
| .padding(.horizontal, 10) |
| @@ -295,6 +311,11 @@ struct ChatView: View { |
| pendingPreview = nil |
| pendingName = nil |
| replyingTo = nil |
| + // Keep the composer focused so you can fire off several messages in a row |
| + // without clicking back into the field after each send. Tapping the send |
| + // button resigns the field's first responder, so re-assert focus on the |
| + // next runloop tick — setting it inline here gets clobbered by the tap. |
| + DispatchQueue.main.async { composerFocused = true } |
| } |
| // Shared staging: show a preview, encrypt + upload, then arm the attachment. |
diff --git a/ios/README.md b/ios/README.md| index c1be0bc..05cd918 100644 |
| --- a/ios/README.md |
| +++ b/ios/README.md |
| @@ -37,6 +37,46 @@ Pick the destination in Xcode's run-destination menu: |
| - **iPhone/iPad** — a Simulator or a connected device. |
| - **Mac** — choose "My Mac (Mac Catalyst)". |
| +### Build without launching Xcode |
| + |
| +Both variants build straight from the command line — useful for CI or a quick |
| +compile check. `CODE_SIGNING_ALLOWED=NO` skips the need for a signing team; a |
| +successful build ends in `** BUILD SUCCEEDED **`. |
| + |
| +```sh |
| +cd ios |
| +xcodegen generate # if you haven't already |
| + |
| +# iOS — compiles against the Simulator SDK (no device or signing needed): |
| +xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| + -destination 'generic/platform=iOS Simulator' \ |
| + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| + |
| +# macOS (Mac Catalyst) — a native build that doesn't touch the iOS Simulator: |
| +xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| + -destination 'platform=macOS,variant=Mac Catalyst' \ |
| + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| +``` |
| + |
| +`-derivedDataPath build` puts the products under the gitignored `ios/build/` |
| +instead of a hashed folder in `~/Library/Developer/Xcode/DerivedData/`: |
| + |
| +- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app` |
| +- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` (runnable Mac app) |
| + |
| +Symlink the deep Mac path somewhere handy to launch it (the `LChat-*.app` |
| +symlink names are gitignored): |
| + |
| +```sh |
| +ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app |
| +open LChat-mac.app |
| +``` |
| + |
| +If a Simulator destination can't be resolved (e.g. an out-of-date |
| +CoreSimulator), target the SDK directly instead — swap the `-destination` flag |
| +for `-sdk iphonesimulator`. The Mac Catalyst build is the most reliable headless |
| +gate since it never touches the Simulator service. |
| + |
| To run on a physical iOS device or on the Mac, set your signing team in Xcode |
| (Signing & Capabilities) — the bundle id is `com.lchat.client`. |
| @@ -48,15 +88,12 @@ Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants |
| the server at all** (REST or WebSocket). The Keychain, photo picker, and share |
| sheet all work under the sandbox as-is. |
| -Headless build check for the Mac variant (doesn't need the iOS Simulator): |
| - |
| -```sh |
| -xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| - -destination 'platform=macOS,variant=Mac Catalyst' build |
| -``` |
| +The Mac variant is also the most reliable headless build gate — see |
| +[Build without launching Xcode](#build-without-launching-xcode) above. |
| -One UX difference to be aware of: the composer sends via the send button (there's |
| -no Enter-to-send shortcut yet on Mac); Return inserts a newline. |
| +Composer keys on the Mac: **Return sends** (matching the web app on desktop) and |
| +**Shift+Return inserts a newline**. The field keeps focus after each send, so you |
| +can fire off several messages without clicking back in. |
| ### Testing against a local server |