diff --git a/docs/MODEL.md b/docs/MODEL.md index e44f886e7..d2b1641b9 100644 --- a/docs/MODEL.md +++ b/docs/MODEL.md @@ -106,6 +106,7 @@ Every change is checked against this table. | A person's session archive | its conversations' files, `sessions/.json`, in a fragment of theirs of kind `sessions` (templates/sessions/README.md) | its indexes (`index/.json`), a projection rewritten by each import; its search, a projection of `sessions/` each import writes (a source a file, its revision the file's sha256) and reconciles against the indexes; the store an import read is never written | | A fragment's preview card | its own storage (`fragment_core::card::Cards`) | status's `page`; errors also an `events` row | | What members are doing: each one's newest listed record | the channel's records | the fragment's `listed`, and each member's list row (docs/api.md, Listed records) | +| Native iPhone drafts and pending sends | protected, backup-excluded Application Support files, scoped to platform and person (`ios/Sources/ChatDraftStore.swift`) | bounded to 32 saved chats, 16 pending sends each; a send is saved before HTTP, retried only with the same id and body, never once refused; editing, saving and loading hold one set of bounds (`SavedChat`), and no saved file stops a chat; device keys live only in Keychain | | A person's own view of their fragments (archived, docked, read up to) | their list, the `Principal` cell | none | | A person's export | their list, the `Principal` cell, and its archive in R2 | the archive is a copy as read, never updated, deleted after seven days (docs/api.md, Export) | diff --git a/docs/ios.md b/docs/ios.md new file mode 100644 index 000000000..32c578b7a --- /dev/null +++ b/docs/ios.md @@ -0,0 +1,196 @@ +# Native iOS client and pairing + +Status: **native CLI-key pairing and scoped web sessions implemented; hosted rollout and TestFlight remain**. +The client is in `ios/`, SwiftUI on iOS 26+, with URLSession HTTP/WebSockets. +The app shares wire shapes with Fragment, not its implementation, and talks to +a real platform only: a deployment, or `cargo xtask dev` in development. + +## Problem and acceptance + +Make Finite.Computer feel at home on iPhone: open your agent, resume a chat, send a +message, see its reply stream, and open an ordinary app without leaving the +conversation. Follow the later, simpler Finite iOS design and the existing +Fragment mobile colours. The signed-in home is a native fullscreen version +of Fragment's Command-K launcher (Paul, 2026-10-14): one input searches chats, +apps and message text; the first action and Return start a new chat with the +complete input. Search hints alone are capped at eight words and 256 bytes; +older search responses never replace newer results. Empty input stays quiet. +Paul, 2026-11-04: use web organization with native iOS presentation. Open on +its search launcher over the web desktop wallpaper. A left sidebar groups chats +by agent (multiple agents supported), with History, Your Apps and Settings. Chat has +sidebar left, title, search/new chat and Your Apps right; chat actions live in the +agent hero menus in the sidebar and chat-row context menus, with no chat ellipsis; Your Apps uses a native list over the shared photo background +and Add an app opens the built-in prompt sheet, without a separate suggestion empty state. +Your Apps has Manage Apps at top right, with all apps (including hidden and archived) +searchable in a native sheet. Per-app settings mirror web sharing and permissions, +personal show/hide/archive, naming, icons and preview generation. +Use system typography and system glass controls, a single glass composer with +aligned 44-point controls and no recipient caption, and actual fragment icons +in Your Apps. The installed hand icon is a layered Icon Composer asset; product copy is Finite.Computer, +Finite for short, and messages name their actual agent. App viewers are native large sheets with a visible drag handle and swipe-down +dismissal, without a Done button; chat state is retained. Native history, settings categories, attachments, voice +memos, reply/copy/share and group addressing follow the existing wire contracts. +No Rust FFI, embedded chat webpage, model key or parallel chat protocol. + +Acceptance: real HTTP +and WebSockets, a working composer above the keyboard, whole-text drafts +replaced once by committed replies, Stop, retry with the original id, +reconnect/catch-up without duplicates or regressing either channel's cursor, +earlier history independent of live catch-up, selectable native Markdown, +light/dark, and ordinary fragments in dismissible WKWebView sheets. Build +with Swift 6 strict concurrency and warnings as errors. See `ios/README.md` +for commands and evaluation. Unit tests on a stub of the platform's answers +lose a committed post's acknowledgement, refuse a post and drop a stream to +test recovery. + +## Existing contract; what is missing + +The live service uses these routes already (`docs/api.md` is authoritative): + +| Need | Existing API | +| --- | --- | +| Person, owned agent | `GET /api/identities/me`, `GET /api/fragments`, agent's `GET /api/f/{name}/status` for its npub | +| New chat | `POST /api/fragments {label, template:"chat", title}` then `PUT /api/f/{chat}/members/{agent npub} {role:"editor"}`; keep a successful create across a failed join | +| Messages, Stop, prompt answer | `POST /api/f/{chat}/channels/chat {id, body}`, as `docs/chat-records.md` defines | +| Stream/catch-up | `GET /f/{chat}/__live`, subscribe to `chat` and `work` with `last` initially, `after` on reconnect; follow `subscribed.more` until caught up | +| Earlier messages | `GET /api/f/{chat}/channels/chat?before=&limit=50`; never advance live cursors from this page | +| List invalidation | `GET /api/fragments/watch`; refetch the authenticated list on `changed` | +| Launcher search | `GET /api/search?q=` → fragments by title/label and messages by text, archived included; a message result opens its conversation | +| Read marker | `PUT /api/fragments/{chat}/seen`, after the visible chat catches up | +| App URL | `GET /api/f/{name}/status` → `urls.canonical` | +| App management | Personal `PUT /api/fragments/{name}/dock` and `/archived`; app `/status`, `/visibility`, `/members`, `/invites`, `/rotate`, `/icon`, `/icon/make`, and `/card` use the existing web contracts | + +## Connect an account + +The native client reuses `fragment login`; no second WorkOS integration or device +token protocol. The phone generates a fresh secp256k1 key, saves it to Keychain, +and signs `POST /cli/begin`. The person types the returned code on the platform’s +`/cli` page, signs in through the existing WorkOS flow if needed, checks the request, +and approves. The phone polls signed `GET /api/identities/me` every two seconds +while foregrounded, up to the code’s ten-minute expiry. That existing person and +email become the connected account. An npub is an identifier; possession of the +approved private key authenticates requests. The CLI’s private key is never copied. + +`DeviceIdentity.swift` uses P256K 0.21.1 (Bitcoin Core’s libsecp256k1) for BIP-340. +NIP-98 binds the actual HTTP URL, method, body hash, and clock. A WebSocket’s tag +names the HTTP upgrade URL, not `wss:`. Every HTTP path (history, attachments, +icons and previews included) and live socket signs through `FragmentAPI`. +Credentials go only to the selected platform origin. Signed sessions are ephemeral, +hold no browser cookies, and refuse redirects. Release accepts HTTPS origins; +Debug also permits loopback and local development hostnames over HTTP. + +Keychain stores the origin, private key and approved account together, using +`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, without synchronization. No key in +UserDefaults, logs, URLs, a fragment page, or its JavaScript. A 401 is checked +against the identity endpoint and returns the app to connection when authorization +has ended. Disconnect revokes this phone’s key through the existing key API and +removes its local account data; it leaves other devices and browser sessions alone. +A failed/offline revocation is shown as an error, not reported as a successful logout. +The separate Remove from this iPhone action clears local credentials and drafts +after confirmation, explicitly leaving server revocation for the website. This +also lets a person leave an unreachable server without getting stuck. + +## What an agent's runtime offers + +Each runtime offers what it supports, and we don't build parity across +runtimes (AGENTS.md). The app offers nothing per agent beyond its chats today +(no Profile, Screen or agent skills), which is right for every runtime, so it +reads no runtime. Should it show one of those, the platform would report what +each agent offers, an add-only `offers` on the agent's status, rather than the +app copying the shell's table (`cell/shell/agent-runtimes.js`). Renaming an +agent rewrites its job (SOUL.md) as well as its title, a rule the shell and the +CLI hold, so the app leaves it to the web until one route does both. + +## Apps and brains in web sheets + +A signed `POST /api/f/{name}/session` reuses the registry’s existing single-use +redemption and top-level fragment session. It returns a one-fragment `__signin` +URL, good for 60 seconds, that the sheet’s nonpersistent WKWebView opens. Only +that fragment’s cookie enters the webview. Native authorization headers and private +keys never do. A stranger’s app asks for consent before learning the person’s +identity; owned/shared apps use their existing membership. General access and +membership permissions still belong to each fragment. + +Each approved native key holds one internal parent session, expiring after 30 days, +which cannot authenticate as a browser’s platform session. The registry checks the +bound key when redeeming and on every derived session lookup; revoking the key +invalidates existing web cookies and pending redemptions. Existing per-parent +redemption and per-fragment session bounds apply. Expired parents are replaced on +the next authenticated mint. The key itself follows normal CLI-key revocation, +not a separate mobile expiration rule. Schema step: `0009_key_sessions.sql`. + +The viewer follows same-origin navigation and sends unrelated clicked web links +to the system browser. It validates the redemption against the fragment’s canonical +origin, reports main-frame HTTP errors, and supports swipe dismissal. Add is a +native prompt sheet, matching the web shell’s built-in Add hand; there is no real +server fragment named `add-app`. + +## Local data and reliability + +Real-account drafts and pending sends are protected, atomic files in Application +Support, scoped by platform and person and excluded from backup. Bounds: 32 saved +chats, one MiB per file, 16 pending sends per chat, eight attachments per message. +The complete post and its original id are saved before HTTP. After termination, +ambiguous sends appear for explicit retry with that same id/body; they are never +silently resent with a new id. The channel takes the same id and body again as +the same post (`docs/chat-records.md`). A refusal (4xx, but 408 and 429) is final: +it is never retried; its words return to an empty composer at once, or, beside a +draft, wait in the chat to be edited once the composer is empty, with the server's +message (a draft and a post joined might be more than a message holds). Editing, +saving and loading keep one set of bounds (`SavedChat`): a paste past 32 KiB is +cut at a character, and said so. A saved file past its bounds loads what fits and +says what was cut; one that is no saved chat is cleared and says so. A chat +always opens and sends. Draft edits are debounced and flushed on leaving a chat or +backgrounding. Empty drafts are removed. + +The app keeps no copy of a limit the server enforces: it shows the server's +refusal. Its own bounds are its storage's (above) and what it sends over a +phone's network: an attachment of at most 25 MiB, and an icon fitted inside +256×256 on the phone, as the server would fit it. + +JSON text subscriptions match the Worker’s live protocol. A chat reads its last +50 messages and a page of `work` (1000, `CHANNEL_PAGE`). Which turns run follows +the web's chat page (`templates/chat/site/chat.js`): a turn is its agent's and its +id, a record counts only toward its own poster's turn, and a turn runs once its +start is read and until its end is, in either order; this is kept apart from the +shown records. Earlier work is read back as the web reads it, to the oldest message +shown, and also while a turn was read without its start, in batches of 50 (each +answer within the app's 4 MiB) and at most 1000 records a visit, so Stop shows for +a long turn however the chat was opened. An archive's chats index is +optional: an archive of other kinds alone has none, and a failed read never +stops the list or its agents. An export is followed while it builds (every two +seconds, as the web's Settings), downloads with the phone's signature to a file of +its own, is shared from there, and can be made again beside it. Its reads and makes +run one after another, so an older read never lands after a newer export; the file +is kept with its export, deleted once the export shown is another, and a download +still in flight for one no longer shown is never kept. Connected accounts follow the signed list watcher, discard +stale list reloads, and mark visible, caught-up chats read. HTTP/WebSocket +interruption recovery, independent cursors, Stop and history remain unchanged. Push delivery, approval-card controls, imported-chat continuation, and +full Markdown tables are not implemented. Notification permission alone does not +deliver background replies. + +## Validation and rollout + +`cargo xtask check` checks guards, schema lock, host tests and host/wasm Clippy. +`cargo xtask e2e --only signin,identities,native,restart` covers native-key web redemption, +unknown/revoked/agent keys, unsafe returns, one-use and wrong-fragment refusal, +consent, revocation isolation, and session survival across graceful restart/crash. +The existing sign-in lanes also exercise actual WorkOS/CLI browser approval. +Native tests cover NIP-98 URL/method/body/time binding and Schnorr verification, +credential origin boundaries, Keychain persistence/deletion, account-scoped drafts, +original-id outbox recovery, and the existing transport and UI flows. + +For local commands, see `ios/README.md`. The app always opens on Connect until a +key is approved. Debug accepts loopback and `.localhost` HTTP origins, and +`--pair-server ` fills in Connect for `cargo xtask dev`. A UI test's +launch (`--ui-test`, Debug only) keeps its account and drafts in a store of its +own, emptied as it starts, and refuses any origin past loopback before a request +(`LaunchScope`): a test never reads a person's account or reaches a deployment. + +Hosted use needs the server changes deployed for private app/brain web sheets; +existing signed native API calls reuse the already deployed CLI authentication. +Deploy to preview, approve a phone key there, and verify an existing chat, stream, +Stop, private app/brain, membership removal and key revocation before production. +Run the same flows on a physical iPhone before TestFlight. Signing/team identifiers, +privacy disclosures and App Store submission remain release work. No deployment, +production account enrollment, or paid model call is authorized by this document. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 000000000..078b7fb95 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,2 @@ +xcuserdata/ +*.xcuserstate diff --git a/ios/Fragment.xcodeproj/project.pbxproj b/ios/Fragment.xcodeproj/project.pbxproj new file mode 100644 index 000000000..e8f2220b4 --- /dev/null +++ b/ios/Fragment.xcodeproj/project.pbxproj @@ -0,0 +1,548 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 08013A491653D5236024FE71 /* AppAppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A48915AD6415232B1F460 /* AppAppearanceView.swift */; }; + 0891E14C0D3DD0876D0C138F /* ChatSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB5B600A80E7BE0157A6E77 /* ChatSession.swift */; }; + 1FA3D11FABC00DC40F41A041 /* FragmentWebSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014FC5154C974ED4F457D381 /* FragmentWebSheet.swift */; }; + 2245C93502274850A29D08C3 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99E857900FD0F65CD6B108 /* ChatView.swift */; }; + 2A5C821580EECE3D7D8C7F3D /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C3D2F32BD4FA5AB5CDF41 /* MarkdownText.swift */; }; + 2D65CF61D8311C8A8A6B3271 /* AccountConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFC16F9330AE76994AAFAFC /* AccountConnection.swift */; }; + 4024A3397E46CADB1EC9D344 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48A78B0A3D4F8A90EEBD8DB /* Models.swift */; }; + 48CB6E8D2ED7B6FD4CFC7BF1 /* AppSettingsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C665A34A78C1B7DD934F0A8F /* AppSettingsAPI.swift */; }; + 4FEE2A2E44CD058FD4666F85 /* ShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C581195A0FDAC2EA88D081F /* ShellTests.swift */; }; + 5241090BFD2229E2945804AA /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 9E2A4999E690DD7D41154B0B /* P256K */; }; + 55978B58D06B7028C7799CB2 /* Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2E702AC579841626A1A67C /* Stub.swift */; }; + 5938CA76ED5569461BA445AB /* AppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A44398C694D0FA2138E5A64 /* AppSettingsTests.swift */; }; + 667D1E87E74EA4792B9BDDA8 /* AppCreationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5117BADF017C3007F84F89 /* AppCreationSheet.swift */; }; + 73DEE42439F9219AE3FE0A67 /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A419CCC396DDBF76202E0D42 /* AppSettingsView.swift */; }; + 73EE483533F3A6306B65148C /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261EC9048255D1B31FED623 /* HomeView.swift */; }; + 74B3174E28BE9F9B4968F808 /* AuthenticatedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63AC3B6E5464205E0D215570 /* AuthenticatedImage.swift */; }; + 78F77F1984EC48618CA7801A /* FragmentUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E480260C706AE0F972CA37 /* FragmentUITests.swift */; }; + 7A8D5455AD2EABF343F2905B /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F75C83DE72E8D3B8797E1 /* SearchTests.swift */; }; + 8356DCE8D6F00248A2D4A6A9 /* ShellViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E90511DA6D6F765EAF42F51 /* ShellViews.swift */; }; + 9B04659A7CA2A9C6E5F0276A /* FragmentApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5376FE0C2DCB1863EE49C7 /* FragmentApp.swift */; }; + 9B71537B7D2B70A697A383A2 /* TransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1E859F65B5B4D304FF0D04 /* TransportTests.swift */; }; + 9CE04EDF8132C39D18DF1A9D /* ChatMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A9F828339CA70751D48F66 /* ChatMedia.swift */; }; + 9E3CA1CA0F64F5776602A767 /* ThirdPartyNotices.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6126FEDF8DC438F0E3C2A5EC /* ThirdPartyNotices.txt */; }; + A0F935E5B3AB7E00AF2297A0 /* MarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0814CDF8824F8A8657129D /* MarkdownTests.swift */; }; + A6AE254FEF924590C893CC8E /* ChatDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F151AE36F242D72835ED4D35 /* ChatDraftStore.swift */; }; + A8391B829C662A475899B98C /* TimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C75B4172B10109A56753F7A /* TimelineTests.swift */; }; + A840242666B7A68F3A3EE5CA /* DeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF4D6F0F154CDADA715DD37 /* DeviceIdentity.swift */; }; + A8A038F320ECBCC025021D8B /* DeviceIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F50038387E161FFC74D67A /* DeviceIdentityTests.swift */; }; + B00F01210DFD198F6F1180C9 /* FragmentAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7C65701872682E560E008F /* FragmentAPI.swift */; }; + B0CC33D54ED91967CDED0F0D /* AppSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCEF88547BE59E217E5B219 /* AppSharingView.swift */; }; + C26F2704F9C3B89DD4186C3B /* LaunchScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01EF9BE87256547DD6FFAAE4 /* LaunchScopeTests.swift */; }; + CC7761F962CB619DD2A5BB51 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 934BF3B3E3B9A45CF6843505 /* Assets.xcassets */; }; + E4358E209B49F024F52BDCAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 811C73B367475E8B5D4DB77F /* AppIcon.icon */; }; + EBB66B878C4362F14779FB52 /* LaunchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95702BF0C241F4A98C5F812 /* LaunchScope.swift */; }; + ED598966FDB4DD6D3F4486A1 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B6FB1CA5638E3B92C0E2CD /* SettingsView.swift */; }; + F8EBB41899022F0D4E70C646 /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B11860501F69BE73ECB3D0 /* DraftTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A00A083525D87C2EA533CDDC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 03B62A6F44730F69CF29B303 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A7A7E910E323B68F306752CC; + remoteInfo = Fragment; + }; + F1A7D59A573F9AE091A5072B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 03B62A6F44730F69CF29B303 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A7A7E910E323B68F306752CC; + remoteInfo = Fragment; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 014FC5154C974ED4F457D381 /* FragmentWebSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentWebSheet.swift; sourceTree = ""; }; + 01EF9BE87256547DD6FFAAE4 /* LaunchScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchScopeTests.swift; sourceTree = ""; }; + 0A44398C694D0FA2138E5A64 /* AppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsTests.swift; sourceTree = ""; }; + 1AFC16F9330AE76994AAFAFC /* AccountConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConnection.swift; sourceTree = ""; }; + 214C3D2F32BD4FA5AB5CDF41 /* MarkdownText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownText.swift; sourceTree = ""; }; + 2C75B4172B10109A56753F7A /* TimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTests.swift; sourceTree = ""; }; + 2E90511DA6D6F765EAF42F51 /* ShellViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellViews.swift; sourceTree = ""; }; + 2F7C65701872682E560E008F /* FragmentAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentAPI.swift; sourceTree = ""; }; + 2FCEF88547BE59E217E5B219 /* AppSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharingView.swift; sourceTree = ""; }; + 3A2F75C83DE72E8D3B8797E1 /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = ""; }; + 463D517A458071F0CD419B80 /* Fragment.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Fragment.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4AE675325F3A8ED7EAE13267 /* FragmentTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FragmentTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 4FB5B600A80E7BE0157A6E77 /* ChatSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSession.swift; sourceTree = ""; }; + 58A68F1B6233D7E206EC07A8 /* FragmentUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FragmentUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6126FEDF8DC438F0E3C2A5EC /* ThirdPartyNotices.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ThirdPartyNotices.txt; sourceTree = ""; }; + 63AC3B6E5464205E0D215570 /* AuthenticatedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedImage.swift; sourceTree = ""; }; + 74E480260C706AE0F972CA37 /* FragmentUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentUITests.swift; sourceTree = ""; }; + 811C73B367475E8B5D4DB77F /* AppIcon.icon */ = {isa = PBXFileReference; path = AppIcon.icon; sourceTree = ""; }; + 8C581195A0FDAC2EA88D081F /* ShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellTests.swift; sourceTree = ""; }; + 92F50038387E161FFC74D67A /* DeviceIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentityTests.swift; sourceTree = ""; }; + 934BF3B3E3B9A45CF6843505 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 98B6FB1CA5638E3B92C0E2CD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A419CCC396DDBF76202E0D42 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = ""; }; + A95702BF0C241F4A98C5F812 /* LaunchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchScope.swift; sourceTree = ""; }; + AB0814CDF8824F8A8657129D /* MarkdownTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTests.swift; sourceTree = ""; }; + AD99E857900FD0F65CD6B108 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + B84A48915AD6415232B1F460 /* AppAppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearanceView.swift; sourceTree = ""; }; + BE5117BADF017C3007F84F89 /* AppCreationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCreationSheet.swift; sourceTree = ""; }; + C665A34A78C1B7DD934F0A8F /* AppSettingsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsAPI.swift; sourceTree = ""; }; + CDF4D6F0F154CDADA715DD37 /* DeviceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentity.swift; sourceTree = ""; }; + D261EC9048255D1B31FED623 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + D48A78B0A3D4F8A90EEBD8DB /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + D8A9F828339CA70751D48F66 /* ChatMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMedia.swift; sourceTree = ""; }; + D8B11860501F69BE73ECB3D0 /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = ""; }; + DC2E702AC579841626A1A67C /* Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stub.swift; sourceTree = ""; }; + DE1E859F65B5B4D304FF0D04 /* TransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportTests.swift; sourceTree = ""; }; + EE5376FE0C2DCB1863EE49C7 /* FragmentApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentApp.swift; sourceTree = ""; }; + F151AE36F242D72835ED4D35 /* ChatDraftStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDraftStore.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EDFA061F064725017EC50210 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5241090BFD2229E2945804AA /* P256K in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 24C24A92D6AB3222BA612400 /* UITests */ = { + isa = PBXGroup; + children = ( + 74E480260C706AE0F972CA37 /* FragmentUITests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + 57B3D4FF633D4590AED50E36 = { + isa = PBXGroup; + children = ( + CD982D4FA51CD976EE122B12 /* Sources */, + DE9A26D5B2874890535BB2B5 /* Tests */, + 24C24A92D6AB3222BA612400 /* UITests */, + 904A365FDF599B8D2EE5FAF7 /* Products */, + ); + sourceTree = ""; + }; + 904A365FDF599B8D2EE5FAF7 /* Products */ = { + isa = PBXGroup; + children = ( + 463D517A458071F0CD419B80 /* Fragment.app */, + 4AE675325F3A8ED7EAE13267 /* FragmentTests.xctest */, + 58A68F1B6233D7E206EC07A8 /* FragmentUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + CD982D4FA51CD976EE122B12 /* Sources */ = { + isa = PBXGroup; + children = ( + 1AFC16F9330AE76994AAFAFC /* AccountConnection.swift */, + B84A48915AD6415232B1F460 /* AppAppearanceView.swift */, + BE5117BADF017C3007F84F89 /* AppCreationSheet.swift */, + 811C73B367475E8B5D4DB77F /* AppIcon.icon */, + C665A34A78C1B7DD934F0A8F /* AppSettingsAPI.swift */, + A419CCC396DDBF76202E0D42 /* AppSettingsView.swift */, + 2FCEF88547BE59E217E5B219 /* AppSharingView.swift */, + 934BF3B3E3B9A45CF6843505 /* Assets.xcassets */, + 63AC3B6E5464205E0D215570 /* AuthenticatedImage.swift */, + F151AE36F242D72835ED4D35 /* ChatDraftStore.swift */, + D8A9F828339CA70751D48F66 /* ChatMedia.swift */, + 4FB5B600A80E7BE0157A6E77 /* ChatSession.swift */, + AD99E857900FD0F65CD6B108 /* ChatView.swift */, + CDF4D6F0F154CDADA715DD37 /* DeviceIdentity.swift */, + 2F7C65701872682E560E008F /* FragmentAPI.swift */, + EE5376FE0C2DCB1863EE49C7 /* FragmentApp.swift */, + 014FC5154C974ED4F457D381 /* FragmentWebSheet.swift */, + D261EC9048255D1B31FED623 /* HomeView.swift */, + A95702BF0C241F4A98C5F812 /* LaunchScope.swift */, + 214C3D2F32BD4FA5AB5CDF41 /* MarkdownText.swift */, + D48A78B0A3D4F8A90EEBD8DB /* Models.swift */, + 98B6FB1CA5638E3B92C0E2CD /* SettingsView.swift */, + 2E90511DA6D6F765EAF42F51 /* ShellViews.swift */, + 6126FEDF8DC438F0E3C2A5EC /* ThirdPartyNotices.txt */, + ); + path = Sources; + sourceTree = ""; + }; + DE9A26D5B2874890535BB2B5 /* Tests */ = { + isa = PBXGroup; + children = ( + 0A44398C694D0FA2138E5A64 /* AppSettingsTests.swift */, + 92F50038387E161FFC74D67A /* DeviceIdentityTests.swift */, + D8B11860501F69BE73ECB3D0 /* DraftTests.swift */, + 01EF9BE87256547DD6FFAAE4 /* LaunchScopeTests.swift */, + AB0814CDF8824F8A8657129D /* MarkdownTests.swift */, + 3A2F75C83DE72E8D3B8797E1 /* SearchTests.swift */, + 8C581195A0FDAC2EA88D081F /* ShellTests.swift */, + DC2E702AC579841626A1A67C /* Stub.swift */, + 2C75B4172B10109A56753F7A /* TimelineTests.swift */, + DE1E859F65B5B4D304FF0D04 /* TransportTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2938405DCF0BB5C112A3CCBC /* FragmentTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 06E292003E7442D1D8B9D829 /* Build configuration list for PBXNativeTarget "FragmentTests" */; + buildPhases = ( + 080E6F0D2D8AA2215A48F2BD /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 84230630DAFCC3A085FC53F3 /* PBXTargetDependency */, + ); + name = FragmentTests; + productName = FragmentTests; + productReference = 4AE675325F3A8ED7EAE13267 /* FragmentTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 8C52024F3CFAFD685787A04E /* FragmentUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD9D6F862081CA8A2E8B54E2 /* Build configuration list for PBXNativeTarget "FragmentUITests" */; + buildPhases = ( + 632EE043D767158F99D6998A /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 059CA4C243EC9281F90C4DA0 /* PBXTargetDependency */, + ); + name = FragmentUITests; + productName = FragmentUITests; + productReference = 58A68F1B6233D7E206EC07A8 /* FragmentUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + A7A7E910E323B68F306752CC /* Fragment */ = { + isa = PBXNativeTarget; + buildConfigurationList = 90E614231237E44BB37C8991 /* Build configuration list for PBXNativeTarget "Fragment" */; + buildPhases = ( + EC4D398A1EE8D1812A34893E /* Sources */, + 10FAF92375959704DAF502C2 /* Resources */, + EDFA061F064725017EC50210 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Fragment; + packageProductDependencies = ( + 9E2A4999E690DD7D41154B0B /* P256K */, + ); + productName = Fragment; + productReference = 463D517A458071F0CD419B80 /* Fragment.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 03B62A6F44730F69CF29B303 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 2938405DCF0BB5C112A3CCBC = { + ProvisioningStyle = Automatic; + }; + 8C52024F3CFAFD685787A04E = { + ProvisioningStyle = Automatic; + TestTargetID = A7A7E910E323B68F306752CC; + }; + A7A7E910E323B68F306752CC = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = CD4001B831A37D88BA3B41F6 /* Build configuration list for PBXProject "Fragment" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 57B3D4FF633D4590AED50E36; + packageReferences = ( + 1A66842FDD099C8FE13F9F7E /* XCRemoteSwiftPackageReference "swift-secp256k1" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + A7A7E910E323B68F306752CC /* Fragment */, + 2938405DCF0BB5C112A3CCBC /* FragmentTests */, + 8C52024F3CFAFD685787A04E /* FragmentUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 10FAF92375959704DAF502C2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E4358E209B49F024F52BDCAE /* AppIcon.icon in Resources */, + CC7761F962CB619DD2A5BB51 /* Assets.xcassets in Resources */, + 9E3CA1CA0F64F5776602A767 /* ThirdPartyNotices.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 080E6F0D2D8AA2215A48F2BD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5938CA76ED5569461BA445AB /* AppSettingsTests.swift in Sources */, + A8A038F320ECBCC025021D8B /* DeviceIdentityTests.swift in Sources */, + F8EBB41899022F0D4E70C646 /* DraftTests.swift in Sources */, + C26F2704F9C3B89DD4186C3B /* LaunchScopeTests.swift in Sources */, + A0F935E5B3AB7E00AF2297A0 /* MarkdownTests.swift in Sources */, + 7A8D5455AD2EABF343F2905B /* SearchTests.swift in Sources */, + 4FEE2A2E44CD058FD4666F85 /* ShellTests.swift in Sources */, + 55978B58D06B7028C7799CB2 /* Stub.swift in Sources */, + A8391B829C662A475899B98C /* TimelineTests.swift in Sources */, + 9B71537B7D2B70A697A383A2 /* TransportTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 632EE043D767158F99D6998A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 78F77F1984EC48618CA7801A /* FragmentUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EC4D398A1EE8D1812A34893E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2D65CF61D8311C8A8A6B3271 /* AccountConnection.swift in Sources */, + 08013A491653D5236024FE71 /* AppAppearanceView.swift in Sources */, + 667D1E87E74EA4792B9BDDA8 /* AppCreationSheet.swift in Sources */, + 48CB6E8D2ED7B6FD4CFC7BF1 /* AppSettingsAPI.swift in Sources */, + 73DEE42439F9219AE3FE0A67 /* AppSettingsView.swift in Sources */, + B0CC33D54ED91967CDED0F0D /* AppSharingView.swift in Sources */, + 74B3174E28BE9F9B4968F808 /* AuthenticatedImage.swift in Sources */, + A6AE254FEF924590C893CC8E /* ChatDraftStore.swift in Sources */, + 9CE04EDF8132C39D18DF1A9D /* ChatMedia.swift in Sources */, + 0891E14C0D3DD0876D0C138F /* ChatSession.swift in Sources */, + 2245C93502274850A29D08C3 /* ChatView.swift in Sources */, + A840242666B7A68F3A3EE5CA /* DeviceIdentity.swift in Sources */, + B00F01210DFD198F6F1180C9 /* FragmentAPI.swift in Sources */, + 9B04659A7CA2A9C6E5F0276A /* FragmentApp.swift in Sources */, + 1FA3D11FABC00DC40F41A041 /* FragmentWebSheet.swift in Sources */, + 73EE483533F3A6306B65148C /* HomeView.swift in Sources */, + EBB66B878C4362F14779FB52 /* LaunchScope.swift in Sources */, + 2A5C821580EECE3D7D8C7F3D /* MarkdownText.swift in Sources */, + 4024A3397E46CADB1EC9D344 /* Models.swift in Sources */, + ED598966FDB4DD6D3F4486A1 /* SettingsView.swift in Sources */, + 8356DCE8D6F00248A2D4A6A9 /* ShellViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 059CA4C243EC9281F90C4DA0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A7A7E910E323B68F306752CC /* Fragment */; + targetProxy = F1A7D59A573F9AE091A5072B /* PBXContainerItemProxy */; + }; + 84230630DAFCC3A085FC53F3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A7A7E910E323B68F306752CC /* Fragment */; + targetProxy = A00A083525D87C2EA533CDDC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 11AABB6FF516E863920DE831 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Finite; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connect to your local Finite development server."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.fragment; + }; + name = Debug; + }; + 39043B6B29F74B1CD6D7B46D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 0.3; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 46F94C0327E16DB510D0325F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.FragmentTests; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Fragment.app/Fragment"; + }; + name = Debug; + }; + 85070E8A2550D2985A5F1D51 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Finite; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.fragment; + }; + name = Release; + }; + 90798D034C4F2238C203CAB0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.FragmentUITests; + TEST_TARGET_NAME = Fragment; + }; + name = Release; + }; + 993DEAAD33886E4311D22B12 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.FragmentUITests; + TEST_TARGET_NAME = Fragment; + }; + name = Debug; + }; + C87C160775B2F59D7B9C4EAD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = build.finite.FragmentTests; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Fragment.app/Fragment"; + }; + name = Release; + }; + F16EC6677DE2791A6CE83756 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 0.3; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 06E292003E7442D1D8B9D829 /* Build configuration list for PBXNativeTarget "FragmentTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 46F94C0327E16DB510D0325F /* Debug */, + C87C160775B2F59D7B9C4EAD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 90E614231237E44BB37C8991 /* Build configuration list for PBXNativeTarget "Fragment" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 11AABB6FF516E863920DE831 /* Debug */, + 85070E8A2550D2985A5F1D51 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + CD4001B831A37D88BA3B41F6 /* Build configuration list for PBXProject "Fragment" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F16EC6677DE2791A6CE83756 /* Debug */, + 39043B6B29F74B1CD6D7B46D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FD9D6F862081CA8A2E8B54E2 /* Build configuration list for PBXNativeTarget "FragmentUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 993DEAAD33886E4311D22B12 /* Debug */, + 90798D034C4F2238C203CAB0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1A66842FDD099C8FE13F9F7E /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1.git"; + requirement = { + kind = exactVersion; + version = 0.21.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9E2A4999E690DD7D41154B0B /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 1A66842FDD099C8FE13F9F7E /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = P256K; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 03B62A6F44730F69CF29B303 /* Project object */; +} diff --git a/ios/Fragment.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Fragment.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/ios/Fragment.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Fragment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Fragment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..323404c26 --- /dev/null +++ b/ios/Fragment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "6827adc91558847f00a4993c64addf8bc378062fab8b489eb1c301118b704e0b", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", + "state" : { + "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", + "version" : "0.21.1" + } + } + ], + "version" : 3 +} diff --git a/ios/Fragment.xcodeproj/xcshareddata/xcschemes/Fragment.xcscheme b/ios/Fragment.xcodeproj/xcshareddata/xcschemes/Fragment.xcscheme new file mode 100644 index 000000000..a98015d14 --- /dev/null +++ b/ios/Fragment.xcodeproj/xcshareddata/xcschemes/Fragment.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Info.plist b/ios/Info.plist new file mode 100644 index 000000000..9bf37aa14 --- /dev/null +++ b/ios/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Finite + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Connect to your local Finite development server. + NSMicrophoneUsageDescription + Record voice memos to send to your agents. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 000000000..805189034 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,164 @@ +# Finite for iOS + +A native SwiftUI client for Finite.Computer, organized like the web shell. iOS 26+, +Swift 6, Apple frameworks and pinned P256K 0.21.1 for CLI-compatible NIP-98 signing. +No Rust bindings, shared runtime, or web chat UI. App fragments and imported +conversations open in a native WKWebView sheet. Every request is signed by this +iPhone's own approved key: there is no unsigned or offline mode. + +## Connect an account + +Enter your Finite platform’s HTTPS address and tap Connect. Enter the phone’s +approval code at that server’s `/cli` page and approve the Finite-iOS request: +sign-in stays the website’s WorkOS flow. The phone keeps its own approved key in +Keychain; never import the CLI’s private key. Settings → Account connection +revokes and disconnects this phone. If its server is unreachable, Remove from +this iPhone clears the saved connection locally; the key still needs revoking on +the web. Private apps and brains open through `POST /api/f/{name}/session`. See +[the integration contract](../docs/ios.md). + +## Run against a local platform + +The app talks to a real platform, never a stand-in. For development that is +`cargo xtask dev` (the repository's dev stack, with its vendor fakes) on the Linux +machine at `:8790`. Forward the port to the Mac, so the Simulator's loopback +reaches it; macOS resolves `.fragment.localhost` to loopback too, so app +sheets load: + +```sh +ssh -N -L 8790:127.0.0.1:8790 linux # on the Mac, while you work +open ios/Fragment.xcodeproj +``` + +Choose the **Fragment** scheme and an iPhone Simulator, then Run. Debug builds +take HTTP loopback and `.localhost` origins; `--pair-server http://127.0.0.1:8790` +fills in Connect. Approve the code at `http://127.0.0.1:8790/cli`, signed in +through the dev stack's WorkOS fake. The checked-in Xcode project needs no +generator; Xcode resolves the pinned signing package. + +From a terminal: + +```sh +xcrun simctl list devices available +FRAGMENT_IOS_SIMULATOR='paste-an-iOS-26.5-iPhone-udid-here' +xcodebuild -project ios/Fragment.xcodeproj -scheme Fragment -configuration Debug -destination "platform=iOS Simulator,id=$FRAGMENT_IOS_SIMULATOR" -derivedDataPath target/ios build +xcrun simctl boot "$FRAGMENT_IOS_SIMULATOR" # if it is not booted +xcrun simctl install "$FRAGMENT_IOS_SIMULATOR" target/ios/Build/Products/Debug-iphonesimulator/Fragment.app +xcrun simctl launch "$FRAGMENT_IOS_SIMULATOR" build.finite.fragment --pair-server http://127.0.0.1:8790 +open -a Simulator +``` + +A physical phone reaches a platform over HTTPS; Release builds take nothing else. + +## Scope + +- Opens on the fullscreen search/new-chat launcher over the exact web desktop + wallpaper, with system light/dark variants. System typography and iOS 26 glass + controls throughout. Product identity is Finite.Computer (Finite for short). +- A left drawer follows horizontal dismissal drags, fading the backdrop with + the distance and settling closed or back in place on release. Vertical chat + scrolling stays independent, and settling respects Reduce Motion. It mirrors + the web: agent menus and recent chats grouped by lead + agent, group-chat faces, History, Your Apps, Settings. The launcher selects an agent; + agent hero menus and chat-row context menus can add more agents, and a group composer addresses one or all. +- Your Apps is a native list over the shared photo background, with Add an app. Add opens the built-in app + prompt sheet; choosing an idea returns a draft to the launcher. + The top-right Manage Apps button opens a searchable list including hidden and + archived apps; each visible app also has a settings button. App Settings covers + naming, personal show/hide/archive, sharing links, member permissions and + invitations, icon generation/upload/removal, and preview generation. + No duplicate suggestions/empty state in the app library itself. +- Dismissible app sheets leave the conversation in place; a bounded in-memory + session cache retains composer drafts, pending post ids and cursors across navigation. +- Native message author identities, Markdown, timestamps, long-press Reply/Copy/ + Share, quoted replies, file import, and AAC voice memos (five minutes maximum). + Attachments use the existing blob API and chat record shape: eight per message, + 25 MiB per file. Download/share and audio playback are native. +- Connected accounts follow the signed list watcher and mark visible chats read. +- Existing HTTP/WebSocket streaming, Stop, same-id retry, independent cursors, + backwards paging, reconnect and foreground resumption remain. +- History searches message contents and lists archived chats and read-only imports + from the Session Archive's actual `index/chat.json` and lexical search contract. +- Native settings categories mirror web organization: account, billing, computer, + agents, skills, connections, developer setup, data, and this iPhone, all read + and written through signed platform calls. Account connection enrolls or + revokes this iPhone’s own key; billing, OAuth setup and renaming an agent + continue on the web (an agent's name is its job's too). No simulated purchases. +- Microphone and notification permission are native. Background reply delivery + still requires production push integration; permission alone does not deliver it. + +Push delivery, interactive approval cards, full Markdown tables and imported-chat +continuation are separate work. Drafts and exact pending sends survive relaunch; +message history is read again from the platform. Unknown records stay readable; +approval cards still direct you to the web. + +## Icon and composer + +The installed icon is `Sources/AppIcon.icon`, a layered Icon Composer document. +Its transparent vector foreground preserves the hand paths in +`cell/shell/finite-logo.svg`, centered at 680 points on a 1024-point canvas over +an independent charcoal background. Corners, lighting and appearance variants +are compiled by Xcode rather than painted into the artwork, following +[Apple’s app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons). +XcodeGen 2.42 needs the `icon` file-type mapping in `project.yml` to keep the +package intact. Edit the document in Icon Composer and regenerate the project. + +The chat composer has a single glass surface, 44-point controls and a 34-point +send/stop circle with semantic foreground/background colors. Chat addressing is +in the sidebar agent menu’s Agents in this chat → Messages setting, rather than +a caption above the composer. Your Apps displays each fragment’s original +`picture` (its versioned icon URL), preserving its transparency and aspect ratio. + +## Verification + +```sh +xcodebuild -project ios/Fragment.xcodeproj -scheme Fragment -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' -derivedDataPath target/ios test +``` + +The unit tests need no server and spend nothing. `TransportTests`, +`ShellTests`, `SearchTests` and `AppSettingsTests` run the app's real HTTP and +live-socket code against a stub of the platform's answers (`Tests/Stub.swift`: +a `URLProtocol` for HTTP, and a scripted `LiveSocket`, since `URLProtocol` never +sees a WebSocket). They cover what a phone meets: an acknowledgement lost after +the post committed (retried with the same id and body), a refusal (final: the +post leaves the outbox and its words return to the composer), a rate limit +(kept to retry), a dropped stream (resubscribed from its cursor, nothing twice), +Stop, a long turn whose start is past the backlog (read back, so Stop shows), +earlier pages that never move the live cursor, an ended authorization, a web +sheet that never reopens a spent redemption, an archive with no chats index or +a failing read, an export built then downloaded signed, and the requests behind +lists, joins, renames, archiving, attachments, search, sharing and icons. `TimelineTests` cover ordering, cursors, drafts and turns; +`DeviceIdentityTests` NIP-98 binding, origins and Keychain; `DraftTests` the +saved draft and outbox across restarts (a paste past a message, a refused send +beside a draft, a saved file past its bounds or unreadable); +`MarkdownTests` a code fence arriving in parts. The stubs' answers are written +by hand from docs/api.md. + +`FragmentUITests` drive what needs no server: Connect, an unreachable server, +and a server past loopback refused. Each launches with `--ui-test` (Debug only): +a Keychain service and Application Support folder of its own, emptied as it +starts, and loopback alone, so a simulator's own account is never read and no +other server is ever asked (`Sources/LaunchScope.swift`; +`--ui-test-saved-account ` seeds one, to show it unused). Flows against +a paired account on the dev stack are their own suite. + +The source of the generated project is `project.yml`. If changing project +structure, use XcodeGen 2.42+ and commit its result: + +```sh +xcodegen generate --spec ios/project.yml +``` + +## Design references + +The signed-in home follows Fragment's own fullscreen Command-K launcher +(`cell/shell/shell.js`): search everything and default to starting a new chat, +replacing an older three-recents home (`finitechat/ios/Sources/FocusedAppShell.swift` +at `finite-mono` commit `a1d5089c^`). + +[Enchanted](https://github.com/gluonfield/enchanted) supplies a native iOS +chat reference for conversation history, Markdown and a configurable server. +[Claude's iOS app](https://claude.com/resources/articles/team-plan-and-ios) +is a reference for a focused conversation-first mobile experience. No source +code was copied. SwiftUI's native scrolling follows +[Apple's scroll geometry API](https://developer.apple.com/documentation/swiftui/view/onscrollgeometrychange(for:of:action:)). diff --git a/ios/Sources/AccountConnection.swift b/ios/Sources/AccountConnection.swift new file mode 100644 index 000000000..eed8739c2 --- /dev/null +++ b/ios/Sources/AccountConnection.swift @@ -0,0 +1,194 @@ +import SwiftUI +import WebKit + +@MainActor @Observable +final class AccountConnection { + struct Code: Decodable { let code: String; let expiresAt: Int64 } + var account: DeviceAccount? + var code: Code? + var error: String? + var busy = false + @ObservationIgnored private var cachedAPI: FragmentAPI? + var api: FragmentAPI? { + guard let account, account.approved, let key = try? account.signingKey else { return nil } + if let cachedAPI, cachedAPI.baseURL == account.origin, cachedAPI.identity.publicKey == key.publicKey, cachedAPI.accountID == account.identity { return cachedAPI } + let client = FragmentAPI(baseURL: account.origin, identity: key, accountID: account.identity) + cachedAPI = client + return client + } + /// Where its account is kept, and what it may reach (`LaunchScope`). + private let scope: LaunchScope + init(scope: LaunchScope = .current) { + self.scope = scope + do { + if let stored = try DeviceKeychain.read(service: scope.keychainService) { + guard try PlatformOrigin.parse(stored.origin.absoluteString, scope: scope) == stored.origin else { throw APIError.invalidResponse } + _ = try stored.signingKey + account = stored + } + } + catch { self.error = error.localizedDescription } + } + func begin(_ raw: String, newKey: Bool = false) async { + busy = true; error = nil; code = nil; defer { busy = false } + do { + let origin = try PlatformOrigin.parse(raw, scope: scope) + if let existing = account, existing.origin != origin && existing.approved { + throw APIError.server("Disconnect this iPhone before pairing a different account.") + } + if newKey || account == nil || account?.origin != origin { + let key = try DeviceIdentity() + let next = DeviceAccount(origin: origin, secret: key.secret, approved: false) + try DeviceKeychain.save(next, service: scope.keychainService); account = next + } + guard let account else { throw APIError.invalidResponse } + let api = try FragmentAPI(baseURL: origin, identity: account.signingKey) + if try await finish(api) { return } + code = try await api.request("cli/begin", method: "POST", body: .object([:])) + } catch { self.error = error.localizedDescription } + } + func poll() async { + guard let code, let account else { return } + do { + let api = try FragmentAPI(baseURL: account.origin, identity: account.signingKey) + // The same interval and ten-minute bound as fragment login. + for _ in 0..<300 { + try Task.checkCancellation() + guard Int64(Date().timeIntervalSince1970 * 1000) < code.expiresAt else { error = "This code expired. Start again for a new code."; return } + if try await finish(api) { self.code = nil; return } + try await Task.sleep(for: .seconds(2)) + } + error = "This code expired. Start again for a new code." + } catch { if !Task.isCancelled { self.error = error.localizedDescription } } + } + private func finish(_ api: FragmentAPI) async throws -> Bool { + do { + let person: JSONValue = try await api.request("api/identities/me") + try Task.checkCancellation() + guard var next = account, next.origin == api.baseURL, + try next.signingKey.publicKey == api.identity.publicKey else { throw CancellationError() } + guard person["kind"].string == "person", let identity = person["id"].string else { throw APIError.invalidResponse } + next.identity = identity; next.email = person["email"].string; next.approved = true + try DeviceKeychain.save(next, service: scope.keychainService); account = next; error = nil + return true + } catch APIError.unauthorized { return false } + } + func validate() async { + guard let api else { return } + do { + if try await finish(api) == false, account?.origin == api.baseURL, + (try? account?.signingKey.publicKey) == api.identity.publicKey { expire() } + } catch is CancellationError { return } + catch { self.error = error.localizedDescription } + } + func expire() { + guard var next = account, next.approved else { return } + next.approved = false + do { try DeviceKeychain.save(next, service: scope.keychainService); account = next; error = "This iPhone’s authorization ended. Pair again to reconnect." } + catch { self.error = error.localizedDescription; account = nil } + } + func disconnect() async { + busy = true; error = nil; defer { busy = false } + do { + let client = api + if let api = client, let account { + let _: JSONValue = try await api.request("api/identities/me/keys/\(try account.signingKey.publicKey)", method: "DELETE") + } + try await removeLocalAccount() + } catch { self.error = error.localizedDescription } + } + func forget() async { + busy = true; error = nil; defer { busy = false } + do { try await removeLocalAccount() } + catch { self.error = error.localizedDescription } + } + private func removeLocalAccount() async throws { + if let account { + let client = try FragmentAPI(baseURL: account.origin, identity: account.signingKey, accountID: account.identity) + try ChatDraftStore.removeAccount(api: client) + } + try DeviceKeychain.clear(service: scope.keychainService); account = nil; code = nil; cachedAPI = nil + await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast) + } +} + +struct AccountConnectionView: View { + let connection: AccountConnection + var close: () -> Void = {} + /// The address to pair with, as the screen first shows it: Debug's + /// `--pair-server`, else the saved account's, else Finite's own. + @State private var server: String + init(connection: AccountConnection, close: @escaping () -> Void = {}) { + self.connection = connection + self.close = close + _server = State(initialValue: Self.pairServer ?? connection.account?.origin.absoluteString ?? "https://finite.chat") + } + @State private var replaceKey = false + @State private var forgetConnection = false + @Environment(\.openURL) private var openURL + @Environment(\.scenePhase) private var scenePhase + var body: some View { + NavigationStack { + Form { + Section { + Text("Finite.Computer").font(.title2.weight(.semibold)) + Text("Connect this iPhone to your account.").foregroundStyle(.secondary) + } + if let account = connection.account, account.approved { + Section("Connected") { + Text(account.email ?? "Your account") + Text(account.origin.absoluteString).font(.footnote).foregroundStyle(.secondary) + Button("Disconnect this iPhone", role: .destructive) { Task { await connection.disconnect() } } + .accessibilityIdentifier("disconnectAccount") + } + } else { + Section("Your Finite server") { + TextField("https://finite.chat", text: $server).textContentType(.URL).keyboardType(.URL) + .textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier("accountServer") + Button(connection.code == nil ? "Connect" : "Get a new code") { Task { await connection.begin(server) } } + .accessibilityIdentifier("pairAccount") + } + if let code = connection.code, let origin = connection.account?.origin { + Section("Approve this iPhone") { + Text(code.code).font(.system(.largeTitle, design: .monospaced).weight(.semibold)).textSelection(.enabled).accessibilityIdentifier("pairingCode") + Button("Copy code", systemImage: "doc.on.doc") { UIPasteboard.general.string = code.code } + Button("Open approval page", systemImage: "safari") { openURL(origin.appending(path: "cli")) } + Text("Enter this code on that page, check the Finite-iOS request, and approve. You can also use a browser on another device. Return here to finish connecting.").font(.footnote).foregroundStyle(.secondary) + ProgressView("Waiting for approval…") + } + } + if connection.account != nil && connection.code == nil { + Section { Button("Create a new device key") { replaceKey = true } } + } + } + if connection.account != nil { + Section { + Button("Remove from this iPhone", role: .destructive) { forgetConnection = true } + .accessibilityIdentifier("forgetAccount") + } footer: { + Text("Use this if the server is unreachable. This removes the saved key and drafts from this iPhone; revoke its approved key from the website when you can.") + } + } + if connection.busy { ProgressView() } + if let error = connection.error { Section { Text(error).foregroundStyle(.secondary) } } + } + .navigationTitle("Connect Finite").navigationBarTitleDisplayMode(.inline) + .disabled(connection.busy) + .task(id: "\(connection.code?.code ?? "")|\(scenePhase)") { if connection.code != nil && scenePhase == .active { await connection.poll() } } + .onChange(of: connection.account?.approved) { _, approved in if approved == true { close() } } + .confirmationDialog("Remove this connection from this iPhone?", isPresented: $forgetConnection, titleVisibility: .visible) { + Button("Remove saved connection", role: .destructive) { Task { await connection.forget() } } + } message: { Text("The key may still be approved on the server. This removes only the key and saved drafts on this iPhone.") } + .confirmationDialog("Create a new device key?", isPresented: $replaceKey, titleVisibility: .visible) { + Button("Create new key") { Task { await connection.begin(server, newKey: true) } } + } message: { Text("Use this if the previous key was revoked. Other devices keep their own keys.") } + } + } + private static var pairServer: String? { + #if DEBUG + let args = ProcessInfo.processInfo.arguments + if let i = args.firstIndex(of: "--pair-server"), args.indices.contains(i + 1) { return args[i + 1] } + #endif + return nil + } +} diff --git a/ios/Sources/AppAppearanceView.swift b/ios/Sources/AppAppearanceView.swift new file mode 100644 index 000000000..d85be4a61 --- /dev/null +++ b/ios/Sources/AppAppearanceView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct AppAppearanceView: View { + let fragment: FragmentSummary + let library: Library + let api: FragmentAPI + @State private var details: AppDetails? + @State private var hint = "" + @State private var options: AppIconOptions? + @State private var preview: Data? + @State private var busy = false + @State private var making = false + @State private var importer = false + @State private var error: String? + @State private var previewRun: UUID? + @State private var previewSaid: String? + private var app: FragmentSummary { library.fragments.first { $0.name == fragment.name } ?? fragment } + private var canEdit: Bool { details?.canEdit ?? false } + private var inFlight: Bool { busy || making || previewRun != nil } + var body: some View { + Form { + Section("App icon") { + HStack { Spacer(); FragmentIcon(fragment: app, fallback: "app", size: 96); Spacer() }.padding(.vertical, 12) + if canEdit { + TextField("Describe an icon (optional)", text: $hint, axis: .vertical).lineLimit(1...3).accessibilityIdentifier("iconHint") + Button("Generate icons", systemImage: "sparkles") { Task { await makeIcons() } }.accessibilityIdentifier("makeAppIcons") + .disabled(inFlight) + Button("Upload icon…", systemImage: "square.and.arrow.up") { importer = true }.disabled(inFlight) + if app.picture != nil { + Button("Remove icon", systemImage: "trash", role: .destructive) { Task { await change { let _: JSONValue = try await api.request("api/f/\(app.name)/icon", method: "DELETE") } } } + .disabled(inFlight).accessibilityIdentifier("removeAppIcon") + } + } + } + if making { Section { ProgressView("Generating icons…") } } + if let options { + Section("Choose an icon") { + Text(options.subject).font(.subheadline).foregroundStyle(.secondary) + ForEach(Array(options.candidates.prefix(8).enumerated()), id: \.offset) { index, candidate in + if let data = candidate.data, let image = UIImage(data: data) { + Button { Task { await chooseIcon(data) } } label: { + HStack(spacing: 16) { + Image(uiImage: image).resizable().scaledToFit().frame(width: 72, height: 72) + Text("Use this icon") + Spacer() + }.padding(.vertical, 4) + }.disabled(inFlight).accessibilityIdentifier("useAppIcon-\(index)") + } + } + if options.candidates.isEmpty { Text("No icons came out right. Try another description.").foregroundStyle(.secondary) } + } + } + Section("Preview") { + if let preview, let image = UIImage(data: preview) { + Image(uiImage: image).resizable().scaledToFit().clipShape(RoundedRectangle(cornerRadius: 12)).accessibilityLabel("App preview").accessibilityIdentifier("appPreviewImage") + } else { Text("No preview yet.").foregroundStyle(.secondary) } + if canEdit && details?.visibility != "members" { + Button("Generate preview", systemImage: "camera") { previewSaid = nil; previewRun = UUID() }.disabled(inFlight).accessibilityIdentifier("makeAppPreview") + } else if canEdit { Text("Previews are available for apps shared by link or publicly. Restricted apps are not photographed.").font(.footnote).foregroundStyle(.secondary) } + if previewRun != nil { ProgressView("Taking a new preview…") } + if let previewSaid { Text(previewSaid).font(.footnote).foregroundStyle(.secondary) } + if let report = details?.page, !report.errors.isEmpty { + DisclosureGroup("Page reported \(report.errors.count) issues") { + ForEach(Array(report.errors.prefix(10).enumerated()), id: \.offset) { _, issue in + Text("\(issue.kind): \(issue.text)").font(.caption).textSelection(.enabled) + } + } + } + } + if busy { ProgressView("Saving…") } + if let error { Section { Text(error).font(.footnote).foregroundStyle(.secondary); Button("Reload appearance") { Task { await load() } } } } + } + .navigationTitle("Icon & Preview").navigationBarTitleDisplayMode(.inline) + .task { await load() } + .task(id: previewRun) { if previewRun != nil { await generatePreview() } } + .fileImporter(isPresented: $importer, allowedContentTypes: [.png, .jpeg, .gif, UTType("org.webmproject.webp") ?? .image]) { result in + switch result { + case .success(let url): Task { await upload(url) } + case .failure(let error): self.error = error.localizedDescription + } + } + } + private func load() async { + do { + async let status = api.appDetails(app.name) + async let image = api.appImage(app.name, kind: "card") + (details, preview) = try await (status, image); error = nil + } catch { self.error = error.localizedDescription } + } + private func change(_ action: () async throws -> Void) async { + busy = true; error = nil; defer { busy = false } + do { try await action(); await library.load(api) } + catch { self.error = error.localizedDescription } + } + private func chooseIcon(_ data: Data) async { + await change { try await api.putAppIcon(data, name: app.name) } + if error == nil { options = nil } + } + private func makeIcons() async { + making = true; options = nil; error = nil; defer { making = false } + do { options = try await api.makeAppIcons(app.name, hint: hint) } + catch { self.error = error.localizedDescription } + } + private func upload(_ url: URL) async { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + await change { try await api.putAppIcon(AppIconImage.fitted(url), name: app.name) } + } + private func generatePreview() async { + defer { previewRun = nil } + do { + struct Asked: Decodable { let since: Int64 } + let asked: Asked = try await api.request("api/f/\(app.name)/card", method: "POST") + // Same bound as the web Apps page. The view's task cancels polling + // when dismissed; a slow capture is never reported as a success. + for _ in 0..<180 { + try Task.checkCancellation() + let status = try await api.appDetails(app.name) + if let report = status.page, report.at >= asked.since { + details = status + preview = try await api.appImage(app.name, kind: "card") + previewSaid = preview == nil ? "The page reported, but no preview image is available." : "Preview updated." + return + } + try await Task.sleep(for: .seconds(1)) + } + previewSaid = "The preview is still being taken. Reload appearance to check again." + } catch { if !Task.isCancelled { self.error = error.localizedDescription } } + } +} diff --git a/ios/Sources/AppCreationSheet.swift b/ios/Sources/AppCreationSheet.swift new file mode 100644 index 000000000..41b148966 --- /dev/null +++ b/ios/Sources/AppCreationSheet.swift @@ -0,0 +1,28 @@ +import SwiftUI + +// Like the web shell's Add hand, this starts an agent conversation; it is +// not an app named "add-app" on the server. +struct AppCreationSheet: View { + let start: (String) -> Void + @State private var idea = "" + var body: some View { + NavigationStack { + Form { + Section { + Text("Ask for an app").font(.title2.weight(.semibold)) + Text("Tell your agent who it’s for and what it should do.").foregroundStyle(.secondary) + TextField("Describe your app…", text: $idea, axis: .vertical).lineLimit(3...8) + Button("Start a chat", systemImage: "arrow.up") { start(idea) } + .disabled(idea.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || idea.utf8.count > 32_768) + } + Section("A few ideas") { + Button("A grocery list for the family") { start("Make a shared grocery list my family can use") } + Button("A reading list worth returning to") { start("Make a reading list for things I want to come back to") } + Button("A little morning briefing") { start("Every morning send me a summary of the sites I follow") } + } + } + .navigationTitle("Add an app").navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.large]).presentationDragIndicator(.visible) + } +} diff --git a/ios/Sources/AppIcon.icon/Assets/FiniteHand.svg b/ios/Sources/AppIcon.icon/Assets/FiniteHand.svg new file mode 100644 index 000000000..da33c374f --- /dev/null +++ b/ios/Sources/AppIcon.icon/Assets/FiniteHand.svg @@ -0,0 +1 @@ + diff --git a/ios/Sources/AppIcon.icon/icon.json b/ios/Sources/AppIcon.icon/icon.json new file mode 100644 index 000000000..244799fa2 --- /dev/null +++ b/ios/Sources/AppIcon.icon/icon.json @@ -0,0 +1,38 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.09412,0.09412,0.09412,1.00000" + }, + "groups": [ + { + "blend-mode": "normal", + "hidden": false, + "layers": [ + { + "glass": true, + "image-name": "FiniteHand.svg", + "name": "Finite Computer hand", + "position": { + "scale": 1, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "lighting": "individual", + "shadow": { + "kind": "neutral", + "opacity": 0.15 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.5 + } + } + ], + "supported-platforms": { + "squares": "shared" + } +} diff --git a/ios/Sources/AppSettingsAPI.swift b/ios/Sources/AppSettingsAPI.swift new file mode 100644 index 000000000..03e628d6e --- /dev/null +++ b/ios/Sources/AppSettingsAPI.swift @@ -0,0 +1,270 @@ +import Foundation +import ImageIO +import UIKit +import UniformTypeIdentifiers + +struct AppDetails: Decodable, Sendable { + struct URLs: Decodable, Sendable { let canonical: URL } + struct Page: Decodable, Sendable { + struct Problem: Decodable, Sendable { let kind: String; let text: String } + let at: Int64 + var errors: [Problem] = [] + } + let owner: String + let role: String + let visibility: String + let urls: URLs + var viewToken: String? + var page: Page? + var canEdit: Bool { role == "owner" || role == "editor" } + var shareURL: URL? { + if visibility == "public" { return urls.canonical } + guard visibility == "link", role == "owner", let viewToken else { return nil } + var components = URLComponents(url: urls.canonical, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "view", value: viewToken)] + return components?.url + } +} + +struct AppMember: Decodable, Identifiable, Sendable { + let principal: String + let role: String + var kind: String? + var owner: String? + var email: String? + var title: String? + var id: String { principal } +} + +struct AppInvite: Decodable, Identifiable, Sendable { + let email: String + let role: String + let expiresAt: Int64 + var id: String { email } +} + +struct AppIconOptions: Decodable, Sendable { + struct Candidate: Decodable, Identifiable, Sendable { + let png: String + let score: Double + var id: String { png } + var data: Data? { Data(base64Encoded: png) } + } + let subject: String + let candidates: [Candidate] +} + +enum AppAccess: String, CaseIterable { + case members, link, `public` + var title: String { + switch self { case .members: "Restricted"; case .link: "Anyone with the link"; case .public: "Public" } + } + var explanation: String { + switch self { + case .members: "Only people with access can open this app." + case .link: "Anyone with its share link can open this app." + case .public: "Anyone can find and open this app." + } + } +} + +enum AppPermission: String, CaseIterable { + case viewer, editor + var title: String { self == .viewer ? "Can view" : "Can edit" } +} + +extension FragmentAPI { + func appDetails(_ name: String) async throws -> AppDetails { try await request("api/f/\(name)/status") } + + func appMembers(_ name: String) async throws -> [AppMember] { + struct Response: Decodable { let members: [AppMember] } + let result: Response = try await request("api/f/\(name)/members") + return result.members + } + + func appInvites(_ name: String) async throws -> [AppInvite] { + struct Response: Decodable { let invites: [AppInvite] } + let result: Response = try await request("api/f/\(name)/invites") + return result.invites + } + + func appImage(_ name: String, kind: String) async throws -> Data? { + precondition(kind == "icon" || kind == "card") + var request = URLRequest(url: url("api/f/\(name)/\(kind)")) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 30 + let (data, response) = try await self.data(for: request) + guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse } + if response.statusCode == 404 { return nil } + guard response.statusCode == 200 else { throw APIError.server("The image could not be loaded (HTTP \(response.statusCode)).") } + guard data.count <= 2 * 1_024 * 1_024, UIImage(data: data) != nil else { throw APIError.invalidResponse } + return data + } + + func putAppIcon(_ data: Data, name: String) async throws { + var request = URLRequest(url: url("api/f/\(name)/icon")) + request.httpMethod = "PUT"; request.httpBody = data; request.timeoutInterval = 30 + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + let (body, response) = try await self.data(for: request) + guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(response.statusCode) else { throw APIError.answer(body, status: response.statusCode) } + } + + func makeAppIcons(_ name: String, hint: String) async throws -> AppIconOptions { + var fields: [String: JSONValue] = [:] + let clean = hint.trimmingCharacters(in: .whitespacesAndNewlines) + if !clean.isEmpty { fields["hint"] = .string(clean) } + return try await request("api/f/\(name)/icon/make", method: "POST", body: .object(fields), timeout: 90) + } +} + +enum AppIconImage { + /// The server fits any icon it is sent inside 256×256 (docs/api.md, + /// Icons). Fitting a picked photo here first, without decoding it whole, + /// only spares the phone sending megabytes the server would shrink. + static let side = 256 + static func fitted(_ file: URL) throws -> Data { + let options = [kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: side] as CFDictionary + guard let source = CGImageSourceCreateWithURL(file as CFURL, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { throw APIError.server("Choose a PNG, JPEG, WebP or GIF image.") } + let png = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(png, UTType.png.identifier as CFString, 1, nil) else { throw APIError.invalidResponse } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw APIError.invalidResponse } + return png as Data + } +} + +/// One export, as its person sees it (`ExportView`, docs/api.md, Export). +struct ExportView: Decodable, Sendable { + struct Progress: Decodable, Sendable { let done: Int; let total: Int } + struct Problem: Decodable, Sendable { let message: String } + let id: String + let state: String + var progress: Progress? + var size: Int64? + var error: Problem? + /// While it is ready: a path on the platform's origin, for its person alone. + var download: String? + var said: String { + switch state { + case "building": progress.map { "Building (\($0.done) of \($0.total))" } ?? "Building" + case "ready": size.map { "Ready, " + ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Ready" + case "failed": "Failed" + (error.map { ": " + $0.message } ?? "") + case "expired": "Expired" + default: state.capitalized + } + } +} + +extension FragmentAPI { + func export() async throws -> ExportView? { + struct Status: Decodable { let export: ExportView? } + let status: Status = try await request("api/export") + return status.export + } + + func startExport() async throws -> ExportView { try await request("api/export", method: "POST", body: .object([:])) } + + /// A ready export's archive, downloaded with this iPhone's signature to a + /// file of its own (`Caches/Exports`), streamed to disk: its path must be + /// the platform's own export download. + func download(_ export: ExportView) async throws -> URL { + guard export.state == "ready", let path = export.download, let url = URL(string: path, relativeTo: baseURL)?.absoluteURL, + PlatformOrigin.same(url, baseURL), url.path == "/api/export/\(export.id)/download" else { throw APIError.invalidResponse } + let (temporary, response) = try await session.download(for: authorized(URLRequest(url: url))) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + if http.statusCode == 401 { authorizationFailed(); throw APIError.unauthorized } + guard http.statusCode == 200 else { throw APIError.answer(Data(), status: http.statusCode) } + let folder = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "Exports", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let file = folder.appending(path: "fragment-export-\(export.id).tar") + if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) } + try FileManager.default.moveItem(at: temporary, to: file) + return file + } +} + +/// The person's export, as Settings follows it (cell/shell/exports.js): its +/// latest view, asked again every `every` while it builds, its archive once +/// downloaded, and a new one made at any time (`POST /api/export` replaces +/// the last). Asks and makes run one after another, each answer applied in +/// the order they were sent, so an older read never lands after a newer +/// one. A downloaded archive is kept with the export it is of: once the +/// export shown is another, it goes (deleted), and one still downloading +/// for an export no longer shown is never kept. +@MainActor @Observable +final class ExportModel { + let api: FragmentAPI + let every: Duration + private(set) var export: ExportView? + private var archive: (id: String, file: URL)? + private var pending = 0 + private var queue: Task? + var error: String? + init(api: FragmentAPI, every: Duration = .seconds(2)) { self.api = api; self.every = every } + + /// The archive downloaded for the export shown, while it is ready. + var file: URL? { archive.flatMap { export?.state == "ready" && $0.id == export?.id ? $0.file : nil } } + var busy: Bool { pending > 0 } + var startLabel: String { + switch export?.state { + case "building": "Exporting…" + case "ready": "Export again" + default: "Export everything" + } + } + + /// `step` after every ask or make sent before it. + private func serially(_ step: @escaping @MainActor () async -> Void) async { + pending += 1 + let before = queue + let next = Task { @MainActor in + await before?.value + await step() + } + queue = next + await next.value + pending -= 1 + } + private func shown(_ view: ExportView?) { + export = view + if let kept = archive, kept.id != view?.id || view?.state != "ready" { drop(kept.file) } + } + private func drop(_ file: URL) { + try? FileManager.default.removeItem(at: file) + if archive?.file == file { archive = nil } + } + + func refresh() async { + await serially { [self] in + do { shown(try await api.export()); error = nil } catch { self.error = error.localizedDescription } + } + } + func start() async { + await serially { [self] in + do { shown(try await api.startExport()); error = nil } catch { self.error = error.localizedDescription } + } + } + func download() async { + guard let export, export.state == "ready" else { return } + pending += 1; defer { pending -= 1 } + do { + let file = try await api.download(export) + // shown no longer (refreshed to another, or made again): never kept + guard self.export?.id == export.id, self.export?.state == "ready" else { return drop(file) } + if let old = archive?.file, old != file { drop(old) } + archive = (export.id, file) + error = nil + } catch { self.error = error.localizedDescription } + } + /// While it builds, its view again every `every`, until it is ready (or + /// failed, or gone), or the screen goes. + func follow() async { + while export?.state == "building" { + do { try await Task.sleep(for: every) } catch { return } + await refresh() + } + } +} diff --git a/ios/Sources/AppSettingsView.swift b/ios/Sources/AppSettingsView.swift new file mode 100644 index 000000000..d38750dfa --- /dev/null +++ b/ios/Sources/AppSettingsView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct AppManagementList: View { + let library: Library + let api: FragmentAPI + @State private var search = "" + private var apps: [FragmentSummary] { + library.allApps.filter { search.isEmpty || $0.displayTitle.localizedCaseInsensitiveContains(search) } + } + var body: some View { + List { + ForEach(apps) { app in + NavigationLink { + AppSettingsView(fragment: app, library: library, api: api) + } label: { + HStack(spacing: 12) { + FragmentIcon(fragment: app, fallback: "app") + VStack(alignment: .leading, spacing: 4) { + Text(app.displayTitle) + if app.archived == true { Text("Archived").font(.caption).foregroundStyle(.secondary) } + else if app.dock == false { Text("Hidden from Your Apps").font(.caption).foregroundStyle(.secondary) } + } + }.padding(.vertical, 4) + }.accessibilityIdentifier("manageApp-\(app.name)") + } + if apps.isEmpty { ContentUnavailableView.search(text: search) } + if let error = library.error { Text(error).font(.footnote).foregroundStyle(.secondary) } + } + .navigationTitle("Manage Apps").navigationBarTitleDisplayMode(.inline) + .searchable(text: $search, prompt: "Find an app") + .refreshable { await library.load(api) } + .task { await library.load(api) } + } +} + +struct AppSettingsView: View { + let fragment: FragmentSummary + let library: Library + let api: FragmentAPI + @State private var details: AppDetails? + @State private var title = "" + @State private var busy = false + @State private var error: String? + private var app: FragmentSummary { library.fragments.first { $0.name == fragment.name } ?? fragment } + private var canEdit: Bool { details?.canEdit ?? false } + var body: some View { + Form { + Section { + HStack(spacing: 16) { + FragmentIcon(fragment: app, fallback: "app", size: 56) + VStack(alignment: .leading, spacing: 4) { + Text(app.displayTitle).font(.headline) + Text(details?.role == "owner" ? "Your app" : "Shared with you").font(.subheadline).foregroundStyle(.secondary) + } + }.padding(.vertical, 8) + } + Section("General") { + if canEdit { + TextField("App name", text: $title).accessibilityIdentifier("appName") + Button("Save name") { Task { await change { try await api.rename(app, title: title) } } } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || title == app.displayTitle) + } + Toggle("Show in Your Apps", isOn: Binding( + get: { app.dock != false }, + set: { value in Task { await change { let _: JSONValue = try await api.request("api/fragments/\(app.name)/dock", method: "PUT", body: .object(["dock": .bool(value)])) } } } + )).tint(.green).disabled(app.archived == true).accessibilityIdentifier("appShown") + if app.archived == true { Text("Unarchive this app to show it in Your Apps.").font(.footnote).foregroundStyle(.secondary) } + } + Section { + NavigationLink { + AppSharingView(fragment: app, library: library, api: api) + } label: { Label("Sharing & Permissions", systemImage: "person.2") }.accessibilityIdentifier("appSharing") + NavigationLink { + AppAppearanceView(fragment: app, library: library, api: api) + } label: { Label("Icon & Preview", systemImage: "photo") }.accessibilityIdentifier("appAppearance") + } + if let address = details?.urls.canonical { + Section("App address") { + Text(address.absoluteString).font(.footnote).textSelection(.enabled) + ShareLink(item: address) { Label("Share app address", systemImage: "square.and.arrow.up") } + } + } + Section { + Button(app.archived == true ? "Unarchive app" : "Archive app", systemImage: "archivebox") { + Task { await change { let _: JSONValue = try await api.request("api/fragments/\(app.name)/archived", method: "PUT", body: .object(["archived": .bool(app.archived != true)])) } } + }.accessibilityIdentifier("archiveApp") + } footer: { Text("Showing, hiding, and archiving change your own app list. They don’t change who can access the app.") } + if busy { ProgressView("Saving…") } + if let error { Section { Text(error).font(.footnote).foregroundStyle(.secondary); Button("Try again") { Task { await load() } } } } + } + .disabled(busy) + .navigationTitle("App Settings").navigationBarTitleDisplayMode(.inline) + .task { if details == nil { title = app.displayTitle; await load() } } + } + private func load() async { + do { details = try await api.appDetails(app.name); error = nil } + catch { self.error = error.localizedDescription } + } + private func change(_ action: () async throws -> Void) async { + busy = true; error = nil; defer { busy = false } + do { try await action(); await library.load(api); await load() } + catch { self.error = error.localizedDescription } + } +} diff --git a/ios/Sources/AppSharingView.swift b/ios/Sources/AppSharingView.swift new file mode 100644 index 000000000..2bafad398 --- /dev/null +++ b/ios/Sources/AppSharingView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct AppSharingView: View { + let fragment: FragmentSummary + let library: Library + let api: FragmentAPI + @State private var details: AppDetails? + @State private var members: [AppMember] = [] + @State private var invites: [AppInvite] = [] + @FocusState private var emailFocused: Bool + @State private var email = "" + @State private var permission = AppPermission.viewer + @State private var busy = false + @State private var loading = true + @State private var error: String? + @State private var resetLink = false + private var owner: Bool { details?.role == "owner" } + private var visibleMembers: [AppMember] { + members.filter { member in + !(member.kind == "agent" && member.owner.map { id in members.contains { $0.principal == id } } == true) + } + } + var body: some View { + Form { + if loading { ProgressView("Loading permissions…") } + if let details { + Section { + if owner { + Picker("General access", selection: Binding( + get: { AppAccess(rawValue: details.visibility) ?? .members }, + set: { value in Task { await change { let _: JSONValue = try await api.request("api/f/\(fragment.name)/visibility", method: "PUT", body: .object(["visibility": .string(value.rawValue)])) } } } + )) { ForEach(AppAccess.allCases, id: \.self) { Text($0.title).tag($0) } } + .accessibilityIdentifier("appVisibility") + } else { LabeledContent("General access", value: AppAccess(rawValue: details.visibility)?.title ?? details.visibility) } + } footer: { Text(AppAccess(rawValue: details.visibility)?.explanation ?? "") } + if let url = details.shareURL { + Section("Share link") { + ShareLink(item: url) { Label("Share link", systemImage: "square.and.arrow.up") } + Button("Copy link", systemImage: "doc.on.doc") { UIPasteboard.general.url = url } + if owner && details.visibility == "link" { Button("Reset share link", systemImage: "arrow.clockwise") { resetLink = true } } + } + } + } + Section("People with access") { + ForEach(visibleMembers) { member in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(memberTitle(member)).lineLimit(2) + if members.contains(where: { $0.kind == "agent" && $0.owner == member.principal }) { + Text("With their agents").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if owner && member.role != "owner" { + Menu { + ForEach(AppPermission.allCases, id: \.self) { role in + Button(role.title) { Task { await change { let _: JSONValue = try await api.request("api/f/\(fragment.name)/members/\(member.principal)", method: "PUT", body: .object(["role": .string(role.rawValue)])) } } } + } + Button("Remove access", role: .destructive) { Task { await change { let _: JSONValue = try await api.request("api/f/\(fragment.name)/members/\(member.principal)", method: "DELETE") } } } + } label: { Text(AppPermission(rawValue: member.role)?.title ?? member.role.capitalized).font(.subheadline) } + .accessibilityIdentifier("memberRole-\(member.principal)") + } else { Text(member.role == "owner" ? "Owner" : AppPermission(rawValue: member.role)?.title ?? member.role).font(.subheadline).foregroundStyle(.secondary) } + }.padding(.vertical, 4) + } + } + if owner { + Section("Invite someone") { + TextField("Email address", text: $email).keyboardType(.emailAddress).textContentType(.emailAddress) + .textInputAutocapitalization(.never).autocorrectionDisabled().focused($emailFocused).accessibilityIdentifier("inviteEmail") + Picker("Permission", selection: $permission) { ForEach(AppPermission.allCases, id: \.self) { Text($0.title).tag($0) } } + Button("Send invitation", systemImage: "envelope") { emailFocused = false; Task { await invite() } } + .disabled(email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityIdentifier("sendAppInvite") + } + if !invites.isEmpty { + Section("Pending invitations") { + ForEach(invites) { invitation in + VStack(alignment: .leading, spacing: 5) { + Text(invitation.email) + Text(AppPermission(rawValue: invitation.role)?.title ?? invitation.role).font(.caption).foregroundStyle(.secondary) + Button("Revoke invitation", role: .destructive) { Task { await change { let _: JSONValue = try await api.request("api/f/\(fragment.name)/invites/\(invitation.email)", method: "DELETE") } } } + .font(.caption).accessibilityIdentifier("revokeInvite-\(invitation.email)") + }.padding(.vertical, 4) + } + } + } + } + if busy { ProgressView("Saving…") } + if let error { Section { Text(error).font(.footnote).foregroundStyle(.secondary); Button("Reload permissions") { Task { await load() } } } } + } + .disabled(busy) + .navigationTitle(owner ? "Sharing & Permissions" : "People with Access").navigationBarTitleDisplayMode(.inline) + .task { await load() } + .confirmationDialog("Reset this share link?", isPresented: $resetLink, titleVisibility: .visible) { + Button("Reset share link", role: .destructive) { Task { await change { let _: JSONValue = try await api.request("api/f/\(fragment.name)/rotate", method: "POST", body: .object(["scopes": .array([.string("view")])])) } } } + } message: { Text("The old link will stop working. People with membership keep their access.") } + } + private func memberTitle(_ member: AppMember) -> String { + if member.principal == details?.owner { return owner ? "You" : "Owner" } + return member.email ?? member.title ?? library.agent(for: member.principal)?.displayTitle ?? member.principal + } + private func load() async { + loading = true; defer { loading = false } + do { + async let status = api.appDetails(fragment.name) + async let people = api.appMembers(fragment.name) + let (newDetails, newMembers) = try await (status, people) + let newInvites = newDetails.role == "owner" ? try await api.appInvites(fragment.name) : [] + details = newDetails; members = newMembers; invites = newInvites; error = nil + } catch { self.error = error.localizedDescription } + } + private func change(_ action: () async throws -> Void) async { + busy = true; error = nil; defer { busy = false } + do { try await action(); await load(); await library.load(api) } + catch { self.error = error.localizedDescription } + } + private func invite() async { + let clean = email.trimmingCharacters(in: .whitespacesAndNewlines) + await change { + let _: JSONValue = try await api.request("api/f/\(fragment.name)/invites", method: "POST", body: .object(["email": .string(clean), "role": .string(permission.rawValue)])) + email = "" + } + } +} diff --git a/ios/Sources/Assets.xcassets/Contents.json b/ios/Sources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..f223229d3 --- /dev/null +++ b/ios/Sources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/Contents.json b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/Contents.json new file mode 100644 index 000000000..e2345e62c --- /dev/null +++ b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images": [ + { + "filename": "wallpaper.jpg", + "idiom": "universal" + }, + { + "filename": "wallpaper-dark.jpg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper-dark.jpg b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper-dark.jpg new file mode 100644 index 000000000..d18e51f9c Binary files /dev/null and b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper-dark.jpg differ diff --git a/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper.jpg b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper.jpg new file mode 100644 index 000000000..e2decefe1 Binary files /dev/null and b/ios/Sources/Assets.xcassets/DesktopWallpaper.imageset/wallpaper.jpg differ diff --git a/ios/Sources/AuthenticatedImage.swift b/ios/Sources/AuthenticatedImage.swift new file mode 100644 index 000000000..ad58cbef2 --- /dev/null +++ b/ios/Sources/AuthenticatedImage.swift @@ -0,0 +1,31 @@ +import SwiftUI + +private struct FragmentAPIKey: EnvironmentKey { static let defaultValue: FragmentAPI? = nil } +extension EnvironmentValues { + var fragmentAPI: FragmentAPI? { + get { self[FragmentAPIKey.self] } + set { self[FragmentAPIKey.self] = newValue } + } +} + +struct AuthenticatedImage: View { + let url: URL + @ViewBuilder let content: (Image?) -> Content + @Environment(\.fragmentAPI) private var api + @State private var image: UIImage? + var body: some View { + content(image.map { Image(uiImage: $0) }) + .task(id: url) { + image = nil + do { + let request = URLRequest(url: url) + let data: Data + let response: URLResponse + if let api, PlatformOrigin.same(api.baseURL, url) { (data, response) = try await api.data(for: request) } + else { (data, response) = try await URLSession.shared.data(for: request) } + guard (response as? HTTPURLResponse)?.statusCode == 200, data.count <= 2 * 1_024 * 1_024 else { return } + image = UIImage(data: data) + } catch { /* The caller's placeholder remains when an icon is unavailable. */ } + } + } +} diff --git a/ios/Sources/ChatDraftStore.swift b/ios/Sources/ChatDraftStore.swift new file mode 100644 index 000000000..4015ee8a0 --- /dev/null +++ b/ios/Sources/ChatDraftStore.swift @@ -0,0 +1,113 @@ +import Foundation +import CryptoKit + +struct SavedChat: Codable { + var version = 1 + var composer: String + var attachments: [ChatAttachment] + var replyTo: ChannelRecord? + var recipients: [String] + var pending: [PendingPost] + var isEmpty: Bool { composer.isEmpty && attachments.isEmpty && pending.isEmpty && replyTo == nil } + + /// One chat's bounds, which editing, saving and loading all hold it to: + /// a message's words, its attachments, and the sends waiting. + static let textMax = 32_768 + static let attachmentsMax = 8 + static let pendingMax = 16 + /// A saved file's size at most: the bounds above, with room for JSON's + /// escapes. + static let fileMax = 4 * 1_048_576 + + /// `text` cut to `textMax` UTF-8 bytes, at a character, and whether it was. + static func fitted(_ text: String) -> (String, Bool) { + guard text.utf8.count > textMax else { return (text, false) } + var kept = "", bytes = 0 + // whole characters: one that would cross the bound is dropped whole + for character in text { + bytes += character.utf8.count + guard bytes <= textMax else { break } + kept.append(character) + } + return (kept, true) + } + + /// Within the bounds, and whether anything was cut to get there: the + /// composer to `textMax`, its attachments to the first eight, and the + /// sends no send could be (no id, too long) and those past sixteen + /// dropped. + func fitted() -> (SavedChat, Bool) { + var chat = self + let (composer, long) = Self.fitted(composer) + chat.composer = composer + chat.attachments = Array(attachments.prefix(Self.attachmentsMax)) + let sendable = pending.filter { !$0.id.isEmpty && $0.text.utf8.count <= Self.textMax && $0.attachments.count <= Self.attachmentsMax } + chat.pending = Array(sendable.prefix(Self.pendingMax)) + return (chat, long || chat.attachments.count < attachments.count || chat.pending.count < pending.count) + } +} + +struct ChatDraftStore { + let directory: URL + let file: URL + private let origin: URL + private let publicKey: String + private let accountID: String? + init(api: FragmentAPI, fragment: String, root: URL? = nil) throws { + origin = api.baseURL; publicKey = api.identity.publicKey; accountID = api.accountID + directory = try Self.accountDirectory(api: api, root: root).appending(path: "Chats", directoryHint: .isDirectory) + file = directory.appending(path: Data(SHA256.hash(data: Data(fragment.utf8))).hex + ".json") + } + private static func accountDirectory(api: FragmentAPI, root: URL? = nil) throws -> URL { + let support = try root ?? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let scope = Data(SHA256.hash(data: Data((api.baseURL.absoluteString + "|" + (api.accountID ?? api.identity.publicKey)).utf8))).hex + return support.appending(path: LaunchScope.current.supportFolder + "/Accounts/" + scope, directoryHint: .isDirectory) + } + /// What this chat saved, within its bounds (`cut`: some of it was not), + /// or nothing. A file that is no saved chat (another version, too big, + /// not JSON) is removed, and said so (`unreadable`): a chat always opens. + func load() throws -> (chat: SavedChat, cut: Bool)? { + guard FileManager.default.fileExists(atPath: file.path) else { return nil } + let size = try file.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= SavedChat.fileMax, let saved = try? JSONDecoder().decode(SavedChat.self, from: Data(contentsOf: file)), saved.version == 1 else { + try FileManager.default.removeItem(at: file) + throw ChatDraftStore.unreadable + } + return saved.fitted() + } + static let unreadable = APIError.server("This chat’s saved draft could not be read, and was cleared.") + + /// Saves `saved` as `load` takes it back: within its bounds. Answers + /// whether anything was cut to fit (nothing is when its chat keeps to + /// them as it is edited: `ChatSession`). + @discardableResult + func save(_ saved: SavedChat) throws -> Bool { + let (saved, cut) = saved.fitted() + if let accountID { + // A send finishing after disconnect must not recreate erased files. + guard let active = try DeviceKeychain.read(), active.origin == origin, + active.identity == accountID, try active.signingKey.publicKey == publicKey else { return cut } + } + if saved.isEmpty { + if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) } + return cut + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.protectionKey: FileProtectionType.complete]) + if !FileManager.default.fileExists(atPath: file.path) { + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + guard files.count < 32 else { throw APIError.server("Finish or discard a draft in another chat before saving more conversations.") } + } + let data = try JSONEncoder().encode(saved) + // within its bounds a chat fits, but for text of nothing but JSON escapes + guard data.count <= SavedChat.fileMax else { throw APIError.server("This chat’s draft is too large to keep on this iPhone.") } + try data.write(to: file, options: [.atomic, .completeFileProtection]) + var excluded = directory + var values = URLResourceValues(); values.isExcludedFromBackup = true + try excluded.setResourceValues(values) + return cut + } + static func removeAccount(api: FragmentAPI) throws { + let path = try accountDirectory(api: api) + if FileManager.default.fileExists(atPath: path.path) { try FileManager.default.removeItem(at: path) } + } +} diff --git a/ios/Sources/ChatMedia.swift b/ios/Sources/ChatMedia.swift new file mode 100644 index 000000000..7adb8d451 --- /dev/null +++ b/ios/Sources/ChatMedia.swift @@ -0,0 +1,118 @@ +import SwiftUI +import CryptoKit +import AVFoundation +import UniformTypeIdentifiers + +struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { + let sha256: String + let name: String + let type: String + let size: Int + var id: String { sha256 } + var json: JSONValue { .object(["sha256": .string(sha256), "name": .string(name), "type": .string(type), "size": .number(Double(size))]) } +} + +extension ChatAttachment { + /// The app's own bound on one attachment: it is read whole into memory, + /// and sent over a phone's network. The server takes up to + /// `limits::BLOB_MAX_BYTES` (crates/proto). + static let bytesMax = 25 * 1_024 * 1_024 +} + +extension FragmentAPI { + func upload(_ data: Data, name: String, type: String, to fragment: String) async throws -> ChatAttachment { + let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + var request = URLRequest(url: url("api/f/\(fragment)/blobs/\(hash)")) + request.httpMethod = "PUT"; request.httpBody = data; request.timeoutInterval = 60 + request.setValue(type, forHTTPHeaderField: "Content-Type") + request.setValue(String(data.count), forHTTPHeaderField: "Content-Length") + let (body, response) = try await self.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw APIError.answer(body, status: http.statusCode) } + return ChatAttachment(sha256: hash, name: name, type: type, size: data.count) + } +} + +@MainActor @Observable +final class VoiceMemo { + private var recorder: AVAudioRecorder? + private var deadline: Task? + private var url: URL? + var recording = false + var error: String? + func start(finished: @escaping @MainActor (URL) -> Void) async { + guard await AVAudioApplication.requestRecordPermission() else { error = "Allow microphone access in iOS Settings to record a voice memo."; return } + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setActive(true) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + let recorder = try AVAudioRecorder(url: url, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44_100, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 96_000]) + guard recorder.record() else { throw APIError.server("The microphone could not start recording.") } + self.recorder = recorder; self.url = url; recording = true + // Voice memos share the web's five-minute bound, including an unattended recording. + deadline = Task { do { try await Task.sleep(for: .seconds(300)); if let url = stop() { finished(url) } } catch {} } + } catch { self.error = error.localizedDescription } + } + func stop() -> URL? { + deadline?.cancel(); deadline = nil + recorder?.stop(); recorder = nil; recording = false + do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } + catch { self.error = error.localizedDescription } + defer { url = nil } + return url + } + func cancel() { + if let url = stop() { do { try FileManager.default.removeItem(at: url) } catch { self.error = error.localizedDescription } } + } +} + +struct AttachmentView: View { + let attachment: ChatAttachment + let api: FragmentAPI + let fragment: String + @State private var downloaded: URL? + @State private var loading = false + @State private var error: String? + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if let downloaded { + if attachment.type.hasPrefix("audio/") { AudioMemoPlayer(url: downloaded) } + ShareLink(item: downloaded) { Label(attachment.name, systemImage: "doc") } + } else { + Button { Task { await download() } } label: { Label(attachment.name, systemImage: attachment.type.hasPrefix("audio/") ? "waveform" : "arrow.down.doc") }.disabled(loading) + } + if loading { ProgressView() } + if let error { Text(error).font(.caption).foregroundStyle(.secondary) } + }.padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + } + private func download() async { + loading = true; defer { loading = false } + do { + let (data, response) = try await api.data(for: URLRequest(url: api.url("api/f/\(fragment)/blobs/\(attachment.sha256)"))) + guard (response as? HTTPURLResponse)?.statusCode == 200, data.count <= 25 * 1_024 * 1_024 else { throw APIError.invalidResponse } + // The server's name is display-only; never let it choose a filesystem path. + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.sha256, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let target = directory.appendingPathComponent(URL(fileURLWithPath: attachment.name).lastPathComponent) + try data.write(to: target, options: .atomic); downloaded = target + } catch { self.error = error.localizedDescription } + } +} + +private struct AudioMemoPlayer: View { + let url: URL + @State private var player: AVAudioPlayer? + @State private var error: String? + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Button("Play voice memo", systemImage: "play.fill") { + do { player = try AVAudioPlayer(contentsOf: url); player?.play() } catch { self.error = error.localizedDescription; player = nil } + } + Button("Stop", systemImage: "stop.fill") { player?.stop() } + } + if let error { Text(error).font(.caption).foregroundStyle(.secondary) } + }.onDisappear { player?.stop() } + } +} diff --git a/ios/Sources/ChatSession.swift b/ios/Sources/ChatSession.swift new file mode 100644 index 000000000..f61b32cc6 --- /dev/null +++ b/ios/Sources/ChatSession.swift @@ -0,0 +1,273 @@ +import Foundation +import Observation + +@MainActor @Observable +final class ChatSession { + let fragment: FragmentSummary + let api: FragmentAPI + private(set) var timeline = Timeline() + private(set) var pending: [PendingPost] = [] { didSet { scheduleDraftSave() } } + private(set) var connected = false + private(set) var caughtUp = false + private(set) var principal = "" + private(set) var loadingHistory = false + private(set) var hasEarlier = true + var error: String? + var composer = "" { didSet { scheduleDraftSave() } } + var attachments: [ChatAttachment] = [] { didSet { scheduleDraftSave() } } + var replyTo: ChannelRecord? { didSet { scheduleDraftSave() } } + var recipients: [String] = [] { didSet { scheduleDraftSave() } } + + @ObservationIgnored private var persistence: ChatDraftStore? + @ObservationIgnored private var draftWrite: Task? + + init(fragment: FragmentSummary, api: FragmentAPI) { + self.fragment = fragment + self.api = api + do { persistence = try ChatDraftStore(api: api, fragment: fragment.name) } + catch { self.error = "The local draft could not be opened: " + error.localizedDescription; return } + // What was saved, within its bounds; a file that is none is cleared. + // Either way the chat opens and sends. + do { + if let (saved, cut) = try persistence?.load() { + composer = saved.composer; attachments = saved.attachments; replyTo = saved.replyTo; recipients = saved.recipients + // A terminated send may already be committed. Retry remains an + // explicit action, with exactly the saved id and body. + pending = saved.pending.map { var post = $0; post.failed = true; return post } + if cut { self.error = "Part of this chat’s saved draft was longer than a message can be, and was cut." } + } + } catch { self.error = error.localizedDescription } + } + private var saved: SavedChat { SavedChat(composer: composer, attachments: attachments, replyTo: replyTo, recipients: recipients, pending: pending) } + private func scheduleDraftSave() { + guard persistence != nil else { return } + draftWrite?.cancel() + draftWrite = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(300)) } catch { return } + self?.flushDraft() + } + } + func flushDraft() { + draftWrite?.cancel(); draftWrite = nil + do { try persistence?.save(saved) } + catch { self.error = "The local draft could not be saved: " + error.localizedDescription } + } + + /// The composer as typed or pasted, kept to what a message (and its + /// saved draft) can hold: past it, cut at a character, and said so. + func edit(_ text: String) { + let (kept, cut) = SavedChat.fitted(text) + composer = kept + if cut { error = "A message is at most 32 KB: the rest was cut." } + } + + /// A refused send's words back in the composer, to change and send + /// anew, once the composer is empty (`deliver`). + func reopen(_ post: PendingPost) { + guard let index = pending.firstIndex(where: { $0.id == post.id }), pending[index].refused != nil else { return } + guard composer.isEmpty && attachments.isEmpty else { error = "Send or clear your draft first, then edit this message."; return } + let refused = pending.remove(at: index) + // its own words, files and addressees: the draft's were kept until now + composer = refused.text; attachments = refused.attachments; recipients = refused.recipients + replyTo = timeline.records.first { $0.channel == "chat" && $0.seq == refused.replyTo } + error = nil + } + + // Owned by the view's .task: backgrounding or leaving the chat closes the + // socket, including a suspended receive. Each foreground visit catches up. + func run() async { + var attempt = 0 + while !Task.isCancelled { + let socket: LiveSocket + do { socket = try api.socket(fragment.name) } + catch { self.error = error.localizedDescription; return } + var refused = false + socket.resume() + do { + try await withTaskCancellationHandler { + let heartbeat = Task { + while !Task.isCancelled { + try await Task.sleep(for: .seconds(20)) + try await socket.send(.string("{\"type\":\"ping\"}")) + } + } + defer { heartbeat.cancel() } + while !Task.isCancelled { + let message = try await socket.receive() + let data: Data + switch message { + case .string(let text): data = Data(text.utf8) + case .data(let bytes): data = bytes + @unknown default: throw APIError.invalidResponse + } + let frame = try JSONDecoder().decode(LiveFrame.self, from: data) + if frame.type == "hello" { + principal = frame.principal ?? "" + connected = true + error = nil + attempt = 0 + for channel in ["chat", "work"] { + try await subscribe(socket, channel: channel, after: timeline.cursors[channel]) + } + } + try timeline.apply(frame) + if let clientID = frame.body?["client_id"].string, frame.principal == principal { + pending.removeAll { $0.id == clientID } + } + if frame.type == "subscribed", let channel = frame.channel { + if frame.more == true { + try await subscribe(socket, channel: channel, after: frame.next) + } else if channel == "chat" { + caughtUp = true + if timeline.records.filter({ $0.channel == "chat" }).count < Self.chatLast { hasEarlier = false } + } else if channel == "work" { + await earlierWork() + } + } + } + } onCancel: { + socket.cancel(with: .goingAway, reason: nil) + } + } catch { + let status = (socket.response as? HTTPURLResponse)?.statusCode + if status == 401 { api.authorizationFailed() } + refused = status == 401 || status == 403 + if socket.closeCode.rawValue == 4003 { + do { let _: JSONValue = try await api.request("api/identities/me") } + catch APIError.unauthorized { refused = true } + catch { /* A transient check failure can retry with the usual backoff. */ } + } + if !Task.isCancelled { self.error = refused ? "Your access ended. Reconnect your account or check sharing permissions." : "Connection interrupted. Reconnecting…" } + } + socket.cancel(with: .goingAway, reason: nil) + connected = false + caughtUp = false + timeline.disconnected() + guard !Task.isCancelled, !refused else { return } + attempt = min(attempt + 1, 4) + do { try await Task.sleep(for: .seconds(min(15, 1 << attempt))) } + catch { return } + } + } + + /// The backlog a chat first reads: its last messages, and a page of + /// `work` (`CHANNEL_PAGE`), which holds a long turn's steps (up to 200 + /// calls and 200 steps) and, mostly, its start (`earlierWork`). + static let chatLast = 50 + static let workPage = 1_000 + /// Earlier `work` is read in batches of this many: each record at most + /// `RECORD_BODY_MAX_BYTES` (64 KiB), so a batch stays within the 4 MiB + /// an answer may be here (`FragmentAPI.request`), which the HTTP route, + /// bounding its pages by count alone, does not promise. + static let workBatch = 50 + /// Earlier `work` read back at most, a visit. + static let earlierWorkMax = 1_000 + + private func subscribe(_ socket: LiveSocket, channel: String, after: Int64?) async throws { + var body: [String: JSONValue] = ["type": .string("subscribe"), "channel": .string(channel)] + if let after { body["after"] = .number(Double(after)) } + else { body["last"] = .number(Double(channel == "work" ? Self.workPage : Self.chatLast)) } + let data = try JSONEncoder().encode(JSONValue.object(body)) + try await socket.send(.string(String(decoding: data, as: UTF8.self))) + } + + /// Earlier `work`, read back as the web's chat page reads it + /// (templates/chat/site/chat.js `earlierWork`): until it reaches the + /// oldest message shown, and, here, also while a turn was read without + /// its start (a long turn's begun before the backlog), so it shows + /// running, and Stop, however the chat was opened. What it reads only + /// says which turns run; none of it is shown. Bounded by + /// `earlierWorkMax`; a failure leaves it for the next visit, and says so. + private func earlierWork() async { + var read = 0 + while read < Self.earlierWorkMax, let oldest = timeline.oldestWork, oldest.seq > 1 { + let shownFrom = timeline.records.first { $0.channel == "chat" }?.at + guard timeline.awaitsAStart || shownFrom.map({ oldest.at >= $0 }) == true else { return } + let page: [ChannelRecord] + do { page = try await api.history(fragment.name, channel: "work", before: oldest.seq, limit: Self.workBatch) } + catch { self.error = "Earlier progress could not be read: " + error.localizedDescription; return } + for record in page { timeline.learn(record) } + read += page.count + if page.count < Self.workBatch { return } + } + } + + func send() async { + guard persistence != nil else { + error = "The local draft could not be opened. Reopen this chat before sending." + return + } + let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty || !attachments.isEmpty else { return } + guard pending.count < SavedChat.pendingMax else { error = "Wait for the messages still sending, or edit the ones not sent."; return } + let post = PendingPost(id: UUID().uuidString, text: text, attachments: attachments, replyTo: replyTo?.seq, recipients: recipients) + do { + // Persist the exact send before any request leaves the phone. + let next = SavedChat(composer: "", attachments: [], replyTo: nil, recipients: recipients, pending: pending + [post]) + try persistence?.save(next) + } catch { self.error = "The message could not be saved on this iPhone: " + error.localizedDescription; return } + pending.append(post) + composer = ""; attachments = []; replyTo = nil + await deliver(post) + } + + func retry(_ post: PendingPost) async { + guard let index = pending.firstIndex(where: { $0.id == post.id }), pending[index].failed, pending[index].refused == nil else { return } + pending[index].failed = false + await deliver(post) + } + + private func deliver(_ post: PendingPost) async { + do { + var body: [String: JSONValue] = ["text": .string(post.text), "client_id": .string(post.id)] + if !post.attachments.isEmpty { body["attachments"] = .array(post.attachments.map(\.json)) } + if let replyTo = post.replyTo { body["reply_to"] = .number(Double(replyTo)) } + if !post.recipients.isEmpty { body["to"] = .array(post.recipients.map { .string($0) }) } + let record = try await api.post(fragment.name, id: post.id, body: .object(body)) + timeline.insert(record, advancingCursor: false) + pending.removeAll { $0.id == post.id } + error = nil + } catch APIError.refused(let message), APIError.absent(let message) { + // Refused, not lost: sending it again gets the same answer, so + // it is never retried. Its words go back to an empty composer; + // beside a draft, it waits to be edited (`reopen`), as a draft + // and it joined might be more than a message holds. + guard let index = pending.firstIndex(where: { $0.id == post.id }) else { return } + pending[index].failed = true + pending[index].refused = message + reopen(pending[index]) + self.error = message + } catch { + // A matching live record also acknowledges delivery. The HTTP + // acknowledgement may have been lost after the append committed. + guard let index = pending.firstIndex(where: { $0.id == post.id }) else { return } + pending[index].failed = true + self.error = error.localizedDescription + } + } + + func stop() async { + for turn in timeline.activeTurns.prefix(16) { + do { + let record = try await api.post(fragment.name, id: "stop:\(turn)", body: .object([ + "kind": .string("stop"), "turn": .string(turn) + ])) + timeline.insert(record, advancingCursor: false) + } catch { self.error = error.localizedDescription } + } + } + + func loadEarlier() async { + guard !loadingHistory, let first = timeline.records.filter({ $0.channel == "chat" }).map(\.seq).min() else { + hasEarlier = false + return + } + loadingHistory = true + defer { loadingHistory = false } + do { + let page = try await api.history(fragment.name, before: first) + for record in page { timeline.insert(record, advancingCursor: false) } + hasEarlier = page.count == 50 && timeline.records.count < Timeline.maximumRecords + } catch { self.error = error.localizedDescription } + } +} diff --git a/ios/Sources/ChatView.swift b/ios/Sources/ChatView.swift new file mode 100644 index 000000000..bb0e45a38 --- /dev/null +++ b/ios/Sources/ChatView.swift @@ -0,0 +1,237 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ChatView: View { + @Environment(\.scenePhase) private var scenePhase + @State private var session: ChatSession + @State private var followsBottom = true + @State private var scrollingByHand = false + let library: Library + @State private var importer = false + @State private var uploading = false + @State private var voice = VoiceMemo() + + init(session: ChatSession, library: Library) { + _session = State(initialValue: session) + self.library = library + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + if session.hasEarlier && !session.timeline.records.isEmpty { + Button("Earlier messages") { Task { await session.loadEarlier() } } + .font(.footnote).frame(maxWidth: .infinity).disabled(session.loadingHistory) + } + ForEach(session.timeline.records) { record in + if record.isMessage { + VStack(alignment: .leading, spacing: 8) { + MessageView(text: record.text, isYou: record.principal == session.principal, agent: library.agent(for: record.principal)) + .contextMenu { + Button("Reply", systemImage: "arrowshape.turn.up.left") { session.replyTo = record } + Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = record.text } + ShareLink(item: record.text) { Label("Share", systemImage: "square.and.arrow.up") } + } + ForEach(attachments(record)) { attachment in + AttachmentView(attachment: attachment, api: session.api, fragment: session.fragment.name) + } + Text(Date(timeIntervalSince1970: Double(record.at) / 1000), style: .time).font(.caption2).foregroundStyle(.tertiary) + }.id(record.id) + } else if record.channel == "work" { + WorkRecordView(record: record) + } + } + ForEach(session.pending) { pending in + VStack(alignment: .trailing, spacing: 6) { + MessageView(text: pending.text, isYou: true) + if let refused = pending.refused { + Button("Not sent: \(refused) Tap to edit", systemImage: "pencil") { + session.reopen(pending) + }.font(.caption).accessibilityIdentifier("editRefused") + } else if pending.failed { + Button("Not sent. Tap to retry", systemImage: "arrow.clockwise") { + Task { await session.retry(pending) } + }.font(.caption).accessibilityIdentifier("retry") + } else { Text("Sending…").font(.caption).foregroundStyle(.secondary) } + }.frame(maxWidth: .infinity, alignment: .trailing) + } + ForEach(session.timeline.drafts.values.sorted { $0.at < $1.at }) { draft in + MessageView(text: draft.text, isYou: false, agent: library.agent(for: draft.principal), streaming: true) + } + if !session.timeline.activeTurns.isEmpty && session.timeline.drafts.isEmpty { + Label("Working…", systemImage: "ellipsis").font(.callout).foregroundStyle(.secondary) + } + Color.clear.frame(height: 1).id("bottom") + } + .frame(maxWidth: 660, alignment: .leading) + .padding(.horizontal, 20).padding(.vertical, 24).frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .accessibilityIdentifier("timeline") + .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.top, for: .alignment) + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentSize.height - geometry.visibleRect.maxY < 100 + } action: { _, nearBottom in + if scrollingByHand { followsBottom = nearBottom } + } + .onScrollPhaseChange { _, phase in + scrollingByHand = phase == .interacting || phase == .decelerating + } + .onChange(of: session.timeline.records.count + session.pending.count) { _, _ in + if followsBottom { proxy.scrollTo("bottom", anchor: .bottom) } + } + .onChange(of: session.timeline.drafts) { _, _ in + if followsBottom { proxy.scrollTo("bottom", anchor: .bottom) } + } + .overlay(alignment: .bottomTrailing) { + if !followsBottom { + Button("Latest messages", systemImage: "arrow.down") { + followsBottom = true + withAnimation { proxy.scrollTo("bottom", anchor: .bottom) } + } + .labelStyle(.iconOnly).buttonStyle(.borderedProminent).buttonBorderShape(.circle).padding() + } + } + .overlay { + if session.timeline.records.isEmpty && session.pending.isEmpty { + ContentUnavailableView("A fresh start", systemImage: "bubble.left.and.bubble.right", + description: Text("A new conversation with your agent.")) + } + } + } + .background(FragmentStyle.background) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + if let error = session.error { + Text(error).font(.caption).foregroundStyle(.secondary).padding(.horizontal, 20).padding(.top, 8) + } + composerAccessories + ComposerView(text: Binding(get: { session.composer }, set: { session.edit($0) }), working: !session.timeline.activeTurns.isEmpty, + enabled: session.connected && session.caughtUp, busy: uploading, recording: voice.recording, + attach: { importer = true }, record: { Task { await recordVoice() } }, + send: { await session.send() }, stop: { await session.stop() }, hasAttachments: !session.attachments.isEmpty) + }.background(FragmentStyle.background) + } + .fileImporter(isPresented: $importer, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): Task { await uploadFiles(urls) } + case .failure(let error): session.error = error.localizedDescription + } + } + .task(id: "\(session.caughtUp)|\(session.timeline.cursors["chat"] ?? 0)|\(scenePhase)") { + guard scenePhase == .active, session.caughtUp else { return } + do { let _: JSONValue = try await session.api.request("api/fragments/\(session.fragment.name)/seen", method: "PUT") } + catch { /* A later live cursor or foreground visit retries the read marker. */ } + } + .onDisappear { session.flushDraft() } + .onChange(of: scenePhase) { _, phase in if phase != .active { session.flushDraft() } } + .task(id: scenePhase) { + guard scenePhase == .active else { voice.cancel(); return } + await session.run() + } + .onDisappear { voice.cancel() } + } + + private var composerAccessories: some View { + VStack(alignment: .leading, spacing: 8) { + if let quoted = session.replyTo { + HStack { Label(quoted.text, systemImage: "arrowshape.turn.up.left").font(.caption).lineLimit(2); Spacer(); Button("Cancel reply", systemImage: "xmark") { session.replyTo = nil }.labelStyle(.iconOnly) } + } + if !session.attachments.isEmpty { + ScrollView(.horizontal) { HStack { + ForEach(session.attachments) { file in + Button { session.attachments.removeAll { $0.id == file.id } } label: { Label(file.name, systemImage: "xmark.circle").font(.caption) }.buttonStyle(.bordered) + } + } } + } + if let error = voice.error { Text(error).font(.caption).foregroundStyle(.secondary) } + if voice.recording { Label("Recording voice memo…", systemImage: "waveform").foregroundStyle(.red).font(.caption) } + }.padding(.horizontal, 20) + } + + private func attachments(_ record: ChannelRecord) -> [ChatAttachment] { + record.body["attachments"].array.compactMap { value in + guard let data = try? JSONEncoder().encode(value) else { return nil } + return try? JSONDecoder().decode(ChatAttachment.self, from: data) + } + } + private func uploadFiles(_ urls: [URL]) async { + guard urls.count + session.attachments.count <= 8 else { session.error = "Attach at most eight files per message."; return } + uploading = true; defer { uploading = false } + for url in urls { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size > 0, size <= ChatAttachment.bytesMax else { throw APIError.server("Files must be between 1 byte and 25 MB.") } + let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + let file = try await session.api.upload(Data(contentsOf: url), name: url.lastPathComponent, type: type, to: session.fragment.name) + if !session.attachments.contains(where: { $0.id == file.id }) { session.attachments.append(file) } + } catch { session.error = error.localizedDescription; return } + } + } + private func recordVoice() async { + if voice.recording { + if let url = voice.stop() { await uploadFiles([url]); try? FileManager.default.removeItem(at: url) } + } else { + guard session.attachments.count < 8 else { session.error = "Attach at most eight files per message."; return } + await voice.start { url in Task { await uploadFiles([url]); try? FileManager.default.removeItem(at: url) } } + } + } +} + +struct MessageView: View { + let text: String + let isYou: Bool + var agent: FragmentSummary? = nil + var streaming = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if isYou { Spacer(minLength: 44) } + VStack(alignment: .leading, spacing: 8) { + if !isYou { + HStack(spacing: 7) { + AgentAvatar(agent: agent, size: 22) + Text(agent?.displayTitle ?? "Agent").font(.caption.weight(.medium)).foregroundStyle(.secondary) + if streaming { ProgressView().controlSize(.mini).accessibilityLabel("Replying") } + } + } + if isYou { + Text(text).font(.body).lineSpacing(5).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } else { MarkdownText(text: text) } + } + .padding(isYou ? 14 : 0) + .background(isYou ? FragmentStyle.bubble : .clear, in: RoundedRectangle(cornerRadius: 20)) + if !isYou { Spacer(minLength: 8) } + } + .frame(maxWidth: .infinity, alignment: isYou ? .trailing : .leading) + .accessibilityElement(children: .combine) + } +} + +struct WorkRecordView: View { + let record: ChannelRecord + var body: some View { + if record.body["kind"].string == "turn.step" { + DisclosureGroup { + Text(record.body["args"].string ?? "").font(.caption.monospaced()).textSelection(.enabled) + Text(record.body["excerpt"].string ?? "").font(.caption).textSelection(.enabled) + } label: { + Label(record.body["tool"].string ?? "Working", systemImage: "checkmark.circle") + .font(.callout).foregroundStyle(.secondary) + } + .padding(14).background(FragmentStyle.surface, in: RoundedRectangle(cornerRadius: 14)) + } else if record.body["kind"].string == "turn.end", let error = record.body["error"].string { + Label(error, systemImage: "exclamationmark.circle").font(.callout).foregroundStyle(.secondary) + } else if record.body["kind"].string == "turn.end", record.body["status"].string == "stopped" { + Text("Response stopped").font(.footnote).foregroundStyle(.secondary) + } else if record.body["kind"].string == "turn.prompt" { + Label("\(record.text)\nOpen this chat on the web to answer this request.", systemImage: "questionmark.bubble") + .font(.callout).foregroundStyle(.secondary) + } + } +} diff --git a/ios/Sources/DeviceIdentity.swift b/ios/Sources/DeviceIdentity.swift new file mode 100644 index 000000000..9e803c942 --- /dev/null +++ b/ios/Sources/DeviceIdentity.swift @@ -0,0 +1,109 @@ +import Foundation +import CryptoKit +import Security +import P256K + +struct DeviceIdentity: Sendable { + let secret: Data + let publicKey: String + init(secret: Data) throws { + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret) + self.secret = secret + publicKey = Data(key.xonly.bytes).hex + } + init() throws { try self.init(secret: P256K.Schnorr.PrivateKey().dataRepresentation) } + + func authorization(method: String, url: URL, body: Data = Data(), at: Int64 = Int64(Date().timeIntervalSince1970)) throws -> String { + // NIP-98 binds the HTTP URL (https/http, including on a WebSocket upgrade), + // method, exact encoded body, and time. JSONSerializer emits NIP-01's array. + var target = URLComponents(url: url, resolvingAgainstBaseURL: false)! + if target.scheme == "wss" { target.scheme = "https" } + if target.scheme == "ws" { target.scheme = "http" } + guard let absolute = target.url, ["http", "https"].contains(absolute.scheme) else { throw APIError.invalidResponse } + var tags = [["u", absolute.absoluteString], ["method", method]] + if !body.isEmpty { tags.append(["payload", Data(SHA256.hash(data: body)).hex]) } + let serialized = try JSONSerialization.data(withJSONObject: [0, publicKey, at, 27235, tags, ""], options: [.withoutEscapingSlashes]) + var digest = Array(SHA256.hash(data: serialized)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret) + let signature = try key.signature(message: &digest, auxiliaryRand: nil, strict: true) + let event: [String: Any] = ["id": Data(digest).hex, "pubkey": publicKey, "created_at": at, "kind": 27235, + "tags": tags, "content": "", "sig": signature.dataRepresentation.hex] + return "Nostr " + (try JSONSerialization.data(withJSONObject: event, options: [.withoutEscapingSlashes])).base64EncodedString() + } +} + +extension Data { + var hex: String { map { String(format: "%02x", $0) }.joined() } +} + +struct DeviceAccount: Codable, Sendable { + let origin: URL + let secret: Data + var identity: String? + var email: String? + var approved: Bool + var signingKey: DeviceIdentity { get throws { try DeviceIdentity(secret: secret) } } +} + +enum DeviceKeychain { + private static func query(_ account: String, _ service: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false] + } + static func read(account: String = "active-account-v1", service: String = LaunchScope.current.keychainService) throws -> DeviceAccount? { + var request = query(account, service) + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(request as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw APIError.keychain(status) } + return try JSONDecoder().decode(DeviceAccount.self, from: data) + } + static func save(_ account: DeviceAccount, account slot: String = "active-account-v1", service: String = LaunchScope.current.keychainService) throws { + let data = try JSONEncoder().encode(account) + let attributes: [String: Any] = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] + let status = SecItemUpdate(query(slot, service) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insert = query(slot, service) + attributes.forEach { insert[$0.key] = $0.value } + let added = SecItemAdd(insert as CFDictionary, nil) + guard added == errSecSuccess else { throw APIError.keychain(added) } + } else if status != errSecSuccess { throw APIError.keychain(status) } + } + static func clear(account: String = "active-account-v1", service: String = LaunchScope.current.keychainService) throws { + let status = SecItemDelete(query(account, service) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw APIError.keychain(status) } + } +} + +extension Notification.Name { static let finiteAuthorizationFailed = Notification.Name("FiniteAuthorizationFailed") } + +// A signed request is never silently redirected with stale authorization or to +// another host. Web navigation has its own one-fragment session and policy. +final class NoSignedRedirects: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } +} + +enum PlatformOrigin { + static func parse(_ raw: String, scope: LaunchScope = .current) throws -> URL { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), url.host != nil, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil, + url.path.isEmpty || url.path == "/" else { throw APIError.server("Enter the platform’s HTTPS address, without a path.") } + var allowed = url.scheme == "https" + #if DEBUG + let host = url.host! + allowed = allowed || (url.scheme == "http" && (host == "localhost" || host == "127.0.0.1" || host == "::1" || host.hasSuffix(".localhost") || host.hasSuffix(".local"))) + #endif + guard allowed else { throw APIError.server("Use HTTPS for your Finite account.") } + guard scope.allows(url) else { throw LaunchScope.refused } + var parts = URLComponents(url: url, resolvingAgainstBaseURL: false)! + parts.path = "" + return parts.url! + } + static func same(_ a: URL, _ b: URL) -> Bool { + func scheme(_ url: URL) -> String? { switch url.scheme { case "wss": "https"; case "ws": "http"; default: url.scheme } } + return scheme(a) == scheme(b) && a.host?.lowercased() == b.host?.lowercased() && (a.port ?? (scheme(a) == "https" ? 443 : 80)) == (b.port ?? (scheme(b) == "https" ? 443 : 80)) + } +} diff --git a/ios/Sources/FragmentAPI.swift b/ios/Sources/FragmentAPI.swift new file mode 100644 index 000000000..c5bdc927e --- /dev/null +++ b/ios/Sources/FragmentAPI.swift @@ -0,0 +1,201 @@ +import Foundation + +enum APIError: LocalizedError { + /// `refused`: the server answered 4xx (but a timeout or a rate limit), a + /// final answer: the same request again gets it again. + /// `absent`: a 404, which `answer` says apart only to `optional` readers. + case invalidResponse, limitExceeded, server(String), refused(String), absent(String), unauthorized, keychain(Int32) + var errorDescription: String? { + switch self { + case .unauthorized: "This iPhone is not authorized. Connect your account again." + case .keychain(let status): "The iPhone key could not be accessed (Keychain \(status))." + case .invalidResponse: "Finite sent an unreadable response." + case .limitExceeded: "This response is larger than the app can display." + case .server(let message), .refused(let message), .absent(let message): message + } + } + /// A non-2xx answer as an error, with the server's own message. + static func answer(_ data: Data, status: Int) -> APIError { + let message = (try? JSONDecoder().decode(JSONValue.self, from: data))?["message"].string ?? "Finite returned HTTP \(status)." + return (400..<500).contains(status) && status != 408 && status != 429 ? .refused(message) : .server(message) + } +} + +/// A live socket a chat or the list watcher reads: a URLSessionWebSocketTask, +/// or a test's scripted one (URLProtocol never sees WebSocket tasks). +protocol LiveSocket: AnyObject, Sendable { + var response: URLResponse? { get } + var closeCode: URLSessionWebSocketTask.CloseCode { get } + func resume() + func send(_ message: URLSessionWebSocketTask.Message) async throws + func receive() async throws -> URLSessionWebSocketTask.Message + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) +} + +extension URLSessionWebSocketTask: LiveSocket {} + +/// Every request signs with this iPhone's approved key (NIP-98), to its own +/// platform origin only. +struct FragmentAPI: Sendable { + typealias Sockets = @Sendable (URLRequest) -> LiveSocket + let baseURL: URL + let session: URLSession + let identity: DeviceIdentity + let accountID: String? + private let sockets: Sockets? + init(baseURL: URL, identity: DeviceIdentity, accountID: String? = nil, session: URLSession? = nil, sockets: Sockets? = nil) { + self.baseURL = baseURL + self.identity = identity + self.accountID = accountID + self.sockets = sockets + if let session { self.session = session } + else { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: configuration, delegate: NoSignedRedirects(), delegateQueue: nil) + } + } + func authorized(_ original: URLRequest) throws -> URLRequest { + guard let url = original.url, PlatformOrigin.same(baseURL, url), url.user == nil, url.password == nil, + url.fragment == nil, original.httpBodyStream == nil else { throw APIError.server("The request does not belong to your Finite server.") } + guard LaunchScope.current.allows(url) else { throw LaunchScope.refused } + var request = original + request.setValue(try identity.authorization(method: request.httpMethod ?? "GET", url: url, body: request.httpBody ?? Data()), forHTTPHeaderField: "Authorization") + request.setValue("Finite-iOS/0.3", forHTTPHeaderField: "User-Agent") + return request + } + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let result = try await session.data(for: authorized(request)) + if (result.1 as? HTTPURLResponse)?.statusCode == 401 { + authorizationFailed() + throw APIError.unauthorized + } + return result + } + func authorizationFailed() { + NotificationCenter.default.post(name: .finiteAuthorizationFailed, object: nil, + userInfo: ["origin": baseURL.absoluteString, "key": identity.publicKey]) + } + + func url(_ path: String) -> URL { baseURL.appending(path: path) } + + func request(_ path: String, method: String = "GET", body: JSONValue? = nil, + query: [URLQueryItem] = [], timeout: TimeInterval = 30) async throws -> T { + var components = URLComponents(url: url(path), resolvingAgainstBaseURL: false)! + components.queryItems = query.isEmpty ? nil : query + var request = URLRequest(url: components.url!) + request.httpMethod = method + request.timeoutInterval = timeout + if let body { + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let (data, response) = try await self.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let refusal = APIError.answer(data, status: http.statusCode) + if http.statusCode == 404, case .refused(let message) = refusal { throw APIError.absent(message) } + throw refusal + } + guard data.count <= 4_194_304 else { throw APIError.limitExceeded } + return try JSONDecoder().decode(T.self, from: data) + } + + /// A read of what may be absent: `nil` for a 404, and else as `request`. + func optional(_ type: T.Type, _ path: String, query: [URLQueryItem] = []) async throws -> T? { + do { return try await request(path, query: query) as T } + catch APIError.absent { return nil } + } + + func fragments() async throws -> [FragmentSummary] { + struct Response: Decodable { let fragments: [FragmentSummary] } + let response: Response = try await request("api/fragments") + return response.fragments + } + + // Search is only a hint: the complete prompt, including words beyond the + // API's eight-word / 256-byte query limit, remains the new chat's message. + static func searchHint(_ text: String) -> String { + let words = text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).prefix(8) + var hint = "" + for character in words.joined(separator: " ") { + guard hint.utf8.count + String(character).utf8.count <= 256 else { break } + hint.append(character) + } + return hint + } + + func search(_ text: String) async throws -> SearchResults { + let hint = Self.searchHint(text) + guard !hint.isEmpty else { return SearchResults() } + return try await request("api/search", query: [URLQueryItem(name: "q", value: hint)]) + } + + func createChat() async throws -> FragmentSummary { + struct Created: Decodable { let name: String } + let created: Created = try await request("api/fragments", method: "POST", body: .object([ + "label": .string("mobile-chat"), "template": .string("chat"), "title": .string("New chat") + ])) + return FragmentSummary(name: created.name, kind: "chat", title: "New chat") + } + + func join(_ agent: FragmentSummary, to chat: FragmentSummary) async throws { + struct Status: Decodable { let npub: String } + let status: Status = try await request("api/f/\(agent.name)/status") + let _: JSONValue = try await request("api/f/\(chat.name)/members/\(status.npub)", + method: "PUT", body: .object(["role": .string("editor")])) + } + + /// A chat's or an app's name, in its manifest. An agent's name is its + /// job's too, which only the shell and the CLI rewrite today. + func rename(_ fragment: FragmentSummary, title: String) async throws { + precondition(fragment.kind != "agent", "an agent is renamed where its job is rewritten with it") + let clean = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { throw APIError.server("Enter a name.") } + let manifest: JSONValue = try await request("api/f/\(fragment.name)/manifest") + guard case .object(var fields) = manifest else { throw APIError.invalidResponse } + var meta: [String: JSONValue] = [:] + if case .object(let existing) = fields["meta"] { meta = existing } + meta["title"] = .string(clean); fields["meta"] = .object(meta) + let text = String(decoding: try JSONEncoder().encode(JSONValue.object(fields)), as: UTF8.self) + let _: JSONValue = try await request("api/f/\(fragment.name)/files", method: "POST", body: .object([ + "message": .string("Rename from Finite for iOS"), "files": .array([.object(["path": .string("fragment.json"), "text": .string(text)])]) + ])) + let _: JSONValue = try await request("api/f/\(fragment.name)/deploy", method: "POST", body: .object([:])) + } + + func post(_ fragment: String, id: String, body: JSONValue) async throws -> ChannelRecord { + struct Posted: Decodable { let record: ChannelRecord } + let result: Posted = try await request("api/f/\(fragment)/channels/chat", method: "POST", + body: .object(["id": .string(id), "body": body])) + return result.record + } + + /// A page of a channel's records just below `before` (at most + /// `CHANNEL_PAGE`, 1000, and a MiB: docs/api.md). + func history(_ fragment: String, channel: String = "chat", before: Int64, limit: Int = 50) async throws -> [ChannelRecord] { + struct Page: Decodable { let records: [ChannelRecord] } + var components = URLComponents(url: url("api/f/\(fragment)/channels/\(channel)"), resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "before", value: String(before)), URLQueryItem(name: "limit", value: String(limit))] + let (data, response) = try await self.data(for: URLRequest(url: components.url!)) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard http.statusCode == 200 else { throw APIError.answer(data, status: http.statusCode) } + guard data.count <= 4_194_304 else { throw APIError.limitExceeded } + return try JSONDecoder().decode(Page.self, from: data).records + } + + func socket(_ fragment: String) throws -> LiveSocket { try webSocket("f/\(fragment)/__live") } + func webSocket(_ path: String) throws -> LiveSocket { + var components = URLComponents(url: url(path), resolvingAgainstBaseURL: false)! + components.scheme = baseURL.scheme == "https" ? "wss" : "ws" + let request = try authorized(URLRequest(url: components.url!)) + if let sockets { return sockets(request) } + let socket = session.webSocketTask(with: request) + socket.maximumMessageSize = 1_048_576 + return socket + } +} diff --git a/ios/Sources/FragmentApp.swift b/ios/Sources/FragmentApp.swift new file mode 100644 index 000000000..9109f0a95 --- /dev/null +++ b/ios/Sources/FragmentApp.swift @@ -0,0 +1,274 @@ +import SwiftUI + +@main +struct FragmentApp: App { + init() { LaunchScope.start() } + var body: some Scene { WindowGroup { FragmentRoot() } } +} + +@MainActor @Observable +final class Library { + var fragments: [FragmentSummary] = [] + var error: String? + var loading = false + var creating = false + var unfinishedChat: FragmentSummary? + var selectedAgent: String? + var imported: [ImportedSession] = [] + var identities: [String: String] = [:] + var sessions: [String: ChatSession] = [:] + @ObservationIgnored private var loadID = UUID() + + var chats: [FragmentSummary] { fragments.filter { $0.kind == "chat" && $0.archived != true }.sorted { ($0.previewAt ?? 0) > ($1.previewAt ?? 0) } } + var allApps: [FragmentSummary] { fragments.filter { ["app", "brain"].contains($0.kind) && $0.name != "add-app" } } + var apps: [FragmentSummary] { allApps.filter { $0.archived != true } } + var shownApps: [FragmentSummary] { apps.filter { $0.dock != false } } + var agents: [FragmentSummary] { fragments.filter { $0.kind == "agent" && $0.archived != true } } + var agent: FragmentSummary? { agents.first { $0.name == selectedAgent } ?? agents.first } + func agent(for identity: String) -> FragmentSummary? { agents.first { identities[$0.name] == identity } } + func agents(in chat: FragmentSummary) -> [FragmentSummary] { (chat.agents ?? []).compactMap { agent(for: $0) } } + func session(for chat: FragmentSummary, api: FragmentAPI) throws -> ChatSession { + if let session = sessions[chat.name] { return session } + // A bounded cache preserves unsent messages and cursors while changing screens. + if sessions.count >= 32, let unused = sessions.first(where: { $0.value.pending.isEmpty && $0.value.composer.isEmpty && $0.value.attachments.isEmpty })?.key { + sessions.removeValue(forKey: unused) + } + guard sessions.count < 32 else { throw APIError.server("Finish or discard a draft in another chat before opening more conversations.") } + let session = ChatSession(fragment: chat, api: api) + sessions[chat.name] = session + return session + } + func load(_ api: FragmentAPI) async { + let requestID = UUID(); loadID = requestID + loading = true + defer { if loadID == requestID { loading = false } } + do { + let response = try await api.fragments() + guard loadID == requestID, !Task.isCancelled else { return } + fragments = response + for index in fragments.indices { + if let picture = fragments[index].picture { fragments[index].picture = URL(string: picture, relativeTo: api.baseURL)?.absoluteString } + } + for agent in agents.prefix(32) where identities[agent.name] == nil { + let status: FragmentStatus = try await api.request("api/f/\(agent.name)/status") + identities[agent.name] = status.npub + } + if loadID == requestID { error = nil } + } catch { if loadID == requestID && !Task.isCancelled { self.error = error.localizedDescription } } + await loadImported(api, requestID) + } + /// The person's imported chats: their session archive's `index/chat.json` + /// (templates/sessions/README.md), apart from the rest of the list. An + /// archive of other kinds alone (cron, subagents) has no such index: + /// none, as the web's History reads it. A read that fails keeps what was + /// read before, and nothing else waits on it. + private func loadImported(_ api: FragmentAPI, _ requestID: UUID) async { + struct Index: Decodable { let sessions: [ImportedSession] } + guard let archive = fragments.first(where: { $0.kind == "sessions" && $0.role == "owner" }) else { + if loadID == requestID { imported = [] } + return + } + let index: Index? + do { index = try await api.optional(Index.self, "api/f/\(archive.name)/file", query: [URLQueryItem(name: "path", value: "index/chat.json")]) } + catch { return } + guard loadID == requestID, !Task.isCancelled else { return } + imported = Array((index?.sessions ?? []).prefix(2_000)) + } + func watch(_ api: FragmentAPI) async { + var attempt = 0 + // One foreground watcher. Cancellation closes an outstanding receive; + // reconnect reloads the list so changes during a disconnect are included. + while !Task.isCancelled { + let socket: LiveSocket + do { socket = try api.webSocket("api/fragments/watch") } + catch { self.error = error.localizedDescription; return } + socket.resume() + do { + try await withTaskCancellationHandler { + while !Task.isCancelled { + let message = try await socket.receive() + let data: Data + switch message { + case .data(let bytes): data = bytes + case .string(let text): data = Data(text.utf8) + @unknown default: throw APIError.invalidResponse + } + let frame = try JSONDecoder().decode(JSONValue.self, from: data) + if ["hello", "changed", "invited"].contains(frame["type"].string ?? "") { + attempt = 0 + await load(api) + } + } + } onCancel: { socket.cancel(with: .goingAway, reason: nil) } + } catch { + let status = (socket.response as? HTTPURLResponse)?.statusCode + if status == 401 { api.authorizationFailed() } + if status == 401 || status == 403 { socket.cancel(with: .goingAway, reason: nil); return } + } + socket.cancel(with: .goingAway, reason: nil) + guard !Task.isCancelled else { return } + attempt = min(attempt + 1, 4) + do { try await Task.sleep(for: .seconds(min(15, 1 << attempt))) } catch { return } + } + } + func archive(_ fragment: FragmentSummary, api: FragmentAPI) async { + do { + let _: JSONValue = try await api.request("api/fragments/\(fragment.name)/archived", method: "PUT", body: .object(["archived": .bool(fragment.archived != true)])) + await load(api) + } catch { self.error = error.localizedDescription } + } +} + +struct FragmentStatus: Decodable { struct URLs: Decodable { let canonical: URL }; let npub: String; let urls: URLs } +enum ShellPage: Equatable { case launcher, chat(String), history, settings, apps } + +struct FragmentRoot: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var library = Library() + @State private var connection = AccountConnection() + @State private var showingConnection = false + @State private var page = ShellPage.launcher + @State private var sidebar = false + @State private var showingApps = false + @State private var openedApp: FragmentSummary? + @State private var homeDraft = "" + + var body: some View { + Group { + if let api = connection.api { shell(api).environment(\.fragmentAPI, api) } + else { AccountConnectionView(connection: connection) } + } + .tint(.primary) + .sheet(isPresented: $showingConnection) { AccountConnectionView(connection: connection, close: { showingConnection = false }) } + .onChange(of: connection.account?.identity) { _, _ in resetNavigation() } + .onChange(of: connection.account?.approved) { _, _ in resetNavigation() } + .onReceive(NotificationCenter.default.publisher(for: .finiteAuthorizationFailed).receive(on: RunLoop.main)) { notification in + guard let api = connection.api, notification.userInfo?["origin"] as? String == api.baseURL.absoluteString, + notification.userInfo?["key"] as? String == api.identity.publicKey else { return } + Task { await connection.validate() } + } + .task(id: scenePhase) { if scenePhase == .active { await connection.validate() } } + } + private func resetNavigation() { + library = Library(); page = .launcher; sidebar = false; showingApps = false; openedApp = nil + } + private func shell(_ api: FragmentAPI) -> some View { + ZStack(alignment: .leading) { + Group { + if showingApps { + NavigationStack { + AppsLibraryView(library: library, api: api, open: { openedApp = $0 }) + .navigationTitle("Your Apps").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Show sidebar", systemImage: "sidebar.left") { toggleSidebar() }.accessibilityIdentifier("sidebar") + } + } + } + } else { + NavigationStack { + content(api) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Show sidebar", systemImage: "sidebar.left") { toggleSidebar() }.accessibilityIdentifier("sidebar") + } + ToolbarItemGroup(placement: .topBarTrailing) { + if page != .launcher && page != .apps { + Button("Search or start a new chat", systemImage: "square.and.pencil") { page = .launcher }.accessibilityIdentifier("launcher") + } + if page != .apps { + Button("Your Apps", systemImage: "list.bullet.rectangle") { showingApps = true }.accessibilityIdentifier("yourApps") + } + } + } + } + } + } + .accessibilityHidden(sidebar) + SidebarDrawer(isPresented: $sidebar, library: library, page: page, api: api) { destination in + page = destination; showingApps = false; toggleSidebar() + } + } + .sheet(item: $openedApp) { app in + if app.name == "add-app" { + AppCreationSheet { prompt in + openedApp = nil; showingApps = false; homeDraft = prompt; page = .launcher + } + } else { FragmentWebSheet(fragment: app, api: api) } + } + .task(id: "\(api.baseURL)|\(api.identity.publicKey)|\(scenePhase)") { + if scenePhase == .active { await library.load(api); await library.watch(api) } + } + .onChange(of: showingApps) { _, presented in + if presented { dismissKeyboard() } + } + } + @ViewBuilder private func content(_ api: FragmentAPI) -> some View { + switch page { + case .launcher: + HomeView(library: library, api: api, draft: $homeDraft, open: { open($0) }, start: { await start(api) }, refresh: { await library.load(api) }) + case .chat(let name): + if let chat = library.fragments.first(where: { $0.name == name }) { + if let session = try? library.session(for: chat, api: api) { + ChatView(session: session, library: library).id(chat.name) + } else { + ContentUnavailableView("Your drafts are safe", systemImage: "square.and.pencil", description: Text("Finish or discard a draft in another chat before opening more conversations.")) + } + } + case .history: HistoryView(library: library, api: api, open: { open($0) }) + case .apps: AppsLibraryView(library: library, api: api, open: { openedApp = $0 }) + case .settings: SettingsView(library: library, api: api, connect: { showingConnection = true }, open: { open($0) }) + } + } + private var title: String { + if showingApps { return "Your Apps" } + switch page { + case .launcher: return "Finite.Computer" + case .chat(let name): return library.fragments.first { $0.name == name }?.displayTitle ?? "Chat" + case .history: return "History" + case .apps: return "Your Apps" + case .settings: return "Settings" + } + } + private func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + private func toggleSidebar() { + if !sidebar { dismissKeyboard() } + withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { sidebar.toggle() } + } + private func open(_ fragment: FragmentSummary) { + if fragment.kind == "chat" { page = .chat(fragment.name); showingApps = false } + else if fragment.kind == "launcher" { page = .launcher } + else { openedApp = fragment } + } + private func start(_ api: FragmentAPI) async { + guard let agent = library.agent, !library.creating else { return } + library.creating = true + defer { library.creating = false } + do { + if library.unfinishedChat == nil { library.unfinishedChat = try await api.createChat() } + guard let chat = library.unfinishedChat else { return } + try await api.join(agent, to: chat) + library.unfinishedChat = nil + await library.load(api) + let resolved = library.fragments.first { $0.name == chat.name } ?? chat + let session = try library.session(for: resolved, api: api) + session.edit(homeDraft) + homeDraft = "" + page = .chat(chat.name) + await session.send() + await library.load(api) + } catch { library.error = error.localizedDescription } + } +} + +enum FragmentStyle { + static let background = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(white: 24 / 255, alpha: 1) : UIColor(red: 247 / 255, green: 246 / 255, blue: 243 / 255, alpha: 1) }) + static let surface = Color(uiColor: .secondarySystemGroupedBackground) + static let bubble = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(white: 57 / 255, alpha: 1) : UIColor(red: 233 / 255, green: 230 / 255, blue: 223 / 255, alpha: 1) }) + static let lilac = Color(red: 168 / 255, green: 139 / 255, blue: 234 / 255) +} diff --git a/ios/Sources/FragmentWebSheet.swift b/ios/Sources/FragmentWebSheet.swift new file mode 100644 index 000000000..4eb79a440 --- /dev/null +++ b/ios/Sources/FragmentWebSheet.swift @@ -0,0 +1,147 @@ +import SwiftUI +import WebKit + +/// What a web sheet shows: a fragment's one-use `__signin` URL, asked of +/// `POST /api/f/{name}/session` (docs/ios.md), or why not. A redemption is +/// spent once its page loads: each load drops the last before asking for a +/// new one, so no web view ever opens a spent one. +@MainActor @Observable +final class WebSheetModel { + let fragment: FragmentSummary + let api: FragmentAPI + private(set) var url: URL? + var error: String? + var loading = true + private(set) var needsConsent = false + private(set) var consentEmail: String? + init(fragment: FragmentSummary, api: FragmentAPI) { self.fragment = fragment; self.api = api } + + func load(consent: Bool = false) async { + url = nil; error = nil; needsConsent = false + loading = true + do { + struct Status: Decodable { struct URLs: Decodable { let canonical: URL }; let urls: URLs } + let status: Status = try await api.request("api/f/\(fragment.name)/status") + guard ["http", "https"].contains(status.urls.canonical.scheme), status.urls.canonical.host != nil, status.urls.canonical.user == nil, status.urls.canonical.password == nil else { + throw APIError.invalidResponse + } + var origin = URLComponents(url: status.urls.canonical, resolvingAgainstBaseURL: false)! + origin.path = ""; origin.query = nil; origin.fragment = nil + _ = try PlatformOrigin.parse(origin.url!.absoluteString) + var components = URLComponents(url: status.urls.canonical, resolvingAgainstBaseURL: false) + if let key = fragment.sessionKey { components?.queryItems = [URLQueryItem(name: "s", value: key)] } + guard let destination = components?.url else { throw APIError.invalidResponse } + struct Session: Decodable { let consentRequired: Bool; var url: URL?; var email: String? } + let back = components!.percentEncodedPath + (components!.percentEncodedQuery.map { "?" + $0 } ?? "") + let session: Session = try await api.request("api/f/\(fragment.name)/session", method: "POST", body: .object([ + "returnTo": .string(back.isEmpty ? "/" : back), "consent": .bool(consent) + ])) + if session.consentRequired { consentEmail = session.email; needsConsent = true; loading = false; return } + guard let redemption = session.url, PlatformOrigin.same(redemption, destination), + redemption.user == nil, redemption.password == nil, redemption.fragment == nil, + redemption.path.hasSuffix("/__signin") else { throw APIError.invalidResponse } + url = redemption + } catch { self.error = error.localizedDescription } + } +} + +struct FragmentWebSheet: View { + @Environment(\.dismiss) private var dismiss + let fragment: FragmentSummary + @State private var model: WebSheetModel + init(fragment: FragmentSummary, api: FragmentAPI) { + self.fragment = fragment + _model = State(initialValue: WebSheetModel(fragment: fragment, api: api)) + } + + var body: some View { + @Bindable var model = model + NavigationStack { + Group { + if model.needsConsent { + ContentUnavailableView { + Label("Open \(fragment.displayTitle)?", systemImage: "person.crop.circle.badge.checkmark") + } description: { + Text("Continue as \(model.consentEmail ?? "your account")? This app will be able to recognize you.") + } actions: { + Button("Continue") { Task { await model.load(consent: true) } } + Button("Cancel", role: .cancel) { dismiss() } + } + } else if let error = model.error { + ContentUnavailableView { + Label("Couldn’t open this app", systemImage: "wifi.exclamationmark") + } description: { Text(error) } actions: { + Button("Try again") { Task { await model.load() } } + } + } else if let url = model.url { + // a new redemption is a new web view: one never reloads a spent one + WebContent(url: url, loading: $model.loading, error: $model.error) + .id(url) + .overlay { if model.loading { ProgressView() } } + } else { ProgressView() } + } + .navigationTitle(fragment.displayTitle).navigationBarTitleDisplayMode(.inline) + .task { await model.load() } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .accessibilityAction(.escape) { dismiss() } + } +} + +private struct WebContent: UIViewRepresentable { + let url: URL + @Binding var loading: Bool + @Binding var error: String? + + func makeCoordinator() -> Coordinator { Coordinator(loading: $loading, error: $error, origin: url) } + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + // Each sheet has its own web storage. Native credentials never enter a + // fragment page; connected accounts use a scoped redemption (docs/ios.md). + configuration.websiteDataStore = .nonPersistent() + let web = WKWebView(frame: .zero, configuration: configuration) + web.navigationDelegate = context.coordinator + web.allowsBackForwardNavigationGestures = true + web.load(URLRequest(url: url)) + return web + } + func updateUIView(_ uiView: WKWebView, context: Context) {} + + @MainActor final class Coordinator: NSObject, WKNavigationDelegate { + @Binding var loading: Bool + @Binding var error: String? + let origin: URL + init(loading: Binding, error: Binding, origin: URL) { + _loading = loading; _error = error; self.origin = origin + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { loading = false } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + self.error = error.localizedDescription; loading = false + } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + self.error = error.localizedDescription; loading = false + } + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { + if navigationResponse.isForMainFrame, let response = navigationResponse.response as? HTTPURLResponse, response.statusCode >= 400 { + error = "The app could not be opened (HTTP \(response.statusCode)). Check your access and try again." + loading = false + return .cancel + } + return .allow + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + guard let url = navigationAction.request.url else { return .cancel } + guard ["http", "https", "about"].contains(url.scheme) else { return .cancel } + if url.scheme != "about", (url.host != origin.host || url.port != origin.port || url.scheme != origin.scheme) { + if navigationAction.navigationType == .linkActivated { _ = await UIApplication.shared.open(url) } + return .cancel + } + if navigationAction.targetFrame == nil, navigationAction.navigationType == .linkActivated { + webView.load(URLRequest(url: url)) + return .cancel + } + return .allow + } + } +} diff --git a/ios/Sources/HomeView.swift b/ios/Sources/HomeView.swift new file mode 100644 index 000000000..c515b5127 --- /dev/null +++ b/ios/Sources/HomeView.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct HomeView: View { + let library: Library + let api: FragmentAPI + @Binding var draft: String + let open: (FragmentSummary) -> Void + let start: () async -> Void + let refresh: () async -> Void + @State private var results = SearchResults() + @State private var searching = false + @State private var searchError: String? + @FocusState private var focused: Bool + private var query: String { draft.trimmingCharacters(in: .whitespacesAndNewlines) } + private var canStart: Bool { !query.isEmpty && query.utf8.count <= 32_768 && library.agent != nil && !library.creating } + + var body: some View { + ZStack { + DesktopWallpaper() + VStack(spacing: 0) { + Spacer(minLength: 24) + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Ask anything, or find a chat or app…", text: $draft) + .focused($focused).submitLabel(.go).accessibilityIdentifier("launcherInput") + .disabled(library.creating).onSubmit(launch) + if !draft.isEmpty { + Button("Clear search", systemImage: "xmark.circle.fill") { draft = "" } + .labelStyle(.iconOnly).foregroundStyle(.secondary).disabled(library.creating) + } + Button(action: launch) { + if library.creating { ProgressView().frame(width: 36, height: 36) } + else { Image(systemName: "arrow.up").frame(width: 36, height: 36) } + }.buttonStyle(.glassProminent).buttonBorderShape(.circle) + .disabled(!canStart).accessibilityLabel("Start new chat").accessibilityIdentifier("startChat") + }.padding(16) + if let agent = library.agent { + HStack { + Menu { + ForEach(library.agents) { agent in + Button { library.selectedAgent = agent.name } label: { + Label(agent.displayTitle, systemImage: agent.name == library.agent?.name ? "checkmark" : "person") + } + } + } label: { + HStack(spacing: 8) { AgentAvatar(agent: agent, size: 22); Text(agent.displayTitle); Image(systemName: "chevron.down").font(.caption2) } + .font(.caption.weight(.medium)) + }.accessibilityIdentifier("launcherAgent") + Spacer() + }.padding(.horizontal, 18).padding(.bottom, 14) + } + if !query.isEmpty { + Divider() + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + if library.agent != nil { + Button(action: launch) { + Label { + VStack(alignment: .leading, spacing: 5) { + Text("Start a new chat with \(library.agent?.displayTitle ?? "your agent")").font(.subheadline.weight(.medium)) + Text(query).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } icon: { Image(systemName: "square.and.pencil").frame(width: 30) } + .frame(maxWidth: .infinity, alignment: .leading).padding(12) + }.disabled(!canStart).accessibilityIdentifier("newChatResult") + } + foundFragments("Chats", kinds: ["chat"]) + foundFragments("Apps", kinds: ["app", "brain"]) + if !results.messages.isEmpty { + heading("Messages") + ForEach(results.messages) { hit in + if let fragment = library.fragments.first(where: { $0.name == hit.fragment }) { + Button { focused = false; open(fragment) } label: { + VStack(alignment: .leading, spacing: 5) { + Text(fragment.displayTitle).font(.subheadline.weight(.medium)) + Text(hit.snippet).font(.caption).foregroundStyle(.secondary).lineLimit(3) + }.frame(maxWidth: .infinity, alignment: .leading).padding(12) + }.accessibilityIdentifier("messageResult-\(hit.id)") + } + } + } + if searching { ProgressView("Searching…").padding() } + if let searchError { Text(searchError).font(.footnote).foregroundStyle(.secondary).padding() } + }.padding(6).buttonStyle(.plain) + }.frame(maxHeight: 330).scrollDismissesKeyboard(.interactively) + } + if library.agent == nil && !library.loading { + VStack(spacing: 8) { + Text("Your account has no agent yet.").foregroundStyle(.secondary) + Link("Set up your agent on the web", destination: api.baseURL.appending(path: "welcome")) + }.font(.footnote).padding() + } + if let error = library.error { + Text(error).font(.footnote).foregroundStyle(.secondary).padding() + Button("Try again") { Task { await refresh() } }.padding(.bottom) + } + } + .glassEffect(.regular, in: .rect(cornerRadius: 26)) + .frame(maxWidth: 620).padding(.horizontal, 16) + Spacer(minLength: 24) + } + } + .task(id: query) { + results = SearchResults(); searchError = nil; searching = false + guard !query.isEmpty else { return } + let asked = query + do { + try await Task.sleep(for: .milliseconds(250)); searching = true + let found = try await api.search(asked) + try Task.checkCancellation() + guard asked == query else { return } + results = found; searching = false + } catch { guard !Task.isCancelled, asked == query else { return }; searchError = error.localizedDescription; searching = false } + } + } + private func heading(_ text: String) -> some View { Text(text).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.horizontal, 12).padding(.top, 10) } + @ViewBuilder private func foundFragments(_ title: String, kinds: Set) -> some View { + let found = results.fragments.filter { kinds.contains($0.kind) } + if !found.isEmpty { + heading(title) + ForEach(found) { fragment in + Button { focused = false; open(fragment) } label: { + HStack(spacing: 12) { + if fragment.kind == "chat" { AgentAvatar(agent: library.agents(in: fragment).first, size: 28) } + else { Image(systemName: "app").frame(width: 28) } + VStack(alignment: .leading, spacing: 4) { + Text(fragment.displayTitle).font(.subheadline.weight(.medium)) + if let preview = fragment.preview { Text(preview).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + } + Spacer(minLength: 0) + if fragment.archived == true { Image(systemName: "archivebox").font(.caption).foregroundStyle(.secondary) } + }.padding(12) + }.accessibilityIdentifier("fragmentResult-\(fragment.name)") + } + } + } + private func launch() { guard canStart else { return }; focused = false; Task { await start() } } +} + +struct ComposerView: View { + @Binding var text: String + let working: Bool + let enabled: Bool + var busy = false + var recording = false + let attach: () -> Void + let record: () -> Void + let send: () async -> Void + let stop: () async -> Void + @FocusState private var focused: Bool + @State private var submitting = false + var hasAttachments = false + private var valid: Bool { (!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || hasAttachments) && text.utf8.count <= 32_768 } + private var canSend: Bool { !submitting && !busy && !recording && enabled && (working || valid) } + var body: some View { + // A single glass surface, with equal hit targets and no nested glass + // button padding, keeps the single-line and growing field aligned. + HStack(alignment: .bottom, spacing: 0) { + Button(action: attach) { + Image(systemName: "plus").font(.system(size: 20)) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .accessibilityLabel("Attach files").disabled(busy || recording) + Button(action: record) { + Image(systemName: recording ? "stop.circle.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(recording ? Color.red : Color.primary) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .accessibilityLabel(recording ? "Finish voice memo" : "Record a voice memo") + .disabled(busy) + TextField("Message your agent", text: $text, axis: .vertical) + .font(.body).lineLimit(1...6) + .padding(.vertical, 10).padding(.horizontal, 6) + .frame(minHeight: 44).focused($focused).accessibilityIdentifier("composer") + Button { + submitting = true + Task { await (working ? stop() : send()); submitting = false } + } label: { + ZStack { + // The adaptive label color keeps the filled control high-contrast; + // the glass foreground style is softened by vibrancy. + Circle().fill(canSend ? Color(uiColor: .label) : Color(uiColor: .tertiarySystemFill)) + if busy || submitting { + ProgressView().tint(.secondary) + } else { + Image(systemName: working ? "stop.fill" : "arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canSend ? Color(uiColor: .systemBackground) : Color.secondary) + } + } + .frame(width: 34, height: 34) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .disabled(!canSend) + .accessibilityLabel(working ? "Stop response" : "Send message") + .accessibilityIdentifier(working ? "stop" : "send") + } + .buttonStyle(.plain) + .padding(6) + .glassEffect(.regular, in: .rect(cornerRadius: 28)) + .frame(maxWidth: 660) + .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 8) + .frame(maxWidth: .infinity) + } +} diff --git a/ios/Sources/LaunchScope.swift b/ios/Sources/LaunchScope.swift new file mode 100644 index 000000000..c5e2b3ce1 --- /dev/null +++ b/ios/Sources/LaunchScope.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Where this launch keeps its account and drafts, and which servers it may +/// reach. A UI test's launch (`--ui-test`, Debug only) gets a store of its +/// own, a Keychain service and an Application Support folder emptied as it +/// starts, and reaches loopback alone (this machine's `cargo xtask dev`): a +/// phone's own account is never read, and no other server is ever asked. +struct LaunchScope: Sendable { + let keychainService: String + let supportFolder: String + let loopbackOnly: Bool + + static let app = LaunchScope(keychainService: "build.finite.fragment.device", supportFolder: "Finite", loopbackOnly: false) + static let uiTest = LaunchScope(keychainService: "build.finite.fragment.device.ui-test", supportFolder: "Finite-UITest", loopbackOnly: true) + static let current: LaunchScope = { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-test") { return .uiTest } + #endif + return .app + }() + + /// Whether this launch may send anything to `url`'s host. + func allows(_ url: URL) -> Bool { + guard loopbackOnly else { return true } + guard let host = url.host?.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host.hasSuffix(".localhost") + } + + static let refused = APIError.server("This launch reaches only this machine.") + + /// Before anything reads the store: a UI test's starts empty, or holding + /// only the account its `--ui-test-saved-account ` names + /// (approved, with a fresh key), so a test can show that one past + /// loopback is never used. + static func start() { + #if DEBUG + guard current.loopbackOnly else { return } + try? DeviceKeychain.clear() + if let support = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { + try? FileManager.default.removeItem(at: support.appending(path: current.supportFolder, directoryHint: .isDirectory)) + } + let args = ProcessInfo.processInfo.arguments + if let i = args.firstIndex(of: "--ui-test-saved-account"), args.indices.contains(i + 1), let origin = URL(string: args[i + 1]), + let key = try? DeviceIdentity() { + try? DeviceKeychain.save(DeviceAccount(origin: origin, secret: key.secret, identity: "npub1uitest", email: "ui-test@example.com", approved: true)) + } + #endif + } +} diff --git a/ios/Sources/MarkdownText.swift b/ios/Sources/MarkdownText.swift new file mode 100644 index 000000000..60463b28e --- /dev/null +++ b/ios/Sources/MarkdownText.swift @@ -0,0 +1,82 @@ +import SwiftUI + +// A small native renderer for the chat slice: paragraphs, headings, lists, +// quotes and fenced code. Inline formatting and links are Apple's Markdown. +// Blocks have positional identities so a growing final block stays in place. +struct MarkdownBlock: Identifiable, Equatable { + enum Kind: Equatable { case paragraph, heading, code(String), quote } + let id: Int + let kind: Kind + let text: String + + static func parse(_ source: String) -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + var lines: [String] = [] + var code: String? + func append(_ kind: Kind) { + guard !lines.isEmpty else { return } + blocks.append(MarkdownBlock(id: blocks.count, kind: kind, text: lines.joined(separator: "\n"))) + lines = [] + } + for line in source.components(separatedBy: "\n") { + if line.hasPrefix("```") { + if let language = code { append(.code(language)); code = nil } + else { append(.paragraph); code = String(line.dropFirst(3)) } + } else if code != nil { lines.append(line) } + else if line.trimmingCharacters(in: .whitespaces).isEmpty { append(.paragraph) } + else if line.hasPrefix("#") { + let heading = line.drop(while: { $0 == "#" }) + if heading.first == " " { + append(.paragraph) + lines = [String(heading.dropFirst())] + append(.heading) + } else { lines.append(line) } + } else if line.hasPrefix("> ") { + append(.paragraph) + lines = [String(line.dropFirst(2))] + append(.quote) + } else { + lines.append(line.hasPrefix("- ") || line.hasPrefix("* ") ? "• " + line.dropFirst(2) : line) + } + } + append(code.map(Kind.code) ?? .paragraph) + return blocks + } +} + +struct MarkdownText: View { + let text: String + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(MarkdownBlock.parse(text)) { block in + switch block.kind { + case .code(let language): + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(language.isEmpty ? "Code" : language).font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Copy code", systemImage: "doc.on.doc") { UIPasteboard.general.string = block.text } + .labelStyle(.iconOnly).font(.caption).frame(minWidth: 44, minHeight: 32) + } + ScrollView(.horizontal) { Text(block.text).font(.callout.monospaced()).textSelection(.enabled) } + } + .padding(14).background(FragmentStyle.surface, in: RoundedRectangle(cornerRadius: 14)) + case .heading: + inline(block.text).font(.title3.weight(.semibold)) + case .quote: + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 2).fill(.secondary.opacity(0.3)).frame(width: 3) + inline(block.text).foregroundStyle(.secondary) + }.fixedSize(horizontal: false, vertical: true) + case .paragraph: + inline(block.text).font(.body).lineSpacing(5) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private func inline(_ source: String) -> some View { + Text((try? AttributedString(markdown: source, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(source)) + .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift new file mode 100644 index 000000000..ce64aaa52 --- /dev/null +++ b/ios/Sources/Models.swift @@ -0,0 +1,245 @@ +import Foundation + +struct FragmentSummary: Codable, Identifiable, Hashable, Sendable { + let name: String + var kind: String + var title: String? + var agents: [String]? + var preview: String? + var previewAt: Int64? + var archived: Bool? + var dock: Bool? + var role: String? + var picture: String? + // A navigation selection in the session archive, never an authentication token. + var sessionKey: String? + var id: String { name } + var displayTitle: String { title ?? name } +} + +struct SearchResults: Decodable, Sendable { + var fragments: [FragmentSummary] = [] + var messages: [MessageHit] = [] +} + +struct MessageHit: Decodable, Identifiable, Sendable { + let fragment: String + let channel: String + let seq: Int64 + let at: Int64 + let snippet: String + var id: String { "\(fragment):\(channel):\(seq)" } +} + +// Channels accept arbitrary JSON; unknown fields and record kinds must survive +// decoding without making an older app unable to read a newer chat. +enum JSONValue: Codable, Equatable, Sendable { + case string(String), number(Double), bool(Bool), object([String: JSONValue]), array([JSONValue]), null + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if value.decodeNil() { self = .null } + else if let v = try? value.decode(String.self) { self = .string(v) } + else if let v = try? value.decode(Bool.self) { self = .bool(v) } + else if let v = try? value.decode(Double.self) { self = .number(v) } + else if let v = try? value.decode([String: JSONValue].self) { self = .object(v) } + else { self = .array(try value.decode([JSONValue].self)) } + } + + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .string(let v): try value.encode(v) + case .number(let v): try value.encode(v) + case .bool(let v): try value.encode(v) + case .object(let v): try value.encode(v) + case .array(let v): try value.encode(v) + case .null: try value.encodeNil() + } + } + + subscript(_ key: String) -> JSONValue { + if case .object(let value) = self { return value[key] ?? .null } + return .null + } + var string: String? { if case .string(let v) = self { return v }; return nil } + var array: [JSONValue] { if case .array(let v) = self { return v }; return [] } +} + +struct ChannelRecord: Codable, Identifiable, Equatable, Sendable { + let channel: String + let seq: Int64 + let at: Int64 + let principal: String + let kind: String + let body: JSONValue + var id: String { "\(channel):\(seq)" } + var text: String { body["text"].string ?? body.string ?? "" } + var turn: String? { body["turn"].string } + var isMessage: Bool { + channel == "chat" && (body["kind"].string == nil || body["kind"].string == "message") + } +} + +struct LiveFrame: Decodable, Sendable { + let type: String + var channel: String? + var seq: Int64? + var at: Int64? + var principal: String? + var kind: String? + var body: JSONValue? + var turn: String? + var text: String? + var next: Int64? + var more: Bool? + var message: String? + + var record: ChannelRecord? { + guard let channel, let seq, seq > 0, let at, let principal, let kind, let body else { return nil } + return ChannelRecord(channel: channel, seq: seq, at: at, principal: principal, kind: kind, body: body) + } +} + +struct ReplyDraft: Identifiable, Equatable { + let turn: String + let principal: String + var text: String + var at: Int64 + var id: String { turn } +} + +/// A turn by its agent and its id, as the web's chat page keys one +/// (templates/chat/site/chat.js `turnOf`): a `work` record counts toward +/// the turn of its id that its own poster began, so one agent's records +/// (an end among them) never touch another's turn (docs/chat-records.md). +struct TurnKey: Hashable, Sendable { + let agent: String + let id: String +} + +/// What a turn's records said: when its own `turn.start` was read, and +/// whether its `turn.end` was. It runs once started and until ended +/// (control.js `running`), in whichever order its records were read. +struct TurnState: Equatable, Sendable { + var startAt: Int64? + var ended = false + var running: Bool { startAt != nil && !ended } +} + +struct Timeline { + private(set) var records: [ChannelRecord] = [] + private(set) var drafts: [String: ReplyDraft] = [:] + private(set) var cursors: [String: Int64] = [:] + /// Each turn read, kept apart from the record window, which a long + /// turn's steps outgrow: whether a turn runs (and Stop shows) never + /// depends on how much of it is on screen. + private(set) var turns: [TurnKey: TurnState] = [:] + /// The oldest `work` record read (its seq and time): where reading + /// earlier work goes on from, and how far back it reached. + private(set) var oldestWork: (seq: Int64, at: Int64)? + // Bound the in-memory window independently of the server's retention. + static let maximumRecords = 2_000 + /// Turns kept, past which the oldest ones both begun and ended go: their + /// start was read, so no earlier page holds it to begin one again. + static let maximumTurns = 4_096 + + /// What a `work` record says of its poster's turn, wherever it falls: + /// in the window, or read back for this alone (`ChatSession`). + mutating func learn(_ record: ChannelRecord) { + guard record.channel == "work" else { return } + if oldestWork.map({ record.seq < $0.seq }) ?? true { oldestWork = (record.seq, record.at) } + guard let id = record.turn else { return } + let key = TurnKey(agent: record.principal, id: id) + var state = turns[key] ?? TurnState() + switch record.body["kind"].string { + case "turn.start": state.startAt = record.at + case "turn.end": state.ended = true + default: break + } + turns[key] = state + if turns.count > Self.maximumTurns { + let done = turns.filter { $0.value.startAt != nil && $0.value.ended && $0.key != key }.sorted { $0.value.startAt! < $1.value.startAt! } + for (gone, _) in done.prefix(turns.count - Self.maximumTurns) { turns.removeValue(forKey: gone) } + } + } + + /// Whether a turn was read without its start: earlier work holds it. + var awaitsAStart: Bool { turns.values.contains { $0.startAt == nil } } + + mutating func insert(_ record: ChannelRecord, advancingCursor: Bool = true) { + learn(record) + if advancingCursor { cursors[record.channel] = max(cursors[record.channel] ?? 0, record.seq) } + if !records.contains(where: { $0.id == record.id }) { + records.append(record) + records.sort { + if $0.at != $1.at { return $0.at < $1.at } + if $0.channel != $1.channel { return $0.channel < $1.channel } + return $0.seq < $1.seq + } + if records.count > Self.maximumRecords { records.removeFirst(records.count - Self.maximumRecords) } + } else { return } + if advancingCursor, record.channel == "chat", let turn = record.turn, + drafts[turn]?.principal == record.principal { + drafts.removeValue(forKey: turn) + } + if record.channel == "work", record.body["kind"].string == "turn.end", let turn = record.turn { + drafts.removeValue(forKey: turn) + } + } + + mutating func apply(_ frame: LiveFrame) throws { + switch frame.type { + case "record": + guard let record = frame.record else { throw APIError.invalidResponse } + insert(record) + case "draft": + guard frame.channel == "chat", let turn = frame.turn, let principal = frame.principal else { return } + if let text = frame.text { + guard text.utf8.count <= 65_536, drafts.count < 32 || drafts[turn] != nil else { + throw APIError.limitExceeded + } + drafts[turn] = ReplyDraft(turn: turn, principal: principal, text: text, at: frame.at ?? 0) + } else { drafts.removeValue(forKey: turn) } + case "subscribed": + if let channel = frame.channel, let next = frame.next { + cursors[channel] = max(cursors[channel] ?? 0, next) + } + case "error": throw APIError.server(frame.message ?? "The live connection was refused.") + default: break + } + } + + mutating func disconnected() { drafts.removeAll() } + + /// The turns running, the earliest begun first: each id once (a Stop + /// names a turn by its id). + var activeTurns: [String] { + var ids: [String] = [] + for (key, _) in turns.filter({ $0.value.running }).sorted(by: { ($0.value.startAt!, $0.key.id) < ($1.value.startAt!, $1.key.id) }) where !ids.contains(key.id) { + ids.append(key.id) + } + return ids + } +} + +struct PendingPost: Codable, Identifiable, Sendable { + let id: String + let text: String + var failed = false + /// Why the server refused it: final, so it is edited, never retried. + var refused: String? + var attachments: [ChatAttachment] = [] + var replyTo: Int64? + var recipients: [String] = [] +} + +struct ImportedSession: Decodable, Identifiable, Sendable { + let key: String + var title: String? + var preview: String? + var started_at: String? + var ended_at: String? + var id: String { key } + var at: Int64 { Int64((ISO8601DateFormatter().date(from: ended_at ?? started_at ?? "")?.timeIntervalSince1970 ?? 0) * 1000) } +} diff --git a/ios/Sources/SettingsView.swift b/ios/Sources/SettingsView.swift new file mode 100644 index 000000000..fa7bf8c29 --- /dev/null +++ b/ios/Sources/SettingsView.swift @@ -0,0 +1,221 @@ +import SwiftUI +import UserNotifications + +enum SettingsSection: String, CaseIterable, Identifiable { + case account = "Account", billing = "Billing", computer = "Computer", agents = "Agents", skills = "Skills", connections = "Connections", developer = "Build with Claude Code or Codex", data = "Your data", device = "This iPhone" + var id: String { rawValue } + var icon: String { + switch self { + case .account: "person.crop.circle" + case .billing: "creditcard" + case .computer: "desktopcomputer" + case .agents: "person.2" + case .skills: "sparkles" + case .connections: "point.3.connected.trianglepath.dotted" + case .developer: "terminal" + case .data: "square.and.arrow.down" + case .device: "iphone" + } + } +} + +struct SettingsView: View { + let library: Library + let api: FragmentAPI + var connect: () -> Void = {} + let open: (FragmentSummary) -> Void + var body: some View { + List { + Section { + HStack(spacing: 14) { + Image(systemName: "circle.hexagongrid.fill").font(.title).foregroundStyle(FragmentStyle.lilac) + VStack(alignment: .leading, spacing: 4) { Text("Finite.Computer").font(.headline); Text("A computer of your own.").font(.subheadline).foregroundStyle(.secondary) } + }.padding(.vertical, 8) + } + Section { + NavigationLink { AppManagementList(library: library, api: api) } label: { + Label("Apps", systemImage: "app.badge").padding(.vertical, 4) + }.accessibilityIdentifier("settings-Apps") + ForEach(SettingsSection.allCases) { section in + NavigationLink { SettingsDetail(section: section, library: library, api: api, open: open) } label: { + Label(section.rawValue, systemImage: section.icon).padding(.vertical, 4) + }.accessibilityIdentifier("settings-\(section.id)") + } + } + Section { + Button("Account connection", systemImage: "person.crop.circle.badge.checkmark", action: connect).accessibilityIdentifier("accountConnection") + NavigationLink("Open-source notices") { + ScrollView { + Text(thirdPartyNotices).font(.footnote).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding() + }.navigationTitle("Open-source notices") + } + LabeledContent("Version", value: "0.3") + } footer: { Text("Connected to " + api.baseURL.absoluteString) } + }.scrollContentBackground(.hidden).background(FragmentStyle.background) + } + private var thirdPartyNotices: String { + guard let url = Bundle.main.url(forResource: "ThirdPartyNotices", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) else { return "Notices could not be loaded." } + return text + } + +} + +private struct SettingsDetail: View { + let section: SettingsSection + let library: Library + let api: FragmentAPI + let open: (FragmentSummary) -> Void + @State private var value: JSONValue = .null + @State private var loading = false + @State private var error: String? + @State private var confirmRestart = false + @State private var notificationStatus = "Not enabled" + @State private var exports: ExportModel + init(section: SettingsSection, library: Library, api: FragmentAPI, open: @escaping (FragmentSummary) -> Void) { + self.section = section; self.library = library; self.api = api; self.open = open + _exports = State(initialValue: ExportModel(api: api)) + } + var body: some View { + Form { + if loading { ProgressView("Loading…") } + if let error { Section { Text(error).foregroundStyle(.secondary); Button("Try again") { Task { await load() } } } } + fields + } + .navigationTitle(section == .developer ? "Build with an agent" : section.rawValue).navigationBarTitleDisplayMode(.inline) + .task { await load() } + .refreshable { await load() } + .confirmationDialog("Restart your computer?", isPresented: $confirmRestart, titleVisibility: .visible) { + Button("Restart computer", role: .destructive) { Task { await restart() } } + } message: { Text("Saves your computer if it can, then starts it fresh. Anything its agents are doing stops.") } + } + @ViewBuilder private var fields: some View { + switch section { + case .account: + Section("Account") { + LabeledContent("Email", value: value["email"].string ?? "Not connected") + LabeledContent("Identity", value: value["id"].string ?? "—").font(.caption).textSelection(.enabled) + } + Section { + Text("This iPhone uses its own approved device key. Manage or disconnect it from Account connection in Settings.").foregroundStyle(.secondary) + } + Section { Link("Privacy", destination: URL(string: "https://finite.chat/privacy")!); Link("Terms", destination: URL(string: "https://finite.chat/terms")!) } + case .billing: + Section("Your seat") { + LabeledContent("Plan", value: value["seat"]["kind"].string ?? "No seat") + LabeledContent("Organization", value: value["seat"]["org"]["name"].string ?? "—") + Text("Manage purchases and subscriptions on your Finite website.").font(.footnote).foregroundStyle(.secondary) + } + case .computer: + if let computer = computer { + Section("Your computer") { + LabeledContent("State", value: ComputerPhase.said(computer["phase"].string)) + LabeledContent("Version", value: computer["version"].string ?? "—") + Button("Restart computer", systemImage: "arrow.clockwise") { confirmRestart = true } + } + } else if !loading { ContentUnavailableView("No computer", systemImage: "desktopcomputer", description: Text("Your computer will appear here when connected.")) } + case .agents: + ForEach(library.agents) { agent in + Section { + HStack { AgentAvatar(agent: agent); Text(agent.displayTitle).font(.headline) } + Button("Start a new chat", systemImage: "square.and.pencil") { library.selectedAgent = agent.name; openLauncher() } + LabeledContent("Chats", value: String(library.chats.filter { ($0.agents ?? []).contains(library.identities[agent.name] ?? "") }.count)) + } + } + case .skills: + Section { + Text("What your agents know how to do, kept in one place.") + if let skills = library.fragments.first(where: { $0.kind == "skills" || $0.name == "skills" }) { Button("Open skills", systemImage: "sparkles") { open(skills) } } + else { Text("No skills library is connected.").foregroundStyle(.secondary) } + } + case .connections: + ForEach(Array(value["providers"].array.enumerated()), id: \.offset) { _, provider in + Section(provider["provider"].string ?? "Connection") { + LabeledContent("Status", value: (provider["state"].string ?? "Not connected").replacingOccurrences(of: "_", with: " ").capitalized) + if let account = provider["account"].string { LabeledContent("Account", value: account) } + if let note = provider["note"].string { Text(note).font(.footnote).foregroundStyle(.secondary) } + } + } + Section { Text("Connect and authorize providers on the web. Credentials stay on your computer’s connection service.").font(.footnote).foregroundStyle(.secondary) } + case .developer: + Section("Build with Claude Code or Codex") { + Text("Paste this into your coding agent and finish the sentence.") + Text(prompt).font(.callout.monospaced()).textSelection(.enabled) + ShareLink(item: prompt) { Label("Share prompt", systemImage: "square.and.arrow.up") } + Button("Copy prompt", systemImage: "doc.on.doc") { UIPasteboard.general.string = prompt } + } + case .data: + ExportSection(model: exports) + case .device: + Section("Notifications") { + LabeledContent("Reply notifications", value: notificationStatus) + Button("Enable notifications", systemImage: "bell") { Task { await notifications() } } + Text("Notification delivery while the app is closed is not connected yet.").font(.footnote).foregroundStyle(.secondary) + } + } + } + private var computer: JSONValue? { value["computer"].string == nil ? value["computers"].array.first : value } + private var prompt: String { "Read \(api.url("llms.txt").absoluteString) and help me build: " } + private func openLauncher() { open(FragmentSummary(name: "__launcher", kind: "launcher")) } + private func load() async { + loading = true; error = nil; defer { loading = false } + let path: String + switch section { + case .account: path = "api/identities/me" + case .billing: path = "api/seat" + case .computer: path = "api/computers" + case .connections: path = "api/connections" + // the Form's pull to refresh asks the export again too + case .data: return await exports.refresh() + default: return + } + do { value = try await api.request(path) } catch { self.error = error.localizedDescription } + } + private func restart() async { + guard let computer, let id = computer["computer"].string else { return } + do { value = try await api.request("api/computers/\(id)/restart", method: "POST", body: .object(["generation": computer["generation"]])) } + catch { self.error = error.localizedDescription } + } + private func notifications() async { + do { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]); notificationStatus = granted ? "Allowed" : "Disabled in iOS Settings" } + catch { self.error = error.localizedDescription } + } +} + +/// The person's export (docs/api.md, Export) as the web's Settings shows it +/// (cell/shell/exports.js): made on the platform, followed while it builds, +/// downloaded with this iPhone's own signature into a file of its own and +/// shared from there, and made again at any time. +private struct ExportSection: View { + let model: ExportModel + var body: some View { + Section("Export") { + Text("Download your fragments, messages, app data, and your computer’s newest save.") + LabeledContent("Status", value: model.export?.said ?? "No export yet") + if let export = model.export, export.state == "ready", export.download != nil { + if let file = model.file { ShareLink(item: file) { Label("Share export", systemImage: "square.and.arrow.up") } } + else { Button("Download export", systemImage: "arrow.down.circle") { Task { await model.download() } }.disabled(model.busy).accessibilityIdentifier("downloadExport") } + } + Button(model.startLabel, systemImage: "square.and.arrow.down") { Task { await model.start() } } + .disabled(model.busy || model.export?.state == "building").accessibilityIdentifier("startExport") + if model.busy { ProgressView() } + if let error = model.error { Text(error).font(.footnote).foregroundStyle(.secondary) } + } + // its first read is the Form's (SettingsDetail `load`), as is a pull to refresh + .task(id: model.export?.state == "building") { await model.follow() } + } +} + +/// A computer's `phase` (`ComputerView`, docs/api.md), as Settings says it. +enum ComputerPhase { + static func said(_ phase: String?) -> String { + switch phase { + case "asleep": "Asleep" + case "starting": "Starting" + case "awake": "Awake" + case "sleeping": "Going to sleep" + case "wont_wake": "Won’t wake" + default: "—" + } + } +} diff --git a/ios/Sources/ShellViews.swift b/ios/Sources/ShellViews.swift new file mode 100644 index 000000000..2b298a6ad --- /dev/null +++ b/ios/Sources/ShellViews.swift @@ -0,0 +1,450 @@ +import SwiftUI +import UIKit + +struct DesktopWallpaper: View { + var body: some View { + GeometryReader { proxy in + Image("DesktopWallpaper").resizable().scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height).clipped() + }.ignoresSafeArea().accessibilityHidden(true) + } +} + +struct AgentAvatar: View { + let agent: FragmentSummary? + var size: CGFloat = 32 + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.28).fill(color.gradient) + if let picture = agent?.picture, let url = URL(string: picture) { + AuthenticatedImage(url: url) { image in + if let image { image.resizable().scaledToFill() } else { Color.clear } + } + .frame(width: size, height: size).clipShape(RoundedRectangle(cornerRadius: size * 0.28)) + } else { + Text(String((agent?.displayTitle ?? "?").prefix(1))).font(.system(size: size * 0.46, weight: .semibold)).foregroundStyle(.white) + } + }.frame(width: size, height: size).accessibilityHidden(true) + } + private var color: Color { + let index = (agent?.name.utf8.reduce(0) { ($0 + Int($1)) % 5 }) ?? 0 + return [FragmentStyle.lilac, .teal, .orange, .indigo, .pink][index] + } +} + +struct SidebarDrawer: View { + @Binding var isPresented: Bool + let library: Library + let page: ShellPage + let api: FragmentAPI + let navigate: (ShellPage) -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var dragOffset: CGFloat = 0 + private var settle: Animation? { reduceMotion ? nil : .snappy(duration: 0.25) } + + var body: some View { + GeometryReader { geometry in + let width = min(350, max(1, geometry.size.width - 36)) + ZStack(alignment: .leading) { + if isPresented { + Color.black.opacity(0.25 * max(0, 1 + dragOffset / width)) + .ignoresSafeArea() + .onTapGesture { close() } + .accessibilityHidden(true) + .transition(.opacity) + SidebarView(library: library, page: page, api: api, navigate: navigate) + .frame(width: width) + .offset(x: dragOffset) + .transition(.move(edge: .leading)) + .accessibilityAddTraits(.isModal) + .accessibilityAction(.escape) { close() } + .gesture(SidebarPan(changed: { distance in + dragOffset = max(-width, min(0, distance)) + }, ended: { distance, velocity in + let projected = distance + min(0, velocity) * 0.2 + withAnimation(settle) { + if distance < -60 && projected < -width * 0.35 { isPresented = false } + dragOffset = 0 + } + })) + } + } + .animation(settle, value: isPresented) + } + .allowsHitTesting(isPresented) + .onChange(of: isPresented) { _, _ in dragOffset = 0 } + .task(id: isPresented) { if isPresented { await library.load(api) } } + } + private func close() { withAnimation(settle) { isPresented = false; dragOffset = 0 } } +} + +// Directional recognition leaves vertical scrolling to the native scroll view +// and cancels button touches once a horizontal dismissal starts. +private struct SidebarPan: UIGestureRecognizerRepresentable { + let changed: (CGFloat) -> Void + let ended: (CGFloat, CGFloat) -> Void + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let pan = UIPanGestureRecognizer() + pan.maximumNumberOfTouches = 1 + pan.delegate = context.coordinator + return pan + } + func handleUIGestureRecognizerAction(_ pan: UIPanGestureRecognizer, context: Context) { + // The window is stable while the drawer itself moves beneath the finger. + let distance = pan.translation(in: pan.view?.window).x + switch pan.state { + case .began, .changed: changed(distance) + case .ended: ended(distance, pan.velocity(in: pan.view?.window).x) + case .cancelled, .failed: ended(0, 0) + default: break + } + } + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool { + guard let pan = gesture as? UIPanGestureRecognizer else { return false } + let translation = pan.translation(in: pan.view?.window) + return translation.x < 0 && abs(translation.x) > abs(translation.y) + } + } +} + +struct SidebarView: View { + let library: Library + let page: ShellPage + let api: FragmentAPI + let navigate: (ShellPage) -> Void + @State private var renamingChat: FragmentSummary? + @State private var chatTitle = "" + @State private var managingChat: FragmentSummary? + @State private var actionError: String? + + private var currentChat: FragmentSummary? { + guard case .chat(let name) = page else { return nil } + return library.fragments.first { $0.name == name } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Finite.Computer").font(.headline) + Spacer() + Button("Search or start a new chat", systemImage: "square.and.pencil") { navigate(.launcher) } + .labelStyle(.iconOnly).buttonStyle(.glass).accessibilityIdentifier("sidebarNewChat") + }.padding(20) + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(library.agents) { agent in + Menu { + Button("New chat with \(agent.displayTitle)", systemImage: "square.and.pencil") { + library.selectedAgent = agent.name; navigate(.launcher) + } + Button("Agent settings", systemImage: "person.crop.circle") { library.selectedAgent = agent.name; navigate(.settings) } + if let chat = currentChat, let identity = library.identities[agent.name], (chat.agents ?? []).contains(identity) { + Section(chat.displayTitle) { chatActions(chat) } + } + } label: { + HStack(spacing: 12) { + AgentAvatar(agent: agent) + Text(agent.displayTitle).font(.headline) + Spacer() + Image(systemName: "chevron.down").font(.caption).foregroundStyle(.secondary) + }.padding(12).background(.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 18)) + }.buttonStyle(.plain).accessibilityIdentifier("agentMenu-\(agent.name)") + ForEach(library.chats.filter { ($0.agents ?? []).first == library.identities[agent.name] }) { chat in + chatRow(chat) + } + } + let ungrouped = library.chats.filter { library.agents(in: $0).isEmpty } + if !ungrouped.isEmpty { + Text("Shared chats").font(.caption).foregroundStyle(.secondary).padding(.top) + ForEach(ungrouped) { chatRow($0) } + } + if library.loading && library.fragments.isEmpty { ProgressView().padding() } + if let error = library.error { + Text(error).font(.footnote).foregroundStyle(.secondary) + Button("Try again") { Task { await library.load(api) } } + } + }.padding(.horizontal, 12) + }.refreshable { await library.load(api) } + VStack(spacing: 4) { + footer("History", icon: "clock", page: .history) + footer("Your Apps", icon: "list.bullet.rectangle", page: .apps) + footer("Settings", icon: "gearshape", page: .settings) + }.padding(12) + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 24)) + .padding(.vertical, 8).padding(.leading, 8) + .alert("Name this chat", isPresented: Binding(get: { renamingChat != nil }, set: { if !$0 { renamingChat = nil } })) { + TextField("Chat name", text: $chatTitle) + Button("Save") { + guard let chat = renamingChat else { return } + Task { + do { try await api.rename(chat, title: chatTitle); await library.load(api) } + catch { actionError = error.localizedDescription } + } + renamingChat = nil + } + Button("Cancel", role: .cancel) { renamingChat = nil } + } + .alert("Couldn’t update chat", isPresented: Binding(get: { actionError != nil }, set: { if !$0 { actionError = nil } })) { + Button("OK") { actionError = nil } + } message: { Text(actionError ?? "") } + .sheet(item: $managingChat) { chat in AgentMembershipView(chat: chat, library: library, api: api) } + } + @ViewBuilder private func chatActions(_ chat: FragmentSummary) -> some View { + Button("Rename chat…", systemImage: "pencil") { chatTitle = chat.displayTitle; renamingChat = chat } + Button("Agents in this chat", systemImage: "person.2") { managingChat = chat } + Button(chat.archived == true ? "Unarchive chat" : "Archive chat", systemImage: "archivebox") { + Task { await library.archive(chat, api: api) } + } + } + private func chatRow(_ chat: FragmentSummary) -> some View { + Button { navigate(.chat(chat.name)) } label: { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(chat.displayTitle).font(.subheadline.weight(.medium)).lineLimit(1) + Spacer(minLength: 4) + if let at = chat.previewAt { Text(Date(timeIntervalSince1970: Double(at) / 1000), style: .relative).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } + } + HStack(spacing: 4) { + if library.agents(in: chat).count > 1 { + ForEach(library.agents(in: chat).prefix(3)) { AgentAvatar(agent: $0, size: 16) } + } + Text(chat.preview ?? "No messages yet").font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(page == .chat(chat.name) ? Color.primary.opacity(0.08) : Color.clear, in: RoundedRectangle(cornerRadius: 12)) + }.buttonStyle(.plain).accessibilityIdentifier("sidebarChat-\(chat.name)") + .contextMenu { chatActions(chat) } + } + private func footer(_ title: String, icon: String, page destination: ShellPage) -> some View { + Button { navigate(destination) } label: { Label(title, systemImage: icon).frame(maxWidth: .infinity, alignment: .leading).padding(12) } + .buttonStyle(.plain).accessibilityIdentifier("sidebar-\(title.lowercased())") + } +} + +private enum AppsLibrarySheet: Identifiable { + case settings(FragmentSummary), manage + var id: String { switch self { case .settings(let app): "settings-" + app.name; case .manage: "manage" } } +} + +struct AppsLibraryView: View { + let library: Library + let api: FragmentAPI + @State private var sheet: AppsLibrarySheet? + let open: (FragmentSummary) -> Void + static let addApp = FragmentSummary(name: "add-app", kind: "app", title: "Add an app") + var body: some View { + ZStack { + DesktopWallpaper() + List { + Section { + ForEach(library.shownApps) { app in + appRow(app, symbol: app.kind == "brain" ? "brain" : "app") + } + } + Section { + appRow(Self.addApp, symbol: "plus") + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Manage Apps", systemImage: "slider.horizontal.3") { sheet = .manage }.accessibilityIdentifier("manageApps") + } + } + .sheet(item: $sheet) { destination in + NavigationStack { + Group { + switch destination { + case .settings(let app): AppSettingsView(fragment: app, library: library, api: api) + case .manage: AppManagementList(library: library, api: api) + } + } + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close", systemImage: "xmark") { sheet = nil } } } + } + .presentationDragIndicator(.visible) + .presentationDetents([.large]) + } + } + private func appRow(_ app: FragmentSummary, symbol: String) -> some View { + HStack(spacing: 8) { + Button { open(app) } label: { + HStack(spacing: 14) { + FragmentIcon(fragment: app, fallback: symbol) + VStack(alignment: .leading, spacing: 4) { + Text(app.displayTitle).font(.body.weight(.medium)) + if let preview = app.preview, !preview.isEmpty { + Text(preview).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + Spacer(minLength: 8) + if app.name == "add-app" { Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(.tertiary) } + } + .padding(.vertical, 12).frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("app-\(app.name)") + if app.name != "add-app" { + Button { sheet = .settings(app) } label: { + Image(systemName: "gearshape").frame(width: 44, height: 44).contentShape(Rectangle()) + }.buttonStyle(.borderless).accessibilityLabel("Settings for \(app.displayTitle)") + .accessibilityIdentifier("appSettings-\(app.name)") + } + }.listRowBackground(Rectangle().fill(.regularMaterial)) + } +} + +struct FragmentIcon: View { + let fragment: FragmentSummary + let fallback: String + var size: CGFloat = 40 + var body: some View { + Group { + if let raw = fragment.picture, let url = URL(string: raw) { + AuthenticatedImage(url: url) { loaded in + if let image = loaded { image.resizable().scaledToFit() } + else { placeholder } + } + } else { placeholder } + }.frame(width: size, height: size).accessibilityHidden(true) + } + private var placeholder: some View { + Image(systemName: fallback).font(.system(size: size * 0.6)).foregroundStyle(.secondary) + } +} + +enum HistoryFilter: String, CaseIterable { case all = "All", archived = "Archived", imported = "Imported" } +struct HistoryView: View { + let library: Library + let api: FragmentAPI + let open: (FragmentSummary) -> Void + @State private var search = "" + @State private var filter = HistoryFilter.all + @State private var messageHits: [String: String] = [:] + @State private var importedHits: [String: String] = [:] + @State private var error: String? + private struct Row: Identifiable { + let id: String + let fragment: FragmentSummary + let at: Int64 + let preview: String + } + private var rows: [Row] { + var rows: [Row] = [] + if filter != .imported { + for chat in library.fragments where chat.kind == "chat" && chat.previewAt != nil { + guard filter != .archived || chat.archived == true else { continue } + guard search.isEmpty || chat.displayTitle.localizedCaseInsensitiveContains(search) || messageHits[chat.name] != nil else { continue } + rows.append(Row(id: "historyChat-\(chat.name)", fragment: chat, at: chat.previewAt ?? 0, preview: messageHits[chat.name] ?? chat.preview ?? "")) + } + } + if filter != .archived, let archive = library.fragments.first(where: { $0.kind == "sessions" && $0.role == "owner" }) { + for entry in library.imported { + guard search.isEmpty || (entry.title ?? "").localizedCaseInsensitiveContains(search) || (entry.preview ?? "").localizedCaseInsensitiveContains(search) || importedHits[entry.key] != nil else { continue } + let fragment = FragmentSummary(name: archive.name, kind: "imported", title: entry.title ?? "Untitled", sessionKey: entry.key) + rows.append(Row(id: "importedSession-\(entry.key)", fragment: fragment, at: entry.at, preview: importedHits[entry.key] ?? entry.preview ?? "")) + } + } + return rows.sorted { $0.at > $1.at } + } + var body: some View { + List { + Picker("Show", selection: $filter) { ForEach(HistoryFilter.allCases, id: \.self) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).listRowBackground(Color.clear) + ForEach(rows) { row in + Button { open(row.fragment) } label: { + HStack(spacing: 12) { + if row.fragment.kind == "imported" { Image(systemName: "tray.and.arrow.down").frame(width: 32) } + else { AgentAvatar(agent: library.agents(in: row.fragment).first) } + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(row.fragment.displayTitle).foregroundStyle(.primary) + Spacer() + if row.fragment.archived == true { Image(systemName: "archivebox").font(.caption) } + Text(Date(timeIntervalSince1970: Double(row.at) / 1000), style: .date).font(.caption2).foregroundStyle(.secondary) + } + Text(row.preview).font(.caption).foregroundStyle(.secondary).lineLimit(2) + if row.fragment.kind == "imported" { Text("Imported · Read only").font(.caption2).foregroundStyle(.tertiary) } + } + }.padding(.vertical, 4) + }.accessibilityIdentifier(row.id) + .swipeActions { + if row.fragment.kind == "chat" { + Button(row.fragment.archived == true ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await library.archive(row.fragment, api: api) } }.tint(.indigo) + } + } + } + if rows.isEmpty { ContentUnavailableView.search(text: search) } + if let error { Text(error).font(.footnote).foregroundStyle(.secondary) } + if let error = library.error { Text(error).font(.footnote).foregroundStyle(.secondary) } + }.searchable(text: $search, prompt: "Search chats").refreshable { await library.load(api) } + .scrollContentBackground(.hidden).background(FragmentStyle.background) + .task(id: search) { + messageHits = [:]; importedHits = [:]; error = nil + guard !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + do { + try await Task.sleep(for: .milliseconds(250)) + let result = try await api.search(search) + try Task.checkCancellation() + for hit in result.messages where messageHits[hit.fragment] == nil { messageHits[hit.fragment] = hit.snippet } + if let archive = library.fragments.first(where: { $0.kind == "sessions" && $0.role == "owner" }) { + let result: JSONValue = try await api.request("api/f/\(archive.name)/search", method: "POST", body: .object(["q": .string(FragmentAPI.searchHint(search)), "limit": .number(50), "mode": .string("lexical"), "fields": .object(["kind": .string("chat")])])) + try Task.checkCancellation() + for hit in result["results"].array { + guard let source = hit["source"].string, source.hasPrefix("sessions/"), source.hasSuffix(".json") else { continue } + let key = String(source.dropFirst(9).dropLast(5)) + if importedHits[key] == nil { importedHits[key] = hit["snippet"].string ?? "" } + } + } + } catch { if !Task.isCancelled { self.error = "Some messages could not be searched: \(error.localizedDescription)" } } + } + } +} + +struct AgentMembershipView: View { + @Environment(\.dismiss) private var dismiss + let chat: FragmentSummary + let library: Library + let api: FragmentAPI + @State private var working = false + @State private var error: String? + var body: some View { + NavigationStack { + List { + Section("Agents in this chat") { + ForEach(library.agents) { agent in + let joined = library.fragments.first(where: { $0.name == chat.name })?.agents?.contains(library.identities[agent.name] ?? "") == true + Button { Task { await add(agent) } } label: { + HStack { AgentAvatar(agent: agent); Text(agent.displayTitle); Spacer(); Image(systemName: joined ? "checkmark" : "plus") } + }.disabled(joined || working).accessibilityIdentifier("joinAgent-\(agent.name)") + } + } + if let session = library.sessions[chat.name] { + Section("Messages") { + Picker("Send to", selection: Binding( + get: { session.recipients.count > 1 ? "all" : session.recipients.first ?? "lead" }, + set: { selection in + let members = library.fragments.first(where: { $0.name == chat.name })?.agents ?? [] + session.recipients = selection == "lead" ? [] : selection == "all" ? members : [selection] + } + )) { + Text("Lead agent").tag("lead") + ForEach(library.agents(in: library.fragments.first(where: { $0.name == chat.name }) ?? chat)) { agent in + if let identity = library.identities[agent.name] { Text(agent.displayTitle).tag(identity) } + } + Text("All agents").tag("all") + }.accessibilityIdentifier("messageRecipient") + } + } + if let error { Text(error).foregroundStyle(.secondary) } + }.navigationTitle("Agents").toolbar { Button("Done") { dismiss() } } + } + } + private func add(_ agent: FragmentSummary) async { + working = true; defer { working = false } + do { try await api.join(agent, to: chat); await library.load(api) } + catch { self.error = error.localizedDescription } + } +} diff --git a/ios/Sources/ThirdPartyNotices.txt b/ios/Sources/ThirdPartyNotices.txt new file mode 100644 index 000000000..8d5d64308 --- /dev/null +++ b/ios/Sources/ThirdPartyNotices.txt @@ -0,0 +1,48 @@ +P256K 0.21.1 +https://github.com/21-DOT-DEV/swift-secp256k1 + +MIT License + +Copyright (c) 2020 GigaBitcoin LLC + +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. + + +Bitcoin Core libsecp256k1 (bundled by P256K) +https://github.com/bitcoin-core/secp256k1 + +Copyright (c) 2013 Pieter Wuille + +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. diff --git a/ios/Tests/AppSettingsTests.swift b/ios/Tests/AppSettingsTests.swift new file mode 100644 index 000000000..cbd923e62 --- /dev/null +++ b/ios/Tests/AppSettingsTests.swift @@ -0,0 +1,77 @@ +import Foundation +import ImageIO +import Testing +import UIKit +@testable import Fragment + +/// An app's settings against a stub of the platform's answers: what the +/// sheet reads of status, members and invites, and what it sends. +@MainActor +struct AppSettingsTests { + private func details(_ json: String) throws -> AppDetails { try JSONDecoder().decode(AppDetails.self, from: Data(json.utf8)) } + + @Test func theShareLinkIsTheOwnersOnALinkAppAndTheAddressOnAPublicOne() throws { + let canonical = #""urls":{"canonical":"https://reading--p1.finite.test/","platform":"https://finite.test"}"# + let link = try details(#"{"owner":"npub1me","role":"owner","visibility":"link","viewToken":"v1",\#(canonical)}"#) + #expect(link.shareURL?.absoluteString == "https://reading--p1.finite.test/?view=v1" && link.canEdit) + #expect(try details(#"{"owner":"npub1o","role":"editor","visibility":"link",\#(canonical)}"#).shareURL == nil) + #expect(try details(#"{"owner":"npub1o","role":"viewer","visibility":"public",\#(canonical)}"#).shareURL?.absoluteString == "https://reading--p1.finite.test/") + let members = try details(#"{"owner":"npub1me","role":"owner","visibility":"members","viewToken":"v1",\#(canonical)}"#) + #expect(members.shareURL == nil) + #expect(try details(#"{"owner":"npub1o","role":"viewer","visibility":"link",\#(canonical)}"#).canEdit == false) + } + + @Test func membersAndInvitesAreReadAsListed() async throws { + let stub = Stub { seen, _ in + switch seen.route { + case "GET /api/f/reading--p1/members": (200, #"{"members":[{"principal":"npub1me","role":"owner","addedBy":"npub1me","addedAt":1,"kind":"person"},{"principal":"npub1moss","role":"editor","addedBy":"npub1me","addedAt":2,"kind":"agent","owner":"npub1me"}]}"#) + case "GET /api/f/reading--p1/invites": (200, #"{"invites":[{"email":"friend@example.com","role":"viewer","expiresAt":9,"createdBy":"npub1me"}]}"#) + default: (404, "{}") + } + } + let api = try stub.api() + let members = try await api.appMembers("reading--p1") + #expect(members.map(\.principal) == ["npub1me", "npub1moss"] && members[1].owner == "npub1me") + #expect(try await api.appInvites("reading--p1").map(\.email) == ["friend@example.com"]) + } + + @Test func iconsAreMadeWithAHintAndARefusalSaysWhy() async throws { + let stub = Stub { seen, _ in + switch seen.route { + case "POST /api/f/reading--p1/icon/make": (200, #"{"subject":"a book","candidates":[{"png":"iVBORw0KGgo=","score":0.9}],"flaws":{}}"#) + case "PUT /api/f/reading--p1/icon": (400, #"{"error":"invalid_request","message":"an icon is a PNG, JPEG, WebP or GIF"}"#) + case "GET /api/f/reading--p1/icon": (404, #"{"error":"not_found","message":"no icon"}"#) + default: (404, "{}") + } + } + let api = try stub.api() + let options = try await api.makeAppIcons("reading--p1", hint: " a book ") + #expect(options.candidates.first?.data != nil) + #expect(stub.seen[0].json == .object(["hint": .string("a book")])) + _ = try await api.makeAppIcons("reading--p1", hint: " ") + #expect(stub.seen[1].json == .object([:])) + do { + try await api.putAppIcon(Data("not an image".utf8), name: "reading--p1") + Issue.record("a refused icon is an error") + } catch APIError.refused(let message) { #expect(message == "an icon is a PNG, JPEG, WebP or GIF") } + #expect(try await api.appImage("reading--p1", kind: "icon") == nil) + } + + @Test func aPickedPhotoIsFittedOnThePhoneBeforeItIsSent() throws { + let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".png") + defer { try? FileManager.default.removeItem(at: file) } + let large = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1200), format: { let f = UIGraphicsImageRendererFormat(); f.scale = 1; return f }()).image { context in + UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1200)) + } + try #require(large.pngData()).write(to: file) + let fitted = try AppIconImage.fitted(file) + let source = try #require(CGImageSourceCreateWithData(fitted as CFData, nil)) + let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + #expect(properties[kCGImagePropertyPixelWidth] as? Int == 256 && properties[kCGImagePropertyPixelHeight] as? Int == 128) + #expect(CGImageSourceGetType(source) as String? == "public.png") + let text = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".txt") + defer { try? FileManager.default.removeItem(at: text) } + try Data("not an image".utf8).write(to: text) + #expect(throws: APIError.self) { try AppIconImage.fitted(text) } + } +} diff --git a/ios/Tests/DeviceIdentityTests.swift b/ios/Tests/DeviceIdentityTests.swift new file mode 100644 index 000000000..da8bb187b --- /dev/null +++ b/ios/Tests/DeviceIdentityTests.swift @@ -0,0 +1,75 @@ +import Foundation +import CryptoKit +import P256K +import Testing +@testable import Fragment + +struct DeviceIdentityTests { + private func fixedKey(_ suffix: UInt8 = 3) throws -> DeviceIdentity { try DeviceIdentity(secret: Data(repeating: 0, count: 31) + Data([suffix])) } + private func event(_ header: String) throws -> [String: Any] { + let data = try #require(Data(base64Encoded: String(header.dropFirst(6)))) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + @Test func signatureBindsExactURLMethodBodyAndClock() throws { + #expect(Bundle.main.url(forResource: "ThirdPartyNotices", withExtension: "txt") != nil) + let key = try fixedKey() + #expect(key.publicKey == "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9") + let url = URL(string: "https://finite.example/api/f/notes/channels/chat?q=one%20two")! + let body = Data("{\"text\":\"hello ☀️\"}".utf8) + let signed = try event(key.authorization(method: "POST", url: url, body: body, at: 1_800_000_000)) + let tags = try #require(signed["tags"] as? [[String]]) + #expect(tags == [["u",url.absoluteString],["method","POST"],["payload",Data(SHA256.hash(data: body)).hex]]) + let preimage = try JSONSerialization.data(withJSONObject: [0,key.publicKey,1_800_000_000,27235,tags,""], options: [.withoutEscapingSlashes]) + #expect(signed["id"] as? String == Data(SHA256.hash(data: preimage)).hex) + let hex = try #require(signed["sig"] as? String) + let bytes = stride(from: 0, to: hex.count, by: 2).map { index in UInt8(hex[hex.index(hex.startIndex, offsetBy: index).. A thought") + #expect(blocks.map(\.kind) == [.heading, .paragraph, .quote]) + #expect(blocks[1].text == "• First\n• **Second**") + } +} diff --git a/ios/Tests/SearchTests.swift b/ios/Tests/SearchTests.swift new file mode 100644 index 000000000..c9950cfe6 --- /dev/null +++ b/ios/Tests/SearchTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import Fragment + +struct SearchTests { + @Test func longPromptOnlyBoundsSearchHint() { + let prompt = "one two three four five six seven eight nine ten" + #expect(FragmentAPI.searchHint(prompt) == "one two three four five six seven eight") + let unicode = String(repeating: "🙂", count: 100) + let hint = FragmentAPI.searchHint(unicode) + #expect(hint.utf8.count == 256) + #expect(FragmentAPI.searchHint(" \n\t ").isEmpty) + #expect(FragmentAPI.searchHint("one\ntwo") == "one two") + } + + @Test func searchSendsTheHintAndReadsFragmentsAndMessages() async throws { + let stub = Stub { seen, _ in + (200, #"{"fragments":[{"name":"reading--p1","kind":"app","title":"Reading"}],"messages":[{"fragment":"talk--c1","channel":"chat","seq":2,"at":2000,"snippet":"a few quiet minutes"}]}"#) + } + let api = try stub.api() + let found = try await api.search("quiet minutes, then a long tail of words past the eighth") + #expect(stub.seen.map(\.route) == ["GET /api/search"]) + #expect(stub.seen[0].query == ["q": "quiet minutes, then a long tail of words"]) + #expect(found.fragments.map(\.name) == ["reading--p1"]) + #expect(found.messages.map(\.id) == ["talk--c1:chat:2"]) + let empty = try await api.search(" ") + #expect(empty.fragments.isEmpty && empty.messages.isEmpty && stub.seen.count == 1) + } +} diff --git a/ios/Tests/ShellTests.swift b/ios/Tests/ShellTests.swift new file mode 100644 index 000000000..8f67cf0e3 --- /dev/null +++ b/ios/Tests/ShellTests.swift @@ -0,0 +1,329 @@ +import Foundation +import Synchronization +import Testing +@testable import Fragment + +/// The shell's reads and writes against a stub of the platform's answers +/// (docs/api.md): what the app asks, and what it makes of the answers. +@MainActor +struct ShellTests { + /// A person's list: two agents, a chat with both, an app, and their + /// session archive. + nonisolated private static let list = #""" + {"fragments":[ + {"name":"moss--a1","kind":"agent","title":"Moss","role":"owner"}, + {"name":"juniper--a2","kind":"agent","title":"Juniper","role":"owner"}, + {"name":"talk--c1","kind":"chat","title":"A calmer morning","agents":["npub1moss","npub1juniper"],"previewAt":5,"role":"owner"}, + {"name":"reading--p1","kind":"app","title":"Reading","picture":"/api/f/reading--p1/icon?v=7","role":"owner","dock":true}, + {"name":"archive--s1","kind":"sessions","role":"owner"} + ]} + """# + nonisolated private static func platform(_ seen: Seen, _ before: [Seen]) -> (Int, String) { + switch seen.route { + case "GET /api/fragments": (200, list) + case "GET /api/f/moss--a1/status": (200, #"{"npub":"npub1moss","urls":{"canonical":"https://moss--a1.finite.test/"}}"#) + case "GET /api/f/juniper--a2/status": (200, #"{"npub":"npub1juniper","urls":{"canonical":"https://juniper--a2.finite.test/"}}"#) + case "GET /api/f/archive--s1/file": (200, #"{"sessions":[{"key":"imported-chat","title":"Before Finite","ended_at":"2026-10-01T10:00:00Z"}]}"#) + default: (404, #"{"error":"not_found","message":"no route \#(seen.route)"}"#) + } + } + + @Test func theListResolvesAgentsChatsAppsAndTheArchive() async throws { + let stub = Stub(Self.platform) + let api = try stub.api() + let library = Library() + await library.load(api) + #expect(library.error == nil) + #expect(library.agents.map(\.name) == ["moss--a1", "juniper--a2"]) + let chat = try #require(library.chats.first) + #expect(library.agents(in: chat).map(\.name) == ["moss--a1", "juniper--a2"]) + #expect(library.agent(for: "npub1juniper")?.displayTitle == "Juniper") + // a listed picture is the platform's, signed like every other read + #expect(library.apps.first?.picture == "https://\(stub.host)/api/f/reading--p1/icon?v=7") + #expect(library.imported.map(\.key) == ["imported-chat"] && library.imported[0].at > 0) + #expect(stub.seen.first { $0.path == "/api/f/archive--s1/file" }?.query == ["path": "index/chat.json"]) + } + + /// The archive's index is optional: one of other kinds alone (cron, + /// subagents) has none, and one that fails to read stops nothing. + @Test func anArchiveWithoutChatsOrFailingNeverStopsAgents() async throws { + let failing = Stub { seen, before in + guard seen.route == "GET /api/f/archive--s1/file" else { return Self.platform(seen, before) } + // the first read finds no chats index; the next fails for a while + return before.contains { $0.route == seen.route } ? (503, #"{"error":"unavailable","message":"Try again."}"#) : (404, #"{"error":"not_found","message":"no file index/chat.json at main"}"#) + } + let api = try failing.api() + let library = Library() + await library.load(api) + #expect(library.error == nil && library.imported.isEmpty) + #expect(library.agent(for: "npub1juniper")?.name == "juniper--a2") + await library.load(api) + #expect(library.error == nil && library.imported.isEmpty) + #expect(library.agents(in: try #require(library.chats.first)).map(\.name) == ["moss--a1", "juniper--a2"]) + + // an index read once is kept through a failed read + let flaky = Stub { seen, before in + seen.route == "GET /api/f/archive--s1/file" && before.contains { $0.route == seen.route } ? (503, #"{"message":"Try again."}"#) : Self.platform(seen, before) + } + let kept = Library() + let flakyAPI = try flaky.api() + await kept.load(flakyAPI) + await kept.load(flakyAPI) + #expect(kept.imported.map(\.key) == ["imported-chat"] && kept.error == nil) + } + + @Test func anExportIsBuiltThenDownloadedSignedToAFile() async throws { + let id = "0123456789abcdef" + let stub = Stub { seen, before in + switch seen.route { + case "POST /api/export": (200, #"{"id":"\#(id)","state":"building","startedAt":1,"progress":{"done":0,"total":3,"bytes":0}}"#) + case "GET /api/export": + before.contains { $0.route == "GET /api/export" } + ? (200, #"{"export":{"id":"\#(id)","state":"ready","startedAt":1,"progress":{"done":3,"total":3,"bytes":11},"size":11,"readyAt":2,"expiresAt":3,"download":"/api/export/\#(id)/download"}}"#) + : (200, #"{"export":null}"#) + case "GET /api/export/\(id)/download": (200, "tar-archive") + default: (404, "{}") + } + } + let api = try stub.api() + #expect(try await api.export() == nil) + let building = try await api.startExport() + #expect(building.state == "building" && building.said == "Building (0 of 3)" && building.download == nil) + await #expect(throws: APIError.self) { _ = try await api.download(building) } + let ready = try #require(try await api.export()) + #expect(ready.state == "ready" && ready.download == "/api/export/\(id)/download") + let file = try await api.download(ready) + defer { try? FileManager.default.removeItem(at: file) } + #expect(try String(contentsOf: file, encoding: .utf8) == "tar-archive" && file.lastPathComponent == "fragment-export-\(id).tar") + let asked = try #require(stub.seen.last) + #expect(asked.route == "GET /api/export/\(id)/download" && asked.authorization?.hasPrefix("Nostr ") == true) + // a download path that is not this export's is never asked + var elsewhere = ready + elsewhere.download = "https://elsewhere.example/api/export/\(id)/download" + await #expect(throws: APIError.self) { _ = try await api.download(elsewhere) } + } + + /// Settings follows a building export to ready on the same screen, then + /// downloads it; a new export stays at hand beside it and, made, drops + /// the file of the one it replaced (cell/shell/exports.js). + @Test func anExportIsFollowedToReadyAndCanBeMadeAgain() async throws { + let id = "0123456789abcdef" + let building = #"{"id":"\#(id)","state":"building","startedAt":1,"progress":{"done":1,"total":3,"bytes":5}}"# + let ready = #"{"id":"\#(id)","state":"ready","startedAt":1,"progress":{"done":3,"total":3,"bytes":11},"size":11,"readyAt":2,"expiresAt":3,"download":"/api/export/\#(id)/download"}"# + let stub = Stub { seen, before in + switch seen.route { + case "POST /api/export": return (200, building) + // ready on its third look, after the first export was asked for + case "GET /api/export": return (200, #"{"export":\#(before.filter { $0.route == seen.route }.count >= 2 ? ready : building)}"#) + case "GET /api/export/\(id)/download": return (200, "tar-archive") + default: return (404, "{}") + } + } + let model = ExportModel(api: try stub.api(), every: .milliseconds(10)) + await model.start() + #expect(model.export?.state == "building" && model.startLabel == "Exporting…") + await model.follow() + #expect(model.export?.state == "ready" && model.startLabel == "Export again" && model.error == nil) + await model.download() + let file = try #require(model.file) + defer { try? FileManager.default.removeItem(at: file) } + #expect(try String(contentsOf: file, encoding: .utf8) == "tar-archive") + await model.start() + #expect(model.export?.state == "building" && model.file == nil) + #expect(stub.seen.filter { $0.route == "POST /api/export" }.count == 2) + } + + /// Asks and makes run one after another: an older read held in flight + /// never lands after the new export made behind it, and nothing is sent + /// before it answers. + @Test func anOlderExportReadNeverLandsAfterANewerExport() async throws { + let gate = Gate() + let building = #"{"id":"0123456789abcdef","state":"building","startedAt":1,"progress":{"done":0,"total":3,"bytes":0}}"# + let stub = Stub { seen, before in + switch seen.route { + case "GET /api/export": + gate.pass() + return (200, #"{"export":null}"#) + // a make sent while the older read is still held fails the test + case "POST /api/export": return gate.isOpen ? (200, building) : (500, #"{"message":"sent before the read answered"}"#) + default: return (404, "{}") + } + } + let model = ExportModel(api: try stub.api()) + let read = Task { await model.refresh() } + try await waitUntil("the read held") { stub.seen.contains { $0.route == "GET /api/export" } } + let make = Task { await model.start() } + await Task.yield() + #expect(model.busy && !stub.seen.contains { $0.route == "POST /api/export" }) + gate.open() + await read.value + await make.value + #expect(model.export?.state == "building" && model.error == nil && !model.busy) + #expect(stub.seen.map(\.route) == ["GET /api/export", "POST /api/export"]) + } + + /// A downloaded archive goes with its export: refreshed to another, + /// sharing needs that one downloaded, and a download still in flight + /// for one no longer shown is never kept. + @Test func aDownloadedArchiveGoesWithItsExport() async throws { + let gate = Gate() + let view: @Sendable (String) -> String = { id in #"{"id":"\#(id)","state":"ready","startedAt":1,"progress":{"done":1,"total":1,"bytes":1},"size":1,"download":"/api/export/\#(id)/download"}"# } + let (a, b) = ("aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb") + let stub = Stub { seen, before in + let reads = before.filter { $0.route == "GET /api/export" }.count + switch seen.route { + // A, then A again, then B (made on the web) + case "GET /api/export": return (200, #"{"export":\#(view(reads < 2 ? a : b))}"#) + case "GET /api/export/\(a)/download": + // its second download is held until B is shown + if before.contains(where: { $0.route == seen.route }) { gate.pass() } + return (200, "archive A") + case "GET /api/export/\(b)/download": return (200, "archive B") + default: return (404, "{}") + } + } + let model = ExportModel(api: try stub.api()) + await model.refresh() + await model.download() + let first = try #require(model.file) + #expect(try String(contentsOf: first, encoding: .utf8) == "archive A") + + // A's download again, held; meanwhile the export shown becomes B + await model.refresh() + let held = Task { await model.download() } + try await waitUntil("A's download held") { stub.seen.filter { $0.route == "GET /api/export/\(a)/download" }.count == 2 } + await model.refresh() + #expect(model.export?.id == b && model.file == nil && !FileManager.default.fileExists(atPath: first.path)) + gate.open() + await held.value + #expect(model.file == nil, "a download for an export no longer shown is never kept") + await model.download() + let second = try #require(model.file) + defer { try? FileManager.default.removeItem(at: second) } + #expect(try String(contentsOf: second, encoding: .utf8) == "archive B") + } + + @Test func aComputerSaysItsPhase() throws { + let listed = try JSONDecoder().decode(JSONValue.self, from: Data(#"{"computers":[{"computer":"c1","owner":"npub1me","image":"stub","phase":"wont_wake","generation":3,"agents":[]}],"support":null}"#.utf8)) + #expect(ComputerPhase.said(listed["computers"].array.first?["phase"].string) == "Won’t wake") + #expect(ComputerPhase.said("awake") == "Awake" && ComputerPhase.said(nil) == "—") + } + + /// A web sheet's spent redemption is never opened again: each load + /// drops it before asking for a new one, and a failed ask leaves none. + @Test func aWebSheetRetryNeverReusesASpentRedemption() async throws { + let stub = Stub { seen, before in + switch seen.route { + case "GET /api/f/reading--p1/status": return (200, #"{"npub":"npub1app","urls":{"canonical":"http://reading--p1.fragment.localhost:8790/"}}"#) + case "POST /api/f/reading--p1/session": + let n = before.filter { $0.route == seen.route }.count + if n == 1 { return (503, #"{"message":"Try again shortly."}"#) } + return (200, #"{"consentRequired":false,"url":"http://reading--p1.fragment.localhost:8790/__signin?token=t\#(n)"}"#) + default: return (404, "{}") + } + } + let model = WebSheetModel(fragment: FragmentSummary(name: "reading--p1", kind: "app"), api: try stub.api()) + await model.load() + #expect(model.url?.query == "token=t0" && model.error == nil) + // its page failed after the redemption was spent; Try again + model.error = "The app could not be opened (HTTP 502)." + await model.load() + #expect(model.url == nil && model.error == "Try again shortly.") + await model.load() + #expect(model.url?.query == "token=t2" && model.error == nil) + } + + @Test func anAgentJoinsAChatAsAnEditorByItsNpub() async throws { + let stub = Stub { seen, before in + seen.route == "PUT /api/f/talk--c9/members/npub1moss" ? (200, #"{"principal":"npub1moss","role":"editor"}"#) : Self.platform(seen, before) + } + let api = try stub.api() + try await api.join(FragmentSummary(name: "moss--a1", kind: "agent"), to: FragmentSummary(name: "talk--c9", kind: "chat")) + #expect(stub.seen.map(\.route) == ["GET /api/f/moss--a1/status", "PUT /api/f/talk--c9/members/npub1moss"]) + #expect(stub.seen[1].json == .object(["role": .string("editor")])) + } + + @Test func aNewChatIsMadeFromTheChatTemplate() async throws { + let stub = Stub { seen, _ in seen.route == "POST /api/fragments" ? (200, #"{"name":"mobile-chat--x1","npub":"npub1c"}"#) : (404, "{}") } + let chat = try await stub.api().createChat() + #expect(chat.name == "mobile-chat--x1" && chat.kind == "chat") + #expect(stub.seen[0].json == .object(["label": .string("mobile-chat"), "template": .string("chat"), "title": .string("New chat")])) + } + + @Test func cachedDraftsAreNeverEvictedAtCapacity() throws { + let stub = Stub { _, _ in (404, "{}") } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let library = Library() + for index in 0..<32 { + try library.session(for: FragmentSummary(name: "draft-\(index)", kind: "chat"), api: api).composer = "A draft to preserve" + } + #expect(throws: APIError.self) { try library.session(for: FragmentSummary(name: "one-more", kind: "chat"), api: api) } + #expect(library.sessions.count == 32) + #expect(library.sessions.values.allSatisfy { $0.composer == "A draft to preserve" }) + let again = try library.session(for: FragmentSummary(name: "draft-3", kind: "chat"), api: api) + #expect(again === library.sessions["draft-3"]) + } + + @Test func renameKeepsTheManifestAndDeploysIt() async throws { + let stub = Stub { seen, _ in + switch seen.route { + case "GET /api/f/talk--c1/manifest": (200, #"{"name":"talk--c1","meta":{"title":"Old","template":"chat"},"channels":{"chat":{}}}"#) + case "POST /api/f/talk--c1/files": (200, #"{"commit":"abc"}"#) + case "POST /api/f/talk--c1/deploy": (200, #"{"live":"abc","canonical":"https://talk--c1.finite.test/"}"#) + default: (404, "{}") + } + } + let api = try stub.api() + let chat = FragmentSummary(name: "talk--c1", kind: "chat") + await #expect(throws: APIError.self) { try await api.rename(chat, title: " ") } + #expect(stub.seen.isEmpty) + try await api.rename(chat, title: " A name to keep ") + #expect(stub.seen.map(\.route) == ["GET /api/f/talk--c1/manifest", "POST /api/f/talk--c1/files", "POST /api/f/talk--c1/deploy"]) + let file = stub.seen[1].json["files"].array.first + #expect(file?["path"].string == "fragment.json") + let manifest = try JSONDecoder().decode(JSONValue.self, from: Data(try #require(file?["text"].string).utf8)) + #expect(manifest["meta"] == .object(["title": .string("A name to keep"), "template": .string("chat")])) + #expect(manifest["channels"] == .object(["chat": .object([:])])) + } + + @Test func archivingIsThePersonsOwnView() async throws { + let stub = Stub { seen, before in + seen.route == "PUT /api/fragments/talk--c1/archived" ? (200, #"{"ok":true}"#) : Self.platform(seen, before) + } + let api = try stub.api() + let library = Library() + await library.archive(FragmentSummary(name: "talk--c1", kind: "chat"), api: api) + #expect(stub.seen.first?.json == .object(["archived": .bool(true)])) + await library.archive(FragmentSummary(name: "talk--c1", kind: "chat", archived: true), api: api) + #expect(stub.seen.last { $0.method == "PUT" }?.json == .object(["archived": .bool(false)])) + } + + @Test func anAttachmentIsPutByItsHashAndARefusalSaysWhy() async throws { + let stub = Stub { seen, _ in + seen.body.count > 4 ? (413, #"{"error":"too_large","message":"a blob is at most 4 bytes here"}"#) : (200, #"{"ok":true,"stored":true}"#) + } + let api = try stub.api() + let file = try await api.upload(Data("abc".utf8), name: "a.txt", type: "text/plain", to: "talk--c1") + #expect(file.sha256 == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" && file.size == 3) + #expect(stub.seen[0].route == "PUT /api/f/talk--c1/blobs/\(file.sha256)" && stub.seen[0].body == Data("abc".utf8)) + do { + _ = try await api.upload(Data("too big".utf8), name: "b.txt", type: "text/plain", to: "talk--c1") + Issue.record("a refused upload is an error") + } catch APIError.refused(let message) { #expect(message == "a blob is at most 4 bytes here") } + } + + @Test func anEndedAuthorizationAsksForTheConnectionAgain() async throws { + let stub = Stub { _, _ in (401, #"{"error":"unauthenticated","message":"the key was revoked"}"#) } + let api = try stub.api() + let heard = Heard() + let observer = NotificationCenter.default.addObserver(forName: .finiteAuthorizationFailed, object: nil, queue: nil) { note in + if let key = note.userInfo?["key"] as? String { heard.keys.withLock { $0.append(key) } } + } + defer { NotificationCenter.default.removeObserver(observer) } + await #expect(throws: APIError.self) { let _: JSONValue = try await api.request("api/identities/me") } + #expect(heard.keys.withLock { $0.contains(api.identity.publicKey) }) + } +} + +private final class Heard: Sendable { let keys = Mutex<[String]>([]) } diff --git a/ios/Tests/Stub.swift b/ios/Tests/Stub.swift new file mode 100644 index 000000000..c42226eb7 --- /dev/null +++ b/ios/Tests/Stub.swift @@ -0,0 +1,167 @@ +import Foundation +import Synchronization +@testable import Fragment + +/// A request the app made, as a stub saw it. +struct Seen: Sendable { + let method: String + let path: String + let query: [String: String] + let body: Data + let authorization: String? + var json: JSONValue { (try? JSONDecoder().decode(JSONValue.self, from: body)) ?? .null } + var route: String { "\(method) \(path)" } +} + +/// A test's stand-in for the platform, on a host of its own: each request +/// is answered by the test's handler (through `StubProtocol`), each socket +/// is a `ScriptedSocket` the test drives, and both are recorded. Answers +/// are written by hand here; the wire they stand for is docs/api.md. +final class Stub: Sendable { + /// The answer to a request, given every request this stub saw before it. + typealias Handler = @Sendable (Seen, [Seen]) -> (Int, String) + private static let live = Mutex<[String: Stub]>([:]) + static func named(_ host: String) -> Stub? { live.withLock { $0[host] } } + + let host = "stub-\(UUID().uuidString.lowercased()).test" + private let handler: Handler + private let state = Mutex<(seen: [Seen], sockets: [ScriptedSocket])>(([], [])) + + init(_ handler: @escaping Handler) { + self.handler = handler + Self.live.withLock { $0[host] = self } + } + deinit { Self.live.withLock { _ = $0.removeValue(forKey: host) } } + + var seen: [Seen] { state.withLock { $0.seen } } + var sockets: [ScriptedSocket] { state.withLock { $0.sockets } } + func answer(_ request: Seen) -> (Int, String) { + let before = state.withLock { s in defer { s.seen.append(request) }; return s.seen } + return handler(request, before) + } + + /// An API on this stub, signed by a fresh key. + func api(accountID: String? = nil) throws -> FragmentAPI { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubProtocol.self] + return FragmentAPI(baseURL: URL(string: "https://\(host)")!, identity: try DeviceIdentity(), accountID: accountID, + session: URLSession(configuration: configuration), sockets: { [weak self] request in + let socket = ScriptedSocket(request: request) + self?.state.withLock { $0.sockets.append(socket) } + return socket + }) + } +} + +/// Each request answered on a queue of its own, never on the thread URLSession +/// loads every stubbed request on: a test that holds one answer (`Gate`) +/// holds no other. +final class StubProtocol: URLProtocol, @unchecked Sendable { + private let stopped = Mutex(false) + override class func canInit(with request: URLRequest) -> Bool { request.url?.host.flatMap(Stub.named) != nil } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let url = request.url, let stub = url.host.flatMap(Stub.named) else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotFindHost)); return + } + var query: [String: String] = [:] + for item in URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] { query[item.name] = item.value ?? "" } + let seen = Seen(method: request.httpMethod ?? "GET", path: url.path, query: query, body: request.httpBody ?? Self.read(request.httpBodyStream), + authorization: request.value(forHTTPHeaderField: "Authorization")) + DispatchQueue.global().async { [self] in + let (status, body) = stub.answer(seen) + guard !stopped.withLock({ $0 }) else { return } + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + } + override func stopLoading() { stopped.withLock { $0 = true } } + private static func read(_ stream: InputStream?) -> Data { + guard let stream else { return Data() } + stream.open(); defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16_384) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + guard n > 0 else { break } + data.append(buffer, count: n) + } + return data + } +} + +/// A `__live` socket the test plays the server's side of: frames it pushes +/// arrive in order, `drop` ends it as a lost connection, and every frame +/// the app sent is kept. +final class ScriptedSocket: LiveSocket { + let request: URLRequest + private let state = Mutex<(queue: [Result], waiting: CheckedContinuation?, sent: [JSONValue], cancelled: Bool)>(([], nil, [], false)) + init(request: URLRequest) { self.request = request } + + var response: URLResponse? { nil } + var closeCode: URLSessionWebSocketTask.CloseCode { .invalid } + var sent: [JSONValue] { state.withLock { $0.sent } } + var cancelled: Bool { state.withLock { $0.cancelled } } + func resume() {} + func send(_ message: URLSessionWebSocketTask.Message) async throws { + guard case .string(let text) = message else { throw URLError(.cannotDecodeContentData) } + let frame = try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) + state.withLock { $0.sent.append(frame) } + } + func receive() async throws -> URLSessionWebSocketTask.Message { + try await withCheckedThrowingContinuation { continuation in + let next: Result? = state.withLock { s in + if !s.queue.isEmpty { return s.queue.removeFirst() } + if s.cancelled { return .failure(URLError(.cancelled)) } + s.waiting = continuation + return nil + } + if let next { continuation.resume(with: next) } + } + } + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + let waiting = state.withLock { s in s.cancelled = true; defer { s.waiting = nil }; return s.waiting } + waiting?.resume(throwing: URLError(.cancelled)) + } + func push(_ frame: String) { deliver(.success(.string(frame))) } + func drop() { deliver(.failure(URLError(.networkConnectionLost))) } + private func deliver(_ result: Result) { + let waiting = state.withLock { s -> CheckedContinuation? in + guard let waiting = s.waiting else { s.queue.append(result); return nil } + s.waiting = nil + return waiting + } + waiting?.resume(with: result) + } +} + +/// Polls `condition` on the main actor until it holds, or records a failure. +@MainActor +func waitUntil(_ what: String, timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while !condition() { + guard clock.now < deadline else { throw StubTimeout(what: what) } + try await Task.sleep(for: .milliseconds(20)) + } +} + +struct StubTimeout: Error, CustomStringConvertible { let what: String; var description: String { "timed out waiting for \(what)" } } + +/// A record frame or an answer's record, as the server writes it. +func record(_ channel: String, _ seq: Int64, principal: String = "npub1agent", body: String) -> String { + #"{"channel":"\#(channel)","seq":\#(seq),"at":\#(seq * 1000),"principal":"\#(principal)","kind":"message","body":\#(body)}"# +} + +/// Holds the stub's answers it is asked in, until opened: a test's way to +/// keep one request in flight while others are made. +final class Gate: Sendable { + private let opened = DispatchSemaphore(value: 0) + private let state = Mutex(false) + var isOpen: Bool { state.withLock { $0 } } + /// On the stub's own thread: waits until the test opens it. + func pass() { if !isOpen { opened.wait(); opened.signal() } } + func open() { state.withLock { $0 = true }; opened.signal() } +} diff --git a/ios/Tests/TimelineTests.swift b/ios/Tests/TimelineTests.swift new file mode 100644 index 000000000..57cf15184 --- /dev/null +++ b/ios/Tests/TimelineTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import Fragment + +struct TimelineTests { + private func record(_ seq: Int64, channel: String = "chat", principal: String = "agent", body: JSONValue) -> ChannelRecord { + ChannelRecord(channel: channel, seq: seq, at: 100, principal: principal, kind: "message", body: body) + } + private func frame(_ json: String) throws -> LiveFrame { try JSONDecoder().decode(LiveFrame.self, from: Data(json.utf8)) } + + @Test func replayAndOlderPagesNeverRegressLiveCursor() { + var timeline = Timeline() + let latest = record(20, body: .string("latest")) + timeline.insert(latest) + timeline.insert(latest) + timeline.insert(record(2, body: .string("earlier")), advancingCursor: false) + timeline.insert(record(10, body: .string("middle")), advancingCursor: false) + #expect(timeline.records.map(\.seq) == [2, 10, 20]) + #expect(timeline.cursors["chat"] == 20) + } + + @Test func draftIsWholeTextAndReplacedOnlyByItsAuthorsCommit() throws { + var timeline = Timeline() + try timeline.apply(frame(#"{"type":"draft","channel":"chat","turn":"a","principal":"agent","text":"One","at":100}"#)) + try timeline.apply(frame(#"{"type":"draft","channel":"chat","turn":"a","principal":"agent","text":"One two","at":101}"#)) + #expect(timeline.drafts["a"]?.text == "One two") + timeline.insert(record(1, principal: "someone-else", body: .object(["turn": .string("a"), "text": .string("forged")]))) + #expect(timeline.drafts["a"] != nil) + timeline.insert(record(2, body: .object(["turn": .string("a"), "text": .string("One two")]))) + #expect(timeline.drafts.isEmpty) + #expect(timeline.records.count == 2) + } + + @Test func replayedRecordDoesNotEraseNextPartOfTurn() throws { + var timeline = Timeline() + let commit = record(1, body: .object(["turn": .string("a"), "text": .string("First part")])) + timeline.insert(commit) + try timeline.apply(frame(#"{"type":"draft","channel":"chat","turn":"a","principal":"agent","text":"Next part","at":110}"#)) + timeline.insert(commit) + #expect(timeline.drafts["a"]?.text == "Next part") + timeline.disconnected() + #expect(timeline.drafts.isEmpty) + #expect(timeline.records.count == 1) + } + + @Test func channelsHaveIndependentCursorsAndUnknownKindsDecode() throws { + var timeline = Timeline() + try timeline.apply(frame(#"{"type":"record","channel":"chat","seq":8,"at":1,"principal":"person","kind":"message","body":"plain text"}"#)) + try timeline.apply(frame(#"{"type":"record","channel":"work","seq":2,"at":2,"principal":"agent","kind":"message","body":{"kind":"future.kind","newField":true}}"#)) + #expect(timeline.cursors == ["chat": 8, "work": 2]) + #expect(timeline.records.first?.text == "plain text") + #expect(timeline.records.last?.isMessage == false) + #expect(throws: (any Error).self) { + try timeline.apply(frame(#"{"type":"record","channel":"chat","seq":-1}"#)) + } + } + + @Test func turnEndClearsWorkingAndDraft() throws { + var timeline = Timeline() + timeline.insert(record(1, channel: "work", body: .object(["kind": .string("turn.start"), "turn": .string("a")]))) + #expect(timeline.activeTurns == ["a"]) + try timeline.apply(frame(#"{"type":"draft","channel":"chat","turn":"a","principal":"agent","text":"unfinished","at":100}"#)) + timeline.insert(record(2, channel: "work", body: .object(["kind": .string("turn.end"), "turn": .string("a")]))) + #expect(timeline.activeTurns.isEmpty) + #expect(timeline.drafts.isEmpty) + } + + // ---- which turns run, as the web's chat page decides it (chat.js + // `turnOf`, control.js `running`), records read in any order ---- + private func work(_ seq: Int64, _ kind: String, _ turn: String, by agent: String = "npub1a") -> ChannelRecord { + ChannelRecord(channel: "work", seq: seq, at: seq * 10, principal: agent, kind: "message", body: .object(["kind": .string(kind), "turn": .string(turn)])) + } + + @Test func anEndBeforeItsStartCountsOnlyFromTheAgentThatBeganIt() { + // its own agent's end, read before its start (paging back): it ended + var same = Timeline() + same.learn(work(9, "turn.end", "t")) + #expect(same.awaitsAStart) + same.learn(work(1, "turn.start", "t")) + #expect(same.activeTurns.isEmpty && !same.awaitsAStart) + // another agent's end naming it: A's turn still runs once its start is read + var other = Timeline() + other.learn(work(9, "turn.end", "t", by: "npub1b")) + other.learn(work(5, "turn.step", "t")) + other.learn(work(1, "turn.start", "t")) + #expect(other.activeTurns == ["t"]) + other.learn(work(12, "turn.end", "t")) + #expect(other.activeTurns.isEmpty) + } + + /// Codex's probe: a 1000-record backlog that holds a boundary turn's end + /// (its start older), 499 whole turns and a busy turn's step, then the + /// page before it: the ended turn never runs again; the busy one does. + @Test func pagingBackNeverRevivesAnEndedTurn() { + var timeline = Timeline() + timeline.learn(work(1001, "turn.end", "boundary")) + for i in Int64(0)..<499 { + timeline.learn(work(1002 + 2 * i, "turn.start", "done\(i)")) + timeline.learn(work(1003 + 2 * i, "turn.end", "done\(i)")) + } + timeline.learn(work(2000, "turn.step", "busy")) + #expect(timeline.activeTurns.isEmpty && timeline.awaitsAStart) + for (seq, turn) in [(Int64(500), "boundary"), (Int64(990), "busy")] { timeline.learn(work(seq, "turn.start", turn)) } + #expect(timeline.activeTurns == ["busy"] && !timeline.awaitsAStart) + } + + @Test func pastItsBoundOnlyTurnsBegunAndEndedGo() { + var timeline = Timeline() + timeline.learn(work(1_000_000, "turn.end", "late")) + for i in Int64(0).. String { + #"{"type":"record",\#(record(channel, seq, body: body).dropFirst())"# + } + + @Test func aLostAcknowledgementIsRetriedWithTheSameIDAndBody() async throws { + let route = posts + let stub = Stub { seen, before in + guard seen.route == route else { return (404, #"{"error":"not_found","message":"no route"}"#) } + // the first try's append committed, and its answer was lost + if !before.contains(where: { $0.route == route }) { return (503, #"{"error":"unavailable","message":"Try again shortly."}"#) } + return posted(seen, seq: 7, replayed: true) + } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let session = ChatSession(fragment: chat, api: api) + session.composer = "Hello there" + await session.send() + let failed = try #require(session.pending.first) + #expect(failed.failed && session.composer.isEmpty && session.error == "Try again shortly.") + await session.retry(failed) + #expect(session.pending.isEmpty) + #expect(session.timeline.records.map(\.seq) == [7]) + let sent = stub.seen.filter { $0.route == route } + #expect(sent.count == 2) + #expect(sent[0].json == sent[1].json && sent[0].json["id"].string == failed.id) + #expect(sent[0].json["body"]["client_id"].string == failed.id && sent[0].json["body"]["text"].string == "Hello there") + #expect(stub.seen.allSatisfy { $0.authorization?.hasPrefix("Nostr ") == true }) + } + + @Test func aRefusedPostLeavesTheOutboxAndItsWordsReturnToTheComposer() async throws { + let route = posts + let stub = Stub { seen, _ in + seen.route == route ? (409, #"{"error":"conflicting_body","message":"this post id was already used with a different body"}"#) : (404, "{}") + } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let session = ChatSession(fragment: chat, api: api) + session.composer = "Words to keep" + await session.send() + #expect(session.pending.isEmpty) + #expect(session.composer == "Words to keep") + #expect(session.error == "this post id was already used with a different body") + #expect(stub.seen.filter { $0.route == route }.count == 1) + } + + @Test func aRateLimitedPostStaysInTheOutboxToRetry() async throws { + let route = posts + let stub = Stub { seen, _ in seen.route == route ? (429, #"{"error":"rate_limited","message":"Slow down."}"#) : (404, "{}") } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let session = ChatSession(fragment: chat, api: api) + session.composer = "Again later" + await session.send() + #expect(session.pending.first?.failed == true && session.composer.isEmpty) + } + + @Test func aDroppedStreamCatchesUpFromItsCursorWithoutDuplicates() async throws { + let stub = Stub { _, _ in (404, "{}") } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let session = ChatSession(fragment: chat, api: api) + let run = Task { await session.run() } + defer { run.cancel() } + try await waitUntil("the first socket") { stub.sockets.count == 1 } + let first = stub.sockets[0] + #expect(first.request.url?.absoluteString == "wss://\(stub.host)/f/\(chat.name)/__live") + #expect(first.request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Nostr ") == true) + first.push(hello) + try await waitUntil("its subscriptions") { first.sent.count == 2 } + #expect(first.sent.map { $0["channel"].string } == ["chat", "work"]) + // the chat's last messages, and a page of work (a long turn's steps, and its start) + #expect(first.sent.allSatisfy { $0["type"].string == "subscribe" && $0["after"] == .null }) + #expect(first.sent.map { $0["last"] } == [.number(50), .number(1000)]) + for seq: Int64 in [1, 2] { first.push(frame("chat", seq, body: #"{"text":"m\#(seq)"}"#)) } + first.push(#"{"type":"subscribed","channel":"chat","next":2,"more":false}"#) + first.push(#"{"type":"subscribed","channel":"work","next":0,"more":false}"#) + first.push(#"{"type":"draft","channel":"chat","principal":"npub1agent","turn":"t1","text":"Hal","at":2500}"#) + try await waitUntil("caught up, a reply being written") { session.caughtUp && session.timeline.drafts["t1"]?.text == "Hal" } + + first.drop() + try await waitUntil("the socket dropped") { !session.connected } + #expect(session.timeline.drafts.isEmpty) + try await waitUntil("the second socket") { stub.sockets.count == 2 } + let second = stub.sockets[1] + second.push(hello) + try await waitUntil("its subscriptions") { second.sent.count == 2 } + #expect(second.sent[0]["channel"].string == "chat" && second.sent[0]["after"] == .number(2) && second.sent[0]["last"] == .null) + // the page from the cursor may repeat what was seen: it is kept once + second.push(frame("chat", 2, body: #"{"text":"m2"}"#)) + second.push(frame("chat", 3, body: #"{"text":"Hallo","turn":"t1"}"#)) + second.push(#"{"type":"subscribed","channel":"chat","next":3,"more":false}"#) + try await waitUntil("caught up again") { session.caughtUp && session.timeline.records.count == 3 } + #expect(session.timeline.records.map(\.seq) == [1, 2, 3]) + #expect(session.timeline.cursors["chat"] == 3) + #expect(session.timeline.drafts.isEmpty) + } + + @Test func stopPostsAStopForEachActiveTurn() async throws { + let route = posts + let stub = Stub { seen, _ in seen.route == route ? posted(seen, seq: 9, replayed: false) : (404, "{}") } + let api = try stub.api() + defer { try? ChatDraftStore.removeAccount(api: api) } + let session = ChatSession(fragment: chat, api: api) + let run = Task { await session.run() } + defer { run.cancel() } + try await waitUntil("a socket") { stub.sockets.count == 1 } + let socket = stub.sockets[0] + socket.push(hello) + socket.push(frame("work", 4, body: #"{"kind":"turn.start","turn":"t4"}"#)) + try await waitUntil("a turn running") { session.timeline.activeTurns == ["t4"] } + await session.stop() + let stop = try #require(stub.seen.first { $0.route == route }) + #expect(stop.json == .object(["id": .string("stop:t4"), "body": .object(["kind": .string("stop"), "turn": .string("t4")])])) + } + + /// A turn begun before the backlog (a start, then more steps than one + /// page holds) still runs, and Stop stops it, opening the chat and + /// opening it again: its start is read back from earlier work. + @Test func aBusyTurnKeepsItsStopPastTheBacklog() async throws { + let route = posts + // work 1 to 1100: turn t9's start, then its steps; the backlog is the + // last 1000, and the 100 before it are large ones (about 60 KB each, + // within a record's 64 KiB): a page of them is 3 MB + let large = String(repeating: "x", count: 60_000) + let work: @Sendable (Int64) -> String = { seq in + record("work", seq, body: seq == 1 ? #"{"kind":"turn.start","turn":"t9"}"# : #"{"kind":"turn.step","turn":"t9","text":"\#(seq > 100 ? "step" : large)"}"#) + } + let stub = Stub { seen, _ in + switch seen.route { + case "GET /api/f/chat--abc123/channels/work": + let before = Int64(seen.query["before"] ?? "") ?? 0, limit = Int64(seen.query["limit"] ?? "") ?? 0 + let page = (max(1, before - limit).. (Int, String) { + let body = String(decoding: (try? JSONEncoder().encode(seen.json["body"])) ?? Data("null".utf8), as: UTF8.self) + return (200, #"{"record":\#(record("chat", seq, principal: "npub1me", body: body)),"replayed":\#(replayed)}"#) +} diff --git a/ios/UITests/FragmentUITests.swift b/ios/UITests/FragmentUITests.swift new file mode 100644 index 000000000..0ffbb5d6b --- /dev/null +++ b/ios/UITests/FragmentUITests.swift @@ -0,0 +1,49 @@ +import XCTest + +/// What the app shows with no server behind it. Flows against a real +/// platform (`cargo xtask dev`, paired through `/cli`) are their own suite. +@MainActor +final class FragmentUITests: XCTestCase { + private func screenshot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()); shot.name = name; shot.lifetime = .keepAlways; add(shot) + } + + /// A UI test's launch keeps a store of its own and reaches loopback + /// alone (`--ui-test`): even an approved account past loopback saved + /// there is never used, so it asks for the server, and a server that + /// does not answer says so, leaving the screen to try again. + func testConnectAsksForTheServerAndSaysWhenItIsUnreachable() throws { + let app = XCUIApplication() + // a closed port on this machine: nothing answers it + app.launchArguments = ["--ui-test", "--ui-test-saved-account", "https://finite.example", "--pair-server", "http://127.0.0.1:9"] + app.launch() + let server = app.textFields["accountServer"] + XCTAssertTrue(server.waitForExistence(timeout: 10), "a saved account past loopback is never loaded") + XCTAssertTrue(app.staticTexts["This launch reaches only this machine."].exists) + XCTAssertFalse(app.buttons["forgetAccount"].exists) + // never ask any other server than the closed port + guard server.value as? String == "http://127.0.0.1:9" else { + return XCTFail("Connect shows \(server.value ?? "nothing"), not the --pair-server address") + } + XCTAssertFalse(app.buttons["tryPreview"].exists, "there is no unsigned preview") + screenshot(app, "Connect") + app.buttons["pairAccount"].tap() + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "could not connect")).firstMatch.waitForExistence(timeout: 20)) + XCTAssertFalse(app.staticTexts["pairingCode"].exists) + XCTAssertTrue(app.buttons["pairAccount"].isEnabled) + } + + /// The boundary holds for the address Connect starts with, too: a test + /// launch tapping Connect for Finite's own server asks nothing of it. + func testATestLaunchRefusesAServerPastLoopback() { + let app = XCUIApplication() + app.launchArguments = ["--ui-test"] + app.launch() + let server = app.textFields["accountServer"] + XCTAssertTrue(server.waitForExistence(timeout: 10)) + XCTAssertEqual(server.value as? String, "https://finite.chat") + app.buttons["pairAccount"].tap() + XCTAssertTrue(app.staticTexts["This launch reaches only this machine."].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["pairingCode"].exists) + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 000000000..c93dadeba --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,103 @@ +name: Fragment +options: + # XcodeGen 2.42 needs the Icon Composer package kept as one resource. + fileTypes: + icon: + file: true + bundleIdPrefix: build.finite + deploymentTarget: + iOS: "26.0" +packages: + secp256k1: + url: https://github.com/21-DOT-DEV/swift-secp256k1.git + exactVersion: 0.21.1 +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + SWIFT_TREAT_WARNINGS_AS_ERRORS: YES + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_STYLE: Automatic + PRODUCT_NAME: "$(TARGET_NAME)" + SDKROOT: iphoneos + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" + IPHONEOS_DEPLOYMENT_TARGET: "26.0" + ALWAYS_SEARCH_USER_PATHS: NO + ONLY_ACTIVE_ARCH: YES + CLANG_ENABLE_MODULES: YES + ENABLE_TESTABILITY: YES + GENERATE_INFOPLIST_FILE: YES + MARKETING_VERSION: "0.3" + CURRENT_PROJECT_VERSION: "1" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DEBUG" + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" +targets: + Fragment: + type: application + platform: iOS + sources: [Sources] + dependencies: + - package: secp256k1 + product: P256K + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: build.finite.fragment + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + INFOPLIST_KEY_CFBundleDisplayName: Finite + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + configs: + Debug: + INFOPLIST_KEY_NSLocalNetworkUsageDescription: "Connect to your local Finite development server." + info: + path: Info.plist + properties: + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + NSLocalNetworkUsageDescription: "Connect to your local Finite development server." + NSMicrophoneUsageDescription: "Record voice memos to send to your agents." + CFBundleDisplayName: Finite + UILaunchScreen: {} + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + FragmentTests: + type: bundle.unit-test + platform: iOS + sources: [Tests] + dependencies: + - target: Fragment + settings: + base: + GENERATE_INFOPLIST_FILE: YES + BUNDLE_LOADER: "$(TEST_HOST)" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Fragment.app/Fragment" + FragmentUITests: + type: bundle.ui-testing + platform: iOS + sources: [UITests] + dependencies: + - target: Fragment + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: Fragment +schemes: + Fragment: + build: + targets: + Fragment: all + run: + config: Debug + test: + targets: [FragmentTests, FragmentUITests] diff --git a/xtask/src/eval_sinks.rs b/xtask/src/eval_sinks.rs index 3625de7b0..537e112ed 100644 --- a/xtask/src/eval_sinks.rs +++ b/xtask/src/eval_sinks.rs @@ -66,7 +66,8 @@ use std::path::Path; use anyhow::{ensure, Context, Result}; /// Where runtime code is: what ships or runs for people. -pub const RUNTIME: [(&str, &str); 8] = [ +pub const RUNTIME: [(&str, &str); 9] = [ + ("ios/", "the native iOS client (its Tests and UITests are tests)"), ("cell/", "the Worker: its Rust, entry.mjs and the platform's JavaScript, and the shell"), ("templates/", "the templates' apps, sites and skills, which fragments and computers run"), ("images/", "the computer images: the bridge, Hermes' boot, plugins and hooks, the screen pages"), @@ -92,7 +93,9 @@ pub const NOT_RUNTIME: [(&str, &str); 9] = [ /// What runs a string as script, each with what it does: the name a failure /// reports, and an `ALLOWED` entry names. -pub const REFUSED: [(&str, &str); 17] = [ +pub const REFUSED: [(&str, &str); 19] = [ + ("evaluateJavaScript", "WKWebView: runs a JavaScript string in a page"), + ("callAsyncJavaScript", "WKWebView: runs a JavaScript function body from a string"), ("Runtime.evaluate", "DevTools: runs a string as script in a page"), ("callFunctionOn", "DevTools' Runtime.callFunctionOn: runs a function's source in a page"), ("compileScript", "DevTools' Runtime.compileScript: compiles a string as script, for Runtime.runScript"), @@ -131,6 +134,8 @@ const CARDS: &str = "cell/src/card.rs"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Lang { Rust, + /// Swift: its strings' text as literals, each interpolation's code as code (`lex_swift`). + Swift, JavaScript, /// a page: its inline scripts, as JavaScript Html, @@ -143,6 +148,7 @@ pub fn lang(path: &str) -> Option { let name = path.rsplit('/').next().unwrap_or(path); match name.rsplit_once('.').map(|(_, ext)| ext) { Some("rs") => Some(Lang::Rust), + Some("swift") => Some(Lang::Swift), Some("js" | "mjs" | "cjs") => Some(Lang::JavaScript), Some("html") => Some(Lang::Html), Some("py" | "sh") => Some(Lang::Hash), @@ -201,7 +207,7 @@ pub fn select(paths: impl Iterator) -> Result let mut found = vec![]; for path in paths { let runtime = RUNTIME.iter().any(|(dir, _)| path.starts_with(dir)); - let test = path.split('/').any(|c| c == "tests"); + let test = path.split('/').any(|c| matches!(c, "tests" | "Tests" | "UITests")); let Some(lang) = lang(&path).filter(|_| runtime && !test && !crate::js_syntax::vendored(&path)) else { continue }; found.push((path, lang)); ensure!(found.len() <= FILES_MAX, "more than {FILES_MAX} runtime files to read: is node_modules or target no longer ignored?"); @@ -237,6 +243,14 @@ pub fn hits(file: &File) -> Vec { devtools(b, &parts, &mut at); drivers(b, &parts, &mut at); } + Lang::Swift => { + let parts = lex_swift(b); + devtools(b, &parts, &mut at); + drivers(b, &parts, &mut at); + for word in ["evaluateJavaScript", "callAsyncJavaScript"] { + at.extend(words(b, &parts, word, code).filter(|&i| call_args(b, &parts, i + word.len()).is_some()).map(|i| (i, word))); + } + } Lang::Rust => { let parts = lex_rust(b); devtools(b, &parts, &mut at); @@ -660,6 +674,91 @@ fn lex_rust(b: &[u8]) -> Vec { parts } +/// Swift: `//` comments and nested `/* */`; strings (`"…"`, multiline +/// `"""…"""`, raw `#"…"#` with any number of `#`) as literals, but the code +/// of each interpolation in one (`\(…)`, `\#(…)` in a raw string with one +/// `#`) as code, its own strings and interpolations read the same way. No +/// `'` quotes anything in Swift. A regular expression literal is read as +/// code: a name in one is found, never missed. +fn lex_swift(b: &[u8]) -> Vec { + let mut parts = vec![Part::Code; b.len()]; + // each string an interpolation is open in: its `#`s, whether it is + // multiline, and the paren depth its `(` opened at (its `)` closes it) + let mut open: Vec<(usize, bool, usize)> = vec![]; + let mut depth = 0usize; + let mut i = 0; + // bounded: each pass moves `i` forward + while i < b.len() { + let (c, next) = (b[i], b.get(i + 1).copied()); + let start = i; + if c == b')' && open.last().is_some_and(|&(_, _, d)| d == depth) { + let (hashes, multiline, _) = open.pop().expect("an interpolation is open"); + parts[i] = Part::Literal; + i = swift_text(b, i + 1, hashes, multiline, &mut parts, &mut open, depth); + } else if c == b'/' && next == Some(b'/') { + i = line_end(b, i); + mark(&mut parts, start..i, Part::Comment); + } else if c == b'/' && next == Some(b'*') { + i = block_end(b, i, true); + mark(&mut parts, start..i, Part::Comment); + } else if c == b'"' || c == b'#' { + let hashes = b[i..].iter().take_while(|&&c| c == b'#').count(); + if b.get(i + hashes) != Some(&b'"') { + // a directive (`#if`, `#available`) or a raw identifier's `#` + i += hashes.max(1); + continue; + } + let multiline = b[i + hashes..].starts_with(b"\"\"\""); + let body = i + hashes + if multiline { 3 } else { 1 }; + mark(&mut parts, start..body.min(b.len()), Part::Literal); + i = swift_text(b, body, hashes, multiline, &mut parts, &mut open, depth); + } else { + match c { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + _ => {} + } + i += 1; + } + } + parts +} + +/// A Swift string's text from `i`, marked as literal: to past its closing +/// quote (and `#`s), or past the `(` of an interpolation, which is pushed on +/// `open` (at paren `depth`). A single-line string unclosed at its line's +/// end ends there. +fn swift_text(b: &[u8], mut i: usize, hashes: usize, multiline: bool, parts: &mut [Part], open: &mut Vec<(usize, bool, usize)>, depth: usize) -> usize { + let quote: &[u8] = if multiline { b"\"\"\"" } else { b"\"" }; + // bounded: each pass moves `i` forward + while i < b.len() { + let rest = &b[i..]; + let hashed = |at: usize| rest.len() >= at + hashes && rest[at..at + hashes].iter().all(|&c| c == b'#'); + if rest[0] == b'\\' && hashed(1) { + let after = 1 + hashes; + if rest.get(after) == Some(&b'(') { + mark(parts, i..i + after + 1, Part::Literal); + open.push((hashes, multiline, depth)); + return i + after + 1; + } + // an escape: what it escapes is text too + let end = (i + after + 1).min(b.len()); + mark(parts, i..end, Part::Literal); + i = end; + } else if rest.starts_with(quote) && hashed(quote.len()) { + let end = i + quote.len() + hashes; + mark(parts, i..end, Part::Literal); + return end; + } else if rest[0] == b'\n' && !multiline { + return i; + } else { + parts[i] = Part::Literal; + i += 1; + } + } + i +} + /// A literal a word at its start prefixes, ending where it does: `r"…"`, /// `r#"…"#`, `b"…"`, `br"…"`, `c"…"`, `cr"…"`, `b'…'`; `None` for a word /// (`r#type` is a raw identifier). @@ -793,13 +892,37 @@ mod tests { hits(&file(path, text)).into_iter().map(|h| (h.line, h.name)).collect() } + #[test] + fn swift_comments_and_data_do_not_evaluate_a_page() { + assert!(found("ios/Sources/Web.swift", "// web.evaluateJavaScript(source)\nlet label = \"evaluateJavaScript\"\nweb.load(request)\n").is_empty()); + // text, however quoted: a raw string's `\(` is no interpolation, and + // an interpolation's own strings are text + for text in [ + "let a = #\"\\(web.evaluateJavaScript(source))\"#\n", + "let b = \"\\(label(\"evaluateJavaScript(x)\"))\"\n", + "let c = \"\"\"\n web.callAsyncJavaScript(source)\n \"\"\"\n", + "let d = ##\"say \"#\\#(web.evaluateJavaScript(x))\"# then\"##\n", + "/* outer /* web.evaluateJavaScript(x) */ still a comment */\n", + ] { + assert!(found("ios/Sources/Web.swift", text).is_empty(), "{text}"); + } + } + /// Goal: a planted use in each category fails the check, named by its /// file, line and name, in each language it can be written in. Method: /// each beside clean code, alone and in a check of both. #[test] fn a_planted_eval_fails_the_check_naming_its_line() { let clean = file("cell/clean.mjs", "export const ok = 1;\n"); - let planted: [(&str, &str, usize, &str); 38] = [ + let planted: [(&str, &str, usize, &str); 45] = [ + ("ios/Sources/Web.swift", "// Native page scripts are not evaluated.\nweb.evaluateJavaScript(source)\n", 2, "evaluateJavaScript"), + ("ios/Sources/Async.swift", "try await web.callAsyncJavaScript(source, arguments: [:], in: nil, contentWorld: .page)\n", 1, "callAsyncJavaScript"), + // inside an interpolation, a string's code is code + ("ios/Sources/Said.swift", "let said = \"result: \\(try await web.evaluateJavaScript(source))\"\n", 1, "evaluateJavaScript"), + ("ios/Sources/Raw.swift", "let said = #\"a \"quote\" \\#(try await web.callAsyncJavaScript(source, arguments: [:], in: nil, contentWorld: .page))\"#\n", 1, "callAsyncJavaScript"), + ("ios/Sources/Lines.swift", "let said = \"\"\"\n page:\n \\(try await web.evaluateJavaScript(source))\n \"\"\"\n", 3, "evaluateJavaScript"), + ("ios/Sources/Nested.swift", "let said = \"\\(f(\"\\(web.evaluateJavaScript(js))\"))\"\n", 1, "evaluateJavaScript"), + ("ios/Sources/After.swift", "let said = \"\\(g(\")\"))\"; web.callAsyncJavaScript(js)\n", 1, "callAsyncJavaScript"), // DevTools, as a method on the wire and as a client's call ("cell/src/card.rs", "async fn f() {\n cdp.call(\"Runtime.evaluate\", json!({ \"expression\": js }), deadline).await?;\n}\n", 2, "Runtime.evaluate"), ("crates/core/src/a.rs", "const M: &str = \"Runtime.callFunctionOn\";\n", 1, "callFunctionOn"), @@ -920,6 +1043,9 @@ mod tests { "templates/todo/site/index.html", "images/hermes/Dockerfile", "images/hermes/preload.py", + "ios/Sources/FragmentWebSheet.swift", + "ios/Tests/DeviceIdentityTests.swift", + "ios/UITests/FragmentUITests.swift", "images/bridge/tests/docker.rs", "cli/tests/mcp.rs", "crates/core/src/lib.rs", @@ -938,6 +1064,7 @@ mod tests { ("templates/todo/site/index.html", Lang::Html), ("images/hermes/Dockerfile", Lang::Hash), ("images/hermes/preload.py", Lang::Hash), + ("ios/Sources/FragmentWebSheet.swift", Lang::Swift), ("crates/core/src/lib.rs", Lang::Rust), ] );