iOS and macOS SDK
Add Little Friend to a native iOS or macOS app with Swift Package Manager: screens, events, deep links, consent and the App Store privacy label.
The Swift SDK sends screens, events and deep link campaigns from a native iOS or macOS app, with SwiftUI, UIKit or AppKit. It counts in the same reports as your site, under the iOS or macOS platform, with your app's id and version.
Install
Add the Swift package https://github.com/ZVN-DEV/littlefriend-swift from version 0.1.0 and link its LittleFriend product to your app. In Xcode, choose File, Add Package Dependencies and paste the URL. It runs on iOS 15 and macOS 12 and later, builds with Xcode 15 or later, and has no dependencies.
// Package.swift
dependencies: [
.package(url: "https://github.com/ZVN-DEV/littlefriend-swift", from: "0.1.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: [.product(name: "LittleFriend", package: "littlefriend-swift")]
),
]Start
Call LittleFriend.start(key:options:) once, as the app launches: in your SwiftUI app's init, or in application(_:didFinishLaunchingWithOptions:). The key is your public site key, which starts with lf_. A key that does not match ^lf_[A-Za-z0-9]{12,40}$ turns the SDK off with one log line, and so does a bundle id or version that fails the contract's rules, such as a version that looks like personal data. Only the first call counts.
import LittleFriend
import SwiftUI
@main
struct ShopApp: App {
init() {
LittleFriend.start(key: "lf_YOUR_SITE_KEY")
}
var body: some Scene {
WindowGroup {
ContentView().lfDeepLinks()
}
}
}import LittleFriend
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
var options = LittleFriend.Options()
options.mode = .journey
options.autoScreens = true
LittleFriend.start(key: "lf_YOUR_SITE_KEY", options: options)
return true
}
}Calls you make before start wait in memory, at most 100, and run once it does, each with the time it was made. Every call is safe from any thread and never waits for the network. Log lines go to the unified log under the subsystem io.littlefriend, and never hold event content.
In Settings, Project, add your app's bundle id, such as com.acme.shop, to Allowed apps. A project admits only the apps on that list, and an empty list admits none. Until the app is listed, its batches are refused, and the data health strip names it with an Add button for owners and editors.
| Option | Default | What it does |
|---|---|---|
mode | .aggregate | .aggregate keeps no session and no identifier. .journey keeps a session (see Visits and sessions). |
consent | .none | .required runs in aggregate mode until you call LittleFriend.consent(.journey). |
endpoint | https://in.littlefriend.io | The collector. Batches go to its /v1/e. |
test | nil | Marks everything as test traffic when true, and never when false. Nil detects it (see Test traffic). |
utmExtended | false | Also reads utm_term and utm_content from deep links. |
appVersion | nil | The version sent with every batch. Nil sends the bundle's CFBundleShortVersionString. |
autoScreens | false | UIKit only: sends a screen from every viewDidAppear(_:) of your own view controllers. |
testClockOffset | 0 | Moves the SDK clock back, for test traffic only (see Test traffic). |
Screens
A screen is a route template, such as /orders/:id, never a value. In SwiftUI, add .lfScreen to the view a screen shows: it sends the screen each time the view appears. Anywhere else, call LittleFriend.screen(_:type:). AppKit apps use either.
struct ProductView: View {
var body: some View {
ProductDetail()
.lfScreen("/products/:id", type: "product")
}
}
// Anywhere else, from any thread
LittleFriend.screen("/orders/:id", type: "order")In a UIKit app, set autoScreens to send a screen from every viewDidAppear(_:) of your own view controllers. A controller that adopts LFScreen is named by its lfRoute; any other by its class name without the ViewController suffix, so CheckoutViewController is /Checkout. Navigation, tab, split and page controllers, SwiftUI hosting controllers and the system's own controllers send nothing, since the screen they show does.
final class OrderViewController: UIViewController, LFScreen {
var lfRoute: String { "/orders/:id" }
}The SDK cleans every route on the device: it adds a leading /, cuts anything from ? or # on, turns a segment that holds = into :redacted, turns numbers, UUIDs and other id-like segments into :id, turns personal data such as an email address into :redacted, and drops a trailing /. A route over 2,048 characters, or over 256 once cleaned, is dropped with one log line.
type sets the screen view's page_type, which page types in Flows read, when it matches ^[a-z][a-z0-9_-]{0,31}$. Any other type is left out with one log line, and the screen is still sent. The route already on display, sent again within a second, is the same view and is ignored.
The SDK also sends the time each screen was on display with the app in the foreground, when the screen changes or the app leaves the foreground: at least a second, and at most 30 minutes per screen. A screen shown while the app is in the background is sent when the app returns to the foreground, unless it is the screen the visit sent last.
Events and goals
Call LittleFriend.track for the actions that matter. A name matches ^[a-z][a-z0-9_.:-]{0,63}$; any other is dropped with one log line. An event carries at most 8 properties, the first 8 valid keys in alphabetical order. A key matches ^[a-z][a-z0-9_]{0,31}$, a string is cut to 64 characters, a number must be finite, and a boolean is kept. A string that looks like personal data, such as an email address, is dropped.
LittleFriend.track("signup", props: ["plan": .string("pro"), "seats": .number(3), "trial": .bool(true)])
// Journey mode: the same id goes to your server with the confirmed outcome
LittleFriend.track("checkout.submit", correlationId: checkoutHash)
// Send what is queued now
LittleFriend.flush()A goal is an event or a screen your project lists in Goals. The SDK sends events and screens, and goals are matched as they arrive, so a goal you add counts from then on. For an outcome only your server can confirm, such as a payment, send a correlationId with the event in journey mode, and the same id from your server with @littlefriend/node or one HTTP request. Call LittleFriend.flush() right after that event, so it goes out at once: reports count the server's event under your app's platform when the app's event arrived first, and as web when it did not. Funnels, paths and flows link the two either way. A correlation id is 16 to 64 letters, digits, _ or -, not only digits and dashes, and never personal data; in aggregate mode, or when it fails that rule, it is left out.
Events wait on the device in a file queue, at most 1,000 and none older than 23 hours. They go out in batches: at 10 events, 5 seconds after an event waits, when the app leaves the foreground (on iOS inside a background task), and when you call LittleFriend.flush(). A send that fails on the network, a 429 or a 5xx is retried up to 3 times with the same event ids, so nothing counts twice, and what is left waits for the next send.
Deep links
Add .lfDeepLinks() to your root view. It hands every link that opens the app, and every universal link, to LittleFriend.handle(url:). A UIKit app calls handle(url:) itself: from scene(_:willConnectTo:options:), with the urlContexts and userActivities of the connection options, where a cold launch's link arrives; from scene(_:openURLContexts:) and scene(_:continue:); and, in an app without scenes, from application(_:open:options:).
func scene(
_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
for context in connectionOptions.urlContexts { LittleFriend.handle(url: context.url) }
for activity in connectionOptions.userActivities {
if let url = activity.webpageURL { LittleFriend.handle(url: url) }
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts { LittleFriend.handle(url: context.url) }
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if let url = userActivity.webpageURL { LittleFriend.handle(url: url) }
}Only utm_source, utm_medium and utm_campaign leave the device, plus utm_term and utm_content with utmExtended: lowercased, cut to 64 characters, and dropped when they look like personal data. Nothing else in the link is read, and a link without any of them is ignored.
A link that arrives before the app first comes to the foreground, or within half a second after, is the source of the launch's visit. Its landing is the screen on display when the app came to the foreground, or else the next screen shown. A link that arrives while the app is open starts a new visit, landing on the screen it opens within half a second, or else on the screen on display. A link that arrives while the app is in the background is the source of the visit that starts when it returns. Apps on iOS send no referrer, so a link without UTM tags counts as direct.
Visits and sessions
A visit starts at a cold launch, at a return to the foreground after 30 minutes or more in the background, and at a deep link with UTM tags. Its first screen is its landing. On the Mac, the app is in the foreground while it is the active app.
Aggregate mode is the default. It keeps no session and no identifier, on the device or on the wire: the visit's landing and its events carry the visit's source instead.
Journey mode keeps a session, so funnels, paths and journeys can follow one visit. The session is stored in session.json: an id of 20 random characters, when it started, its last activity, the number of its last event, and whether its first screen was sent. Time in the foreground counts as activity. A launch within 30 minutes of the last activity continues the session; it ends after 30 minutes without activity or 24 hours in all, and a deep link with UTM tags starts a new one once the session's first screen was sent.
Everything the SDK keeps is in Application Support/littlefriend (on the Mac, Application Support/<bundle id>/littlefriend), which is excluded from backups: the event queue, session.json in journey mode only, and the opt-out flag.
A process launched in the background, by a silent push or a background refresh, may never come to the foreground. For the first 2 seconds of a launch, until the app first comes to the foreground, screen, track and consent calls are held, so they join the launch's visit and its source. LittleFriend.flush() sends them at once, so a process that tracks an event and exits should call it. After the 2 seconds, until the app first comes to the foreground, events go out at once with no landing and no source, consent applies at once, and a screen waits to land when the app comes to the foreground.
Consent and opt-out
With consent set to .required, a journey mode app runs in aggregate mode, and keeps no session file, until you call LittleFriend.consent(.journey). A session still live from an earlier launch then continues. consent(.none) and consent(.required) return to aggregate mode and delete session.json. In an app whose mode is aggregate, consent(.journey) is ignored with one log line.
var options = LittleFriend.Options()
options.mode = .journey
options.consent = .required
LittleFriend.start(key: "lf_YOUR_SITE_KEY", options: options)
// When the person agrees, and when they withdraw
LittleFriend.consent(.journey)
LittleFriend.consent(.none)
// A setting in your app that turns analytics off, and back on
LittleFriend.optOut()
LittleFriend.optIn()optOut() drops what is queued, deletes session.json, writes the opt-out flag and sends nothing more, across launches, until optIn(). Once the SDK has started, the queue and the session file are gone when optOut() returns. optIn() removes the flag and starts a new visit, and in journey mode a new session.
Test traffic
In the Simulator and in XCTest runs, everything the SDK sends is test traffic. The live install check in Settings shows it, and it is never stored or counted in reports. Set test to true or false to decide yourself.
testClockOffset is for test harnesses: it moves the SDK's whole clock back, so a check of the 30 minute rule does not have to wait. It applies to test traffic only, must be negative and at most 23 hours, and is ignored with one log line otherwise.
What the SDK never sends
Each batch names your app (its bundle id, the platform, the device class of mobile, tablet or desktop, and its version) and the SDK (ios-0.1.0, or macos-0.1.0 on the Mac). Each event carries a random id, its name, time, route, page type, properties, the landing flag and campaign values, and in journey mode the session id, the event's number in the session and any correlation id. The SDK never reads or sends:
- the advertising identifier, the identifier for vendor, or any other device or user identifier;
- the device model, the OS build, the carrier, the locale, the time zone or the location;
- screen text, the view hierarchy, taps, input values, the clipboard or contacts;
- anything in a deep link beyond the UTM tags above;
- cookies: its requests use their own session, with no cookie or cache storage.
The collector reads the country from the request's IP address in transit. The address itself is stored nowhere.
App Store privacy label
For the data Little Friend collects, answer App Store Connect's privacy questions this way.
| Question | Answer |
|---|---|
| Data types | Usage Data: Product Interaction |
| Linked to the user's identity | No |
| Used for tracking | No |
| Purpose | Analytics |
| Identifiers | Not collected |
| Location | Coarse Location: collected, not linked to the user's identity, not used for tracking, purpose Analytics (the country the collector derives from the request address) |
| Diagnostics | Not collected |
No App Tracking Transparency prompt is needed: the SDK links nothing across apps or to anyone's identity, and reads no advertising identifier.
Your app's own answers depend on everything else it collects: these cover only what Little Friend sends, so review them with the rest of your form before your first release.