XMPP client written in Swift, primarily for iOS devices
  • Swift 99.1%
  • Shell 0.5%
  • Makefile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-10 04:28:36 +02:00
AnotherIM What libtacky 1.2.3 lets the app do: delete for me, pin for everyone, move a pinned chat, and the profile's three visibility levels. 2026-11-08 17:13:01 +01:00
AnotherIME2ETests End-to-end tests run against another.im with two test accounts. 2026-11-07 09:17:38 +01:00
AnotherIMNotificationService version upd 2026-11-07 09:17:38 +01:00
AnotherIMShared Read pushes on feat/ui-redesign (ported from PR #33): the APNs token asks mod_apush for them (apushRead: 1, never the PushKit one), and a silent background push of kind: read withdraws the chat's delivered notices by its thread, so a chat read or answered on another device no longer leaves its notice on the lock screen of a phone whose app is frozen or killed (the user, 2026-11-06: "72 new messages" for a room read on the desktop). anotherim-server feat/read-push (8938a8c) and apushd feat/read-push (0e24a35) send it as an APNs background push with the sealed summary. 2026-11-07 09:17:39 +01:00
AnotherIMTests What libtacky 1.2.3 lets the app do: delete for me, pin for everyone, move a pinned chat, and the profile's three visibility levels. 2026-11-08 17:13:01 +01:00
AnotherIMUITests A message being sent shows a clock, not a spinner, and New Chat's scenarios pass. 2026-11-07 09:17:38 +01:00
Packages What libtacky 1.2.3 lets the app do: delete for me, pin for everyone, move a pinned chat, and the profile's three visibility levels. 2026-11-08 17:13:01 +01:00
tacky@c95b3d9d18 libtacky v1.2.3: 1.2.0 final, then 1.2.1 (forge #20-#23, interop issue 1's security and correctness items), 1.2.2 (auto-download from contacts only, destroyed rooms not recreated, joins across a bad link), 1.2.3 (chatlist move, shared pins). 2026-11-08 16:04:09 +01:00
.gitignore The UI redesign's plan is a local working document now, out of the repository and ignored; the code names the redesign and its phases instead of the file. 2026-11-07 09:15:45 +01:00
.gitmodules The app builds and its tests pass on a Mac (the user: "maybe try to build swift version?", then "tclsh - why not 9.0? its stable. libomemo - bye."): Xcode 27.0 compiles the backend, the simulator app and the Designed-for-iPad app as the README says, and gmake test runs 339 tests with no failure on an iPhone 17 simulator. The Makefile no longer fetches native/tacky-omemo/third_party/libomemo-c, which left the backend on 2026-10-07 (OMEMO is the Rust tacky-omemo), and failed every build of a nested checkout with "pathspec did not match"; deps takes tacky/ without --recursive, since its one submodule, tacky-media's libdatachannel, is not read by the iOS archive. The host tclsh is the backend's own Tcl 9.0: a tclsh9.0 on PATH, else one the backend's tcl.mk builds from its pinned Tcl 9.0.3 under build/backend/host before the slices (HOST_TCLSH), as Homebrew's tcl-tk is 9.1 now and has no 9.0 formula; the README's brew line drops tcl-tk. The test target did not compile under Swift 6: PinGateTests awaited inside XCTAssertNil's autoclosure ('async' call in an autoclosure that does not support concurrency), and ChatScreenLayoutGroupTests' row map took the type checker past its limit; the first awaits into a constant, the second's closure is typed. The README says what has run, .gitmodules and TACKY-MIGRATION.md no longer name libomemo-c, and TACKY-MIGRATION.md says where the tclsh9.0 comes from. 2026-10-10 13:50:56 +03:00
.swiftformat prepare to migrate 2026-06-16 18:18:17 +02:00
.swiftlint.yml Cleanup code 2026-11-07 09:17:37 +01:00
AnotherTacky.Package.swift iOS 18.1 and macOS 15 again as the floor, the look aimed at iOS 27 and checked on the iOS 18, 26 and 27 simulators. 2026-11-07 09:15:44 +01:00
COPYING Initial commit 2024-06-19 17:13:08 +02:00
coverage.py End-to-end tests run against another.im with two test accounts. 2026-11-07 09:17:38 +01:00
LICENSE anotherim-swift is MPL-2.0 (the user, 2026-09-29: "License is GPL-3.0 for libtacky and MPL 2.0 for anotherim-web and anotherim-swift"): a LICENSE file with the Mozilla Public License 2.0 text, which the repository had none of, and the README's licence section says MPL-2.0 instead of GPL-3 and that an app build linking the GPL-3.0 libtacky is conveyed under the GPL-3.0 as a whole, which MPL-2.0 permits 2026-09-29 22:56:27 +03:00
Makefile build version update 2026-11-07 09:17:38 +01:00
PRIVACY.md PRIVACY.md: what contacts see (idle time), legacy OMEMO file metadata, and where an exported backup is staged 2026-11-10 04:28:36 +02:00
project.yml libtacky v1.2.3: 1.2.0 final, then 1.2.1 (forge #20-#23, interop issue 1's security and correctness items), 1.2.2 (auto-download from contacts only, destroyed rooms not recreated, joins across a bad link), 1.2.3 (chatlist move, shared pins). 2026-11-08 16:04:09 +01:00
README.md Cleanup code 2026-11-07 09:17:37 +01:00
remote.sh A message being sent shows a clock, not a spinner, and New Chat's scenarios pass. 2026-11-07 09:17:38 +01:00
TACKY-MIGRATION.md The store is split in two tiers, so that nothing on the phone opens the messages without the person: accounts.db (credentials, settings) stays under the keyring AppDeviceKey (K1) opens without anyone, which a call answered on the lock screen needs, and every account's own store (history, roster, OMEMO, the attachments' keys) goes under libtacky 1.0.4's second keyring, whose slots take the new AnotherIMShared/AppPresenceKey (K2): 32 bytes ECIES-sealed, as AppDeviceKey's are, under a Secure Enclave P-256 key made with .privateKeyUsage and .userPresence, items WhenUnlockedThisDeviceOnly, opened only through an LAContext the person evaluated (authenticate, then load/loadOrCreate(context:) passing it as the enclave's authenticationContext, so one sheet; a context not seen evaluated is refused before the enclave is asked; the simulator's software path, and the DEBUG-only softwareKeyForTests switch the tests use, since Xcode 27.1's simulator reports an enclave). AppEnvironment: setupPin asks once (.deviceOwnerAuthentication) before anything is written, then encrypt -pin, split {pin, mainKey, mainDeviceKey}, set_pin {pin, deviceKey K1, mainDeviceKey K2}, set_device_key {key K1, mainKey K2}; unlock(pin:) asks once and sends unlock {pin, deviceKey, mainDeviceKey, mainKey}, from locked or from call (libtacky keeps a running call on its session, so "A call is on" is now said only for an unsplit store a wake opened), and returns a PinUnlockResult so a cancelled or failed sheet is no PIN miss for AppPinPolicy; a store split by a biometric unlock has no main PIN slot until the next PIN unlock, so from call with no call on it is locked first and the call tier checks the PIN, and with a call on K2 alone opens it as Face ID does; unlockWithBiometrics sends unlock {key K1, mainKey K2} from the same context; an unsplit store (info.split false) is split right after the person's unlock, never on a wake, with the PIN screen showing "Updating your vault…" over the busy keyboard, and a failed split leaves it one tier to be tried again; a phone with no passcode cannot ask for the person and keeps one tier. unlockInBackground/unlockToAnswer stay K1-only and land in call, which resolveRootState keeps at .locked while isStoreOpen (now StorageStatus.isReadable) lets settings and conn through; relockAfterBackgroundWake locks call as it locked unlocked (not while the person's unlock runs); .storageUnlocked (now carrying tier, XMPPEvent.swift) acts only on main; storeOpened runs again from call to unlocked for the full sessions; push remove_target waits for the whole store, the push module being per account; resetStore and the storage-generation reset forget K2 too. TackyAPI gains storageUnlock(pin:deviceKey:mainDeviceKey:mainKey:), storageUnlock(deviceKey:mainKey:), storageSplit, storageSetPassphrase, storageSetPin(_:deviceKey:mainDeviceKey:), storageSetDeviceKey(_:mainKey:); StorageLockInfo (Wire.swift) reads split, tier, mainSlots, mainHardware (an older backend's reply reads as one tier) and canUnlockWithBiometrics wants a device slot in both keyrings. Generated/TackyWire.swift is regenerated (tools/api/api.py gen --swift) from libtacky develop's schema with the 1.0.4 storage additions applied in a scratch copy (split, set_passphrase, mainKey/mainDeviceKey, StorageStatus call, the StorageInfoReply fields, Unlocked's tier, Split); the integrator regenerates from the merged libtacky, and the tacky submodule pointer is unchanged. project.yml keeps AppPresenceKey out of the notification extension and rewords NSFaceIDUsageDescription; Localizable.xcstrings (English only) changes PinCode.msg1/msg2, Security.biometrics.footer.available and Security.autolock.footer and adds the PinCode.presence.* refusals, PinCode.splitting and Security.presence.footer.software/noPasscode, which Settings → Security shows when the key is not the enclave's or the phone has no passcode; README.md and TACKY-MIGRATION.md §8.2 describe the tiers. Tests: TieredPinGateTests (11: a VoIP wake sends unlock {key} only, lands in call with the root .locked and isStoreOpen, and never splits; a PIN unlock sends both main keys and a split store gets no split; an unsplit store is split after the person's PIN unlock and after a biometric one with K2 alone; no passcode keeps one tier; a cancelled sheet sends nothing; a biometric unlock from call sends {key, mainKey} and no lock; the no-main-PIN-slot relock; call is relocked on coming to the front; the setup's request order and shapes), PresenceKeyTests (4: made once and read back, refused without an evaluated context, apart from K1, the software flag), TackyAPITests (4 new request and reply shapes). On the iPhone 18 Pro simulator (Xcode 27.1, ad-hoc test signing): the filtered PinGate/TieredPinGate/PinPolicy/DeviceKey/PresenceKey/TackyAPI run 51 tests, 0 failures; gmake test 378 tests, 0 failures (359 on master); gmake app-ios BUILD SUCCEEDED; swiftlint reports nothing new on the changed files beyond TackyAPITests' existing type_body_length. Not run: the enclave's .userPresence path on a device (no device here; compiled, not run), in particular that a context evaluated with .deviceOwnerAuthenticationWithBiometrics satisfies the key's .userPresence, and the whole flow against a libtacky with split, which is built in parallel (the tests answer as storage.tcl is designed to). 2026-10-24 20:15:41 +00:00

Another.im XMPP client for iOS and macOS

This is the source code for the another.im XMPP app: one SwiftUI app for the iPhone and a native one for the Mac, over the same backend.

Layout

path what it is
AnotherIM/iOS the iOS app: entry point, the delegate for push, app icon and launch screen
AnotherIM/Mac the macOS app: entry point, the Settings window, calls without CallKit
AnotherIM/App AppEnvironment (the backend, the PIN gate, push, the scene phase) and the app's settings
AnotherIM/Shell the UI's root: the gate (lock, welcome) and AppShell, which joins the features
AnotherIM/Live the features' clients over the backend (*Client+Live.swift)
AnotherIM/Calls calls: CallsModel, WebRTC, CallKit
AnotherIM/Tacky, AnotherIM/XMPP the backend's protocol (TackyAPI, the generated TackyWire) and the app's XMPP layer
AnotherIMShared what the app and the notification service extension share: keys, paths, push
AnotherIMNotificationService the notification service extension
Packages/ the UI, as local Swift packages (below)

The UI is in Packages/. AnotherDesign holds the colours, type roles, metrics and the glass; AnotherComponents the views more than one screen shows (and AnotherFormat, their text); AnotherModels the values the screens show and the features' clients, closures with fixtures beside them. Each Feature* package (Lock, Onboarding, Settings, ChatList, Compose, Conversation, ChatDetails, Media, Security, Calls) has a …Core target, the screens' logic in Foundation and Observation, tested with Swift Testing on Linux too, and a …UI target with the SwiftUI views. Features never import each other: what one asks of another is a callback AppShell answers. The live clients are in AnotherIM/Live, the fixtures in AnotherModelsFixtures (previews and tests).

Building from a clean clone (on a Mac)

The app is the fifth host of Tacky's JSON protocol (TACKY-MIGRATION.md §2, §8): it links the backend's iOS archive, AnotherTacky.xcframework, as a Swift package (AnotherTacky, C module CTacky). The backend is the nested submodule tacky/ (libtacky, pinned by this repository; its own submodules are third-party pins), so a clone of this repository alone has everything the build reads, and the build writes only under build/.

What the Mac needs:

  • Xcode 26 or later (the iOS 26 and macOS 26 SDKs; the app runs from iOS 18.1 and macOS 15), with the iOS 18, 26 and 27 simulator runtimes (xcode-select -p pointing at it; the project is Swift 6, the backend's Package.swift swift-tools 6.0);
  • Homebrew: brew install make cmake ninja xcodegen swiftlint -- GNU make 4.3 or later is gmake (macOS's own make is 3.81, too old for the backend's platforms/ios.mk). No Tcl is needed: the tclsh9.0 that bundles the backend's script image is one on PATH or, failing that, built by gmake backend from the backend's pinned Tcl 9.0 under build/backend/host (Homebrew's tcl-tk is 9.1);
  • Rust through rustup, with the two iOS targets (the macOS slice is the host's own, aarch64-apple-darwin): rustup target add aarch64-apple-ios aarch64-apple-ios-sim;
  • the network on the first build: the backend's make fetches the Tcl, tcllib and LibreSSL source tarballs (pinned by checksum), and xcodebuild resolves the app's other Swift packages.
git clone --recurse-submodules ssh://git@dev.another.im:3022/another-im/anotherim-swift.git
cd anotherim-swift
gmake deps        # only if the clone was not recursive: the nested backend
gmake backend     # build/backend/AnotherTacky: the xcframework + Package.swift
gmake app-ios     # xcodegen, then an unsigned simulator build
gmake test        # AnotherIMTests on a simulator
gmake test-packages  # swift test in every package (on Linux too)
gmake archive     # a Release device archive, signed as project.yml says

gmake help lists every target. What they are:

target what it does
backend (xcframework) make -C tacky -f platforms/ios.mk libtacky-ios with every tree under build/backend: per slice LibreSSL and the Rust archives -- tacky-core, tacky-crypto, tacky-omemo (ios/), the interpreter, its Tcl sources and the merged archive (tacky/ios-<sdk>), the LibreSSL tarball (dl/), and the package project.yml links (AnotherTacky/); then the backend's native lib for macOS (macos/), folded into the xcframework as a third slice, with AnotherTacky.Package.swift as the package
project xcodegen generate from project.yml
app-ios simulator build (IOS_DESTINATION, BUILD_SIGNING=CODE_SIGNING_ALLOWED=NO, CONFIG=Debug)
app-macos the native macOS app on an Apple-silicon Mac (MACOS_DESTINATION), against the backend's macOS slice
app both of the above
archive build/AnotherIM.xcarchive (ARCHIVE_DESTINATION=generic/platform=iOS, ARCHIVE_SIGNING, e.g. DEVELOPMENT_TEAM=…)
test xcodebuild test on TEST_DESTINATION (platform=iOS Simulator,name=iPhone 16), results in build/AnotherIM.xcresult
test-packages swift test in every package under Packages/ (on Linux, their …Core targets alone)
clean, clean-backend, distclean the Xcode project and DerivedData; build/backend; both

XCODEBUILD_FLAGS goes on every xcodebuild line (-allowProvisioningUpdates, -quiet). app-*, archive and test build the backend first only when build/backend/AnotherTacky/Package.swift is missing; gmake backend rebuilds it on request.

The nested checkout is only read, with one exception the backend's make does not yet let a caller move: its ios-lib copies each slice's archive to tacky/dist/libtacky-<sdk>.a (ignored by the backend's .gitignore) before platforms/ios.mk folds the two.

From the another-im workspace

TACKY= names a backend checkout other than the nested one; the output still lands in build/backend, so project.yml is the same:

gmake -C anotherim-swift app-ios TACKY=../another-tacky

or, with libtacky cloned beside this repository, gmake app-ios TACKY=../libtacky.

From a Linux machine the workspace's mac/run.sh (MAC_HOST=user@mac) syncs the tree to a Mac and runs a command there, e.g. mac/run.sh gmake -C anotherim-swift backend app-ios TACKY=../another-tacky.

From a machine without Xcode (remote.sh)

remote.sh lets an editor without Xcode (a Linux container that mounts this checkout) build, test and drive the app on a Mac. On the Mac, in the checkout: ./remote.sh serve (the watchman). Elsewhere: ./remote.sh [@18|@26|@27] <verb>, e.g. build ios|mac, test, pkgtest <Package>, analyze ios|mac (swiftlint's unused code and imports), run [mac], shot [mac], tap-id <id>, logs [mac]; @NN picks the simulator's iOS. ./remote.sh --help lists every verb. The two sides talk through .remote/ (ignored by git); a build's full log is .remote/out/<job>.log.

What has run, and what has not

On 2026-10-10 the steps above ran on an Apple-silicon Mac with Xcode 27.0 (iOS 27.0 SDK and simulator), from a clean build/ and with no Homebrew Tcl: gmake backend (with its host tclsh9.0), gmake app-ios, gmake app-macos (unsigned), and gmake test on TEST_DESTINATION='platform=iOS Simulator,name=iPhone 17' (Xcode 27 has no iPhone 16 simulator) -- 339 tests, no failure. The app was installed on that simulator and opens on its PIN screen. Not run yet: gmake archive and a signed app-macos (both need the team's signing identities), the app on a device, and the app past its PIN screen. The user's rule behind the layout (2026-09-24): a module's repository is cloned and built with make from it, without the superproject's contents.

The store and the PIN

The backend's store is in two tiers (libtacky 1.0.4, storage split): accounts.db, which a call answered on the lock screen opens with the device key alone (AppDeviceKey), and every account's history, roster and keys, which open only with the PIN together with Face ID, Touch ID or the passcode, or with Face ID or Touch ID alone, through a second key the Secure Enclave releases only inside that authentication (AppPresenceKey). A store from before is split on the first unlock after the update. TACKY-MIGRATION.md §8.2 has the requests. The enclave's path is compiled and was not run on a device: the tests take the software key.

Push

The app holds two APNs tokens and registers each with the server's App Server, apushd (push.another.im, XEP-0357 through libtacky's push set_target), under its own apushd module name, AppPushConfiguration:

token pushModule (sandbox / production) apushd module wakes
APNs (registerForRemoteNotifications) anotherimIOSdev / anotherimIOSprod pushType: alert, topic im.narayana.anotherim.ios the notification service extension, for a message
PushKit (PKPushRegistry, .voIP) anotherimIOSVoipdev / anotherimIOSVoipprod pushType: voip, topic im.narayana.anotherim.ios.voip the app, for a call (CallKit)

Two names because a module has one topic and one delivery class, so a PushKit token can only be delivered through a voip module. The PushKit registration carries apushCalls: only, which tells ejabberd's mod_apush (anotherim-server) to push it calls and nothing else; the APNs one carries none, which is messages only. Both carry the app's own X25519 key (apushEncryptionPublicKey, TACKY-MIGRATION.md D7): mod_apush seals a JSON summary to it (who, how many, a call's session and media; never a message's text) under the label apush-v2, apushd passes it on unread as apushEncrypted, and the extension or the app opens it here (AppPushEncryption).

A message push carries the chat's opaque collapse tag (apushCollapse, which apushd sends as apns-collapse-id), so a chat's newer notice replaces the older one, and mod_apush holds a burst in one chat to one push and one more after its window with the latest count. iOS names a pushed notice itself (a random id or that tag), so the extension files it under the chat's thread identifier (AppPushThread, <account>|<chat>) and the app withdraws a chat's notices by that thread.

The four modules are in anotherim-server's push/settings.sh, which writes apushd's settings.json: anotherimIOSdev and anotherimIOSprod with "pushType": "alert" and the bundle id as topic, anotherimIOSVoipdev and anotherimIOSVoipprod with "pushType": "voip" and the bundle id plus .voip; the dev pair against the APNs sandbox, the prod pair against production. The app picks the pair by the aps-environment of the profile it is signed with (AppPushConfiguration.isSandbox), not by Debug or Release: a token sent to the other environment is 400 BadDeviceToken.

License

Copyright (c) 2024 Narayana OÜ

Licensed under the Mozilla Public License 2.0 (LICENSE). The backend it embeds, libtacky (the nested tacky/), is GPL-3.0, so an app build that links it is distributed under the GPL-3.0 terms as a whole; MPL-2.0 is compatible with that.