Swift
Swift quickstart
A working feedback board inside your iOS or macOS app, in three steps. One key to set it up, one line to show it.
The shape of it: add the package, hand it your embed key, and place a view. The board brings its own navigation, so it works on a tab, in a sheet, or on a screen of its own, with nothing to wrap it in.
-
Add the package
In Xcode, File → Add Package Dependencies, paste the repository address, and add the
VoteFirstlibrary to your app target.The repositoryhttps://github.com/votefirst-app/votefirst-swift
In a package of your own, it is a dependency and a product.
Package.swiftdependencies: [ .package(url: "https://github.com/votefirst-app/votefirst-swift", from: "0.3.0") ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "VoteFirst", package: "votefirst-swift") ]) ]While the views settle,
.upToNextMinor(from: "0.3.0")is the safer requirement. The package is 0.x, so a minor release can change them. -
Start it with your embed key
Open the dashboard, choose your project, and go to Share & Embed. The embed key is the first thing on the page. It is the same key a web page uses, and it is the only value the package needs, because the key names its own board.
MyApp.swiftimport SwiftUI import VoteFirst @main struct MyApp: App { init() { VoteFirst.start("vf_pk_...") } var body: some Scene { WindowGroup { RootView() } } }In UIKit,
application(_:didFinishLaunchingWithOptions:)is the same moment.startis cheap and makes no request. It builds the client and returns. The first request happens when a view appears or when you make a call. -
Show a board
One view. On a tab, it is a tab.
In your appTabView { Home() VoteFirst.FeedbackView() .tabItem { Label("Feedback", systemImage: "lightbulb") } }Inside a settings list, use the link, which pushes the board onto your own navigation and keeps your back button.
In your appList { Section("Support") { VoteFirst.FeedbackLink() } }UIKit gets a view controller, with its title and its tab bar item already set.
In your apppresent(VoteFirst.viewController(), animated: true)
That is the whole integration. Run the app and your features are there, ranked, with a vote button on every row, a filter across your stages, threaded comments, and a suggestion form behind the plus.
The whole app
The three steps together, as one file.
import SwiftUI
import VoteFirst
@main
struct MyApp: App {
init() { VoteFirst.start("vf_pk_...") }
var body: some Scene {
WindowGroup {
TabView {
Home()
.tabItem { Label("Home", systemImage: "house") }
VoteFirst.FeedbackView()
.tabItem { Label("Feedback", systemImage: "lightbulb") }
}
}
}
}
The three products
One import gets you everything, because VoteFirst re-exports the other two.
| Product | What it holds | When to use it |
|---|---|---|
VoteFirst | Both of the others | An app. This is the one to depend on |
VoteFirstKit | The client, the models, the queue | No SwiftUI in it. A server side Swift app, a command line tool, a Linux build |
VoteFirstUI | The screens | It already depends on the kit, so there is rarely a reason to name it directly |
What it needs
| Platforms | iOS 15, macOS 12, and later |
| Swift tools | 5.9 |
| Language modes | Swift 5 and Swift 6, both under strict concurrency |
| Xcode | 15 or later |
| Dependencies | None |
Measured on macOS arm64, release, stripped, the client alone adds 451 KB to a binary, and the client with the screens adds 1078 KB.
Your embed key
It starts with vf_pk_ and it is public by design, the way any key that has to reach a browser is. It opens exactly what your public board already shows to anyone: reading the board, reading features, comments and releases, and taking part as one voter. It cannot read another project, cannot see a private one, and cannot reach anything in your dashboard.
An embed key is not an API key. The keys under API keys on the same dashboard page are secrets that open every administrative route, including exporting your voters. One of those in a shipped binary is a compromise, and every copy of your app hands it out. The package refuses to build a client with one rather than warning about it here.
VoteFirst.start("sk_live_...")
Rotating the key
Rotating stops every copy of your app that carries the old one. A web page is redeployed in a minute; a shipped app waits for review and then for people to update. Treat rotation as a release rather than a setting, and expect to ship an update behind it.
The origin allowlist does not apply
The Allowed origins box beside the key is a browser control. An app sends no Origin header, so nothing in that list can refuse it. Restricting origins for your website does not lock your app out, and it does not make the key a secret either.
What the key may spend
Per project, 3000 reads, 600 writes and 300 new voters a minute. Every copy of your app draws from the same budget rather than getting one each. The numbers, and the burst inside each of them.
Naming the project as well
VoteFirst.start(project: "your-project-slug", key: "vf_pk_...")
The same thing with the slug already known. Every route but one hangs off the slug, and the key alone has to ask for it once per launch before the first of them. Naming it saves that request.
One difference, and it only matters if you switch between the two forms. What the device stores is filed under the slug when you supply one, and under a digest of the key when you do not, so switching form looks like a different device: a new anonymous voter and an empty queue. Pick one and stay on it.
Another host
VoteFirst.start("vf_pk_...", host: URL(string: "http://localhost:18080")!)
The default is https://app.votefirst.app. The host is there for a local stack or a staging deployment. It is not a place to put a path: the package appends api/v3/... to whatever you give it.
When the key is wrong
Nothing throws at start, because a screen showing what is wrong is more use than a crash on launch.
if let problem = VoteFirst.setupError {
print(problem.errorDescription ?? "")
}
VoteFirst.clientis nil.VoteFirst.setupErrorholds the reason.- Every built in view draws a notice with that reason on it instead of a board.
- Every call throws it.
- Debug builds trip an assertion, so this does not get past the first run.
A key with the right shape but no board behind it cannot be caught here, because that is not knowable without asking. It arrives on the first request as invalidKey.
What happens on the first screen
A built in screen, appearing for the first time, makes these requests in this order.
GET /api/v3/board, only when the key named no project, to learn the slug.GET /api/v3/projects/{slug}for the board: columns, tags, theme, and what is allowed.POST /api/v3/projects/{slug}/identityto mint or renew this device's voter.GET /api/v3/projects/{slug}/featuresfor the first page.
The board is held for the life of the process and shared by every screen, so a second tab costs one features call rather than four requests. The identity call is what gives the vote buttons the right state before anybody presses one.
If you never show a built in screen, nothing is minted until your first write or your first me().
Without SwiftUI
The façade holds one client and one board, which is what an app wants. A tool with no SwiftUI in it, or an app showing two different projects, can hold its own.
let client = try VoteFirstClient(key: "vf_pk_...") let board = try await client.board() let page = try await client.features(FeatureQuery(sort: .top))
It throws for a key without the vf_pk_ prefix, and that is the only reason it throws. Two clients over two projects keep separate voters, separate queues and separate files, because everything stored is filed under the project.
Every member is in the reference.