Files
Felitendo 80009e4bc8 feat: build a tweak against a base the operator points at
A repository that publishes only a dylib needs a clean build of the app
to put it into, and until now that build had to already be in the
Library. The commonest case is the other one: the author publishes a
tweak for an app release nobody has a build of yet, and nothing came out
of the pipeline until somebody put one there by hand.

A source may now name the address a base build lives at, with * standing
where the version goes. The version the App Store is shipping is
resolved, substituted in, and the build is fetched when the Library has
nothing current. A new tweak release and a new app release now both
produce a build on their own: the scan compares what the store is
shipping against the version the last build actually went into and
re-queues the newest tweak when they differ.

The address is typed by an operator and nothing invents one. There is no
search, no mirror list and no second host to try, an empty field still
means the Library alone, and the fetched build is a build input cached
under the temp directory rather than something the store ever serves. A
base that is still encrypted is refused by the injector exactly as
before.

Also fixed while here: a base is now never a tweaked build. The result
of this pipeline is ingested back under the same bundle id, so the next
release could pick one up and stack a second tweak on top of the first.
2026-09-02 12:27:16 +02:00

2220 lines
72 KiB
Swift

//
// AdminModels.swift
// FTStore Admin
//
// Wire types for the FTStore Admin dashboard API. Each struct mirrors exactly
// what one handler in `web/src/app/api/…` serialises — keep them in step when
// a route's response changes.
//
// Heavy free-form JSON columns (entitlements, privacy manifests) are decoded
// through `AnyCodable` so a schema change on the server can't fail the whole
// response.
//
import Foundation
// For `CGFloat` on AdminFeedbackAttachment. Foundation re-exports it on Apple
// platforms, but naming it here means this file does not depend on that.
import CoreGraphics
// MARK: - Auth
struct AdminUser: Codable, Identifiable, Equatable {
let id: Int
let username: String
let role: String
let approved: Bool
/// Only present on `/api/users` (the management listing).
var createdAt: Date?
var isAdmin: Bool { role == "admin" }
}
// MARK: - Dashboard
/// `/api/dashboard/status`
struct AdminDashboardStatus: Decodable {
let totalIpas: Int
let totalDownloads: Int
let storageUsed: Int64
/// Configured cap in bytes; `0` means unlimited.
let storageLimitBytes: Int64
let storageOverLimit: Bool
let activeChannels: Int
let queueDepth: Int
let workerStatus: String
let lastScanAt: Date?
let lastJsonGenAt: Date?
let uptime: Double
var isRunning: Bool { workerStatus == "running" }
var storageIsCapped: Bool { storageLimitBytes > 0 }
}
/// `/api/metrics/overview`
struct AdminMetricsOverview: Decodable {
struct DayPoint: Decodable, Identifiable {
let date: String
let count: Int
var id: String { date }
}
struct ChannelCount: Decodable, Identifiable {
let channel: String
let count: Int
var id: String { channel }
}
let totalIpas: Int
let totalDownloads: Int
let storageUsedBytes: Int64
let storageLimitBytes: Int64
let activeChannels: Int
let successRate: Double
let ipasAddedByDay: [DayPoint]
let releaseDownloadsByDay: [DayPoint]
let ipasByChannel: [ChannelCount]
}
/// `/api/dashboard/activity` — one row of the activity log.
struct AdminActivityEntry: Decodable, Identifiable {
let id: Int
let type: String
let message: String
let status: String
let createdAt: Date
}
struct AdminActivityResponse: Decodable {
let data: [AdminActivityEntry]
let stats: [String: Int]?
let total: Int
let page: Int
let pageSize: Int
let totalPages: Int
}
/// `/api/dashboard/transfers` — what the worker is moving right now.
struct AdminTransfer: Decodable, Identifiable {
let id: Int
let channelId: String
let messageId: Int64
let fileName: String?
let fileSize: Int64?
let status: String
let updatedAt: Date
}
// MARK: - Library
/// `/api/library` — one build in the grouped catalog listing.
struct AdminLibraryItem: Decodable, Identifiable {
let id: Int
let fileName: String
let downloadUrl: String?
let appName: String
let bundleId: String
let version: String
let isTweaked: Bool
let isCorrupted: Bool
/// False when the feed leaves this build out (a known tweak locked to a
/// different channel). Still listed so it stays manageable.
let inFeed: Bool
/// Whether the bytes are actually on the server.
let hasFile: Bool
let iconUrl: String?
let channelId: String?
let fileSize: Int64
let createdAt: Date
let groupKey: String
let matchedTweak: String?
/// Which override table a rename writes to: `global` renames every build of
/// the bundle id, `ftstore` only this tweak's variant.
let renameScope: String
let displayName: String
let sourceType: String?
let sourceLabel: String?
}
struct AdminLibrarySource: Decodable, Identifiable {
let id: String
let label: String
let type: String
}
struct AdminLibraryResponse: Decodable {
let data: [AdminLibraryItem]
let channels: [AdminLibrarySource]
}
/// `/api/library/entries/<id>` — the detail dialog's payload.
struct AdminLibraryDetail: Decodable, Identifiable {
let id: Int
let bundleId: String
let appName: String
let version: String
let buildNumber: String?
let minOsVersion: String?
let fileName: String
let fileSize: Int64
let iconUrl: String?
let screenshotUrls: [String]?
let description: String?
let developerName: String?
let tweaks: [String]?
let isTweaked: Bool
let entitlements: [String: AnyCodable]?
let privacyInfo: [String: AnyCodable]?
let downloadCount: Int
let channelId: String?
let messageId: Int64?
let isCorrupted: Bool
let corruptionNote: String?
let hasFile: Bool
let createdAt: Date
let updatedAt: Date
}
// MARK: - Queue
struct AdminQueueStats: Decodable {
let pending: Int
let downloading: Int
let processing: Int
let completed: Int
let failed: Int
let skipped: Int
var active: Int { downloading + processing }
}
struct AdminQueueStorage: Decodable {
let blocked: Bool
let usedBytes: Int64
let limitBytes: Int64
}
struct AdminQueueItem: Decodable, Identifiable {
let id: Int
let channelId: String
let sourceType: String?
let messageId: Int64
let fileName: String?
let fileSize: Int64?
let status: String
let error: String?
let createdAt: Date
let updatedAt: Date
}
struct AdminQueueResponse: Decodable {
let data: [AdminQueueItem]
let stats: AdminQueueStats
let storage: AdminQueueStorage
let channelNames: [String: String]?
let total: Int
let page: Int
let pageSize: Int
let totalPages: Int
}
// MARK: - Sources
/// One entry of a forum channel's topic list.
///
/// `Encodable` as well, because a topic toggle sends the whole list back:
/// `PUT /api/channels` replaces `forumTopics` wholesale rather than patching one
/// entry, so a round-trip has to preserve every field the worker wrote.
struct AdminForumTopic: Codable, Identifiable {
let id: Int
let name: String
let enabled: Bool
func setting(enabled newValue: Bool) -> AdminForumTopic {
AdminForumTopic(id: id, name: name, enabled: newValue)
}
}
/// `/api/channels` — a scanned Telegram channel.
struct AdminChannel: Decodable, Identifiable {
let id: Int
let channelId: String
let channelName: String?
let channelDescription: String?
let isActive: Bool
/// `full` or `on_demand`. Absent from a server that predates the mode, and
/// `full` is what those channels did, so that is the fallback.
let scanMode: String?
let isForum: Bool
let forumTopics: [AdminForumTopic]?
let priority: Int
let totalMessages: Int
let ipaCount: Int
let lastScannedAt: Date?
var title: String { channelName ?? channelId }
var enabledTopicCount: Int { (forumTopics ?? []).filter { $0.enabled }.count }
/// The channel is never swept. Builds only arrive from it when a targeted
/// search asks for a tweak by name, which is what lets one app be taken out
/// of a channel without its whole catalog coming too.
var isOnDemand: Bool { scanMode == AdminChannelScanMode.onDemand.rawValue }
var mode: AdminChannelScanMode { isOnDemand ? .onDemand : .full }
}
/// How much of a channel the scanners may take. The raw values are the strings
/// the server stores, so they are written verbatim on the wire.
enum AdminChannelScanMode: String, CaseIterable, Identifiable {
case full
case onDemand = "on_demand"
var id: String { rawValue }
/// A `String` rather than a literal, so it goes through `String.localized`
/// at the call site.
var label: String {
switch self {
case .full: return .localized("Full scan")
case .onDemand: return .localized("On demand")
}
}
}
/// `/api/github/sources` — a GitHub Releases source. `token` is always masked
/// by the server, so this app never sees a PAT.
struct AdminGithubSource: Decodable, Identifiable {
let id: Int
let owner: String
let repo: String
/// Nullable on the wire. The column is optional and the dashboard has always
/// fallen back to `owner/repo` for a source without one, so requiring it here
/// meant a single unnamed source failed the decode for the whole list, which
/// is a list that then reads as "you have no sources".
let displayName: String?
/// The app this repository's tweak goes into, when it publishes a `.dylib`
/// rather than a finished IPA.
///
/// Null for nearly every source, which is what "this repo publishes IPAs"
/// means. Set, and a release with no IPA in it is built from a clean build of
/// this bundle id: the one in the Library when it is current, and otherwise
/// the one at `dylibBaseIpaUrl`.
let dylibBundleId: String?
/// Where a base build of that app is fetched from when the Library holds none
/// at the version the App Store is shipping. `*` stands where the version
/// goes. Null means the Library is the only place a base comes from.
let dylibBaseIpaUrl: String?
/// The version the newest tweak was last actually built against.
///
/// Not what the App Store is shipping: the two differ exactly while an
/// address has not mirrored today's release yet, which is the state worth
/// being able to see on this screen.
let dylibBaseVersion: String?
let isPrivate: Bool
let isActive: Bool
let priority: Int
let lastScannedAt: Date?
let totalReleases: Int
let ipaCount: Int
var slug: String { "\(owner)/\(repo)" }
/// What to call this source on screen. The same fallback the dashboard uses.
var title: String {
guard let displayName, !displayName.isEmpty else { return slug }
return displayName
}
/// What a known tweak locked to this repo stores in `lockedChannelId`. The
/// server keeps Telegram and GitHub locks in that one field and tells them
/// apart by this `github:` prefix, so FTStore Admin has to write it verbatim.
var lockId: String { "github:\(slug)" }
}
// MARK: - System
/// `/api/system` — the worker kill switch.
struct AdminSystemState: Decodable {
let success: Bool?
let enabled: Bool
}
// MARK: - Telegram
/// `/api/auth/telegram` — the worker's TDLib session, as the dashboard sees it.
/// The fields are spread across the response body rather than nested under
/// `data`, so this decodes the envelope too.
struct AdminTelegramStatus: Decodable {
let success: Bool?
let error: String?
/// `disconnected` · `connecting` · `waiting_code` · `waiting_password` ·
/// `ready` · `error`
let state: String
let passwordHint: String?
let busy: Bool
let sessionReady: Bool
let currentCommandType: String?
let progressLabel: String?
let progressCurrent: Int?
let progressTotal: Int?
let retryCount: Int?
let lastHeartbeatAt: Date?
let lastConnectedAt: Date?
let lastAuthAt: Date?
let workerOnline: Bool
var isWaitingForCode: Bool { state == "waiting_code" }
var isWaitingForPassword: Bool { state == "waiting_password" }
/// A `String` rather than a view, so it is localized here: several call sites
/// interpolate it into a sentence, where SwiftUI would never look it up.
var title: String {
switch state {
case "disconnected": return .localized("Disconnected")
case "connecting": return .localized("Connecting…")
case "waiting_code": return .localized("Waiting for code")
case "waiting_password": return .localized("Waiting for 2FA password")
case "ready": return .localized("Connected")
case "error": return .localized("Error")
default: return state.capitalized
}
}
}
// MARK: - Upload
/// `/api/actions/upload-ipa` (GET) — sources an upload may be filed under.
struct AdminUploadSources: Decodable {
let sources: [AdminLibrarySource]
}
/// What the finalize step reports back about the ingested build.
struct AdminUploadResult: Decodable {
let id: Int
let appName: String
let version: String
let bundleId: String
let isTweaked: Bool
let fileSize: Int64
let storageOverLimit: Bool
}
// MARK: - Known tweaks
/// One entry of the `known_tweaks` setting. Every field but `name` is optional
/// on the wire, and an omitted key means "unset" — so encoding drops the nils
/// rather than writing nulls the server would have to special-case.
struct AdminTweakConfig: Codable, Identifiable, Equatable {
var name: String
var aliases: [String]?
/// Builds matching this tweak are only accepted from this channel.
var lockedChannelId: String?
/// No scanned source may contribute builds at all: the operator uploads the
/// IPA by hand. The opposite of an absent `lockedChannelId`, which means take
/// it from anywhere, rather than a stricter version of it.
var manualOnly: Bool?
var recommended: Bool?
/// Which half of the store this lands in: `"game"`, `"app"`, or absent to let
/// the App Store genre decide. The override exists because the genre lookup
/// is by bundle id and cannot answer for anything that was never on the App
/// Store, and because a tweaked build can carry a rewritten identifier the
/// lookup then misses.
var category: String?
/// Somebody ran this one and it worked. Drives the green "Tested" tag in the
/// store, and stacks with `recommended` rather than competing with it: one is
/// taste, the other is a report.
var tested: Bool?
/// The tweak costs money. Drives the red "Paid" tag in the store, whether or
/// not anyone has written down what it costs.
var paid: Bool?
/// What it costs, per tier. Only published for a tweak that is also paid.
var pricing: AdminTweakPricing?
/// Where the licence is sold: the tweak's site, a Discord invite, a shop
/// page. Independent of `pricing`, since where to buy something and what it
/// costs are different facts, and only published for a paid tweak.
var purchaseUrl: String?
/// Server-hosted icon, stored as a relative `/media/icons/…` path.
var iconUrl: String?
var fileNameMustContain: [String]?
var fileNameMustNotContain: [String]?
/// The name is the identity — it is what the matcher and the icon slug are
/// derived from, so two tweaks can never share one.
var id: String { name }
}
/// The price tiers of a paid tweak. Every one is optional and independent: the
/// store shows exactly the tiers that carry a figure, so an operator selling
/// only a lifetime licence fills in that field alone.
struct AdminTweakPricing: Codable, Equatable {
/// ISO 4217 code the amounts are in. Absent means USD.
var currency: String?
var monthly: Double?
var yearly: Double?
var lifetime: Double?
/// Nothing is for sale, so the whole object is dropped rather than written
/// as a set of nulls the server would have to strip again.
var isEmpty: Bool {
monthly == nil && yearly == nil && lifetime == nil
}
}
/// `/api/settings` narrowed to the one key the tweak editor cares about. The
/// generic `AdminSettingsResponse` would hand back `AnyCodable` and force a
/// re-encode round trip to get typed entries out.
struct AdminKnownTweaksResponse: Decodable {
struct Payload: Decodable {
let tweaks: [AdminTweakConfig]
enum CodingKeys: String, CodingKey {
case tweaks = "known_tweaks"
}
}
let data: Payload
}
/// `/api/actions/import-tweak` — the ack, plus the entry the server wrote.
///
/// The tweak comes back so the editor can fold it into its working copy instead
/// of reloading, which would throw away anything else unsaved on that screen.
struct AdminTweakImportAck: Decodable {
let success: Bool?
let message: String?
let error: String?
let tweak: AdminTweakConfig?
let channelId: String?
/// The mode the channel ended up in. An existing channel keeps the one it
/// already had, so an import can report "full scan" here.
let scanMode: String?
var summary: String { message ?? error ?? "Done" }
}
/// `/api/settings/tweak-icon` (POST) — where the uploaded icon landed.
struct AdminTweakIcon: Decodable {
struct Payload: Decodable {
let url: String
}
let success: Bool?
let data: Payload?
let error: String?
}
// MARK: - Tweak requests
/// One row of `/api/tweak-requests`: every request for the same source, folded
/// into one thing to decide.
///
/// Not to be confused with `AdminTweakConfig` above: that is the `known_tweaks`
/// *matcher config* (what groups a tweaked build in the feed). This is a queue
/// of people asking for a source, waiting on a yes or a no.
struct AdminTweakRequest: Codable, Identifiable, Equatable {
/// One name people gave this source, and how many asked for it by that name.
struct Name: Codable, Equatable {
let name: String
let count: Int
}
/// The group's identity — a decision acts on it, not on a row id.
///
/// `groupKey`, not `sourceId`: a wish filed without a source has none, and
/// every such wish would otherwise share the same empty identity.
var id: String { groupKey }
/// What makes two rows the same wish. The source when there is one, the
/// folded tweak name when there is not.
let groupKey: String
/// `telegram` or `github`. Null when nobody named a source.
let sourceType: String?
/// Normalised: `@handle` or `github:owner/repo`. Null when none was given.
let sourceId: String?
/// Every row behind the group. What a deletion acts on, since a wish with no
/// source cannot be named by one.
let allIds: [Int]
/// The spelling most people used.
let tweakName: String
/// Every distinct wish under this source, most-wanted first.
let tweakNames: [Name]
/// Distinct installs that asked. This is the demand number.
let requestCount: Int
/// Rows behind the group — higher than `requestCount` when someone asked twice.
let entryCount: Int
/// What a decision should act on. Empty once the group is settled.
let pendingIds: [Int]
/// Held at the top of the app's board by an operator.
let pinnedAt: Date?
/// The row this source's public votes and replies hang off: the oldest one.
///
/// The board attaches everything for a source to one request, so five people
/// asking for the same channel are one wish with one score rather than five
/// copies splitting the votes between them. The server resolves whatever id
/// it is handed, so this only has to be *a* row of the group.
let boardId: Int?
/// What the app's users did with this wish. Different from `requestCount`:
/// that counts the people who asked before there was anywhere to say "me
/// too", this counts the ones who said it.
let upvotes: Int?
let downvotes: Int?
let score: Int?
let commentCount: Int?
/// Exactly what someone typed, which may differ from `sourceId`.
let rawInput: String
let note: String?
/// `pending`, `approved` or `rejected`.
let status: String
/// Shown to the users in the app, so it is written for them, not for us.
let decisionReason: String?
let decidedAt: Date?
/// Operator username, resolved server-side.
let decidedBy: String?
let firstRequestedAt: Date?
let lastRequestedAt: Date?
var isPending: Bool { !pendingIds.isEmpty }
/// The id to open this group's public thread with.
///
/// `boardId` is the canonical row and the right answer. The fallback is for
/// an FTStore Admin build talking to a server that predates the field: any row of the
/// group reaches the same thread, because the server resolves whatever id it
/// is handed. A settled group on such a server has neither, and then there
/// is genuinely nothing to open.
var threadId: Int? { boardId ?? pendingIds.first }
var isPinned: Bool { pinnedAt != nil }
/// `github:owner/repo` reads better without the prefix once the icon says so.
/// Nil when nobody named a source, which every view has to allow.
var sourceLabel: String? {
guard let sourceId else { return nil }
return sourceType == "github" && sourceId.hasPrefix("github:")
? String(sourceId.dropFirst("github:".count))
: sourceId
}
/// The label, or a sentence saying there is none. For the places that need
/// a string rather than a branch.
var sourceLabelOrNone: String {
sourceLabel ?? .localized("No source given")
}
/// The names after the headline one, for the "also asked for" line.
var otherNames: [Name] { Array(tweakNames.dropFirst()) }
/// The public tally, in the shape the feedback thread already draws.
var votes: AdminFeedbackVotes {
AdminFeedbackVotes(
upvotes: upvotes ?? 0,
downvotes: downvotes ?? 0,
score: score ?? 0
)
}
/// Whether this group should survive the search field.
///
/// Matches the same fields the server's `?search=` does (tweak name, source
/// id, raw input) plus the alternate spellings, which only exist once rows
/// are grouped: someone searching a name that lost the headline vote should
/// still find the group it was folded into.
func matches(_ query: String) -> Bool {
let haystacks = ([tweakName, sourceId, sourceLabel, rawInput] + tweakNames.map(\.name))
.compactMap { $0 }
return haystacks.contains { $0.localizedCaseInsensitiveContains(query) }
}
}
// MARK: - Feedback
/// The ticket lifecycle, mirroring `FEEDBACK_STATUS_TABLE` in
/// `web/src/lib/feedback.ts`. The server validates the value, so an unknown one
/// here is a version skew rather than an error: it falls back to showing the raw
/// code instead of hiding the ticket.
enum AdminFeedbackStatus: String, CaseIterable, Identifiable {
case open
case planned
case inProgress = "in_progress"
case resolved
case declined
case duplicate
var id: String { rawValue }
var label: String {
switch self {
case .open: return .localized("Open")
case .planned: return .localized("Planned")
case .inProgress: return .localized("In Progress")
case .resolved: return .localized("Resolved")
case .declined: return .localized("Declined")
case .duplicate: return .localized("Duplicate")
}
}
/// Whether the ticket counts as settled. Matches the `closed` column of the
/// server's table, and is the only thing the open/closed filter reads.
var isClosed: Bool {
switch self {
case .open, .planned, .inProgress: return false
case .resolved, .declined, .duplicate: return true
}
}
var symbol: String {
switch self {
case .open: return "smallcircle.filled.circle"
case .planned: return "checklist"
case .inProgress: return "hammer"
case .resolved: return "checkmark.circle"
case .declined: return "xmark.circle"
case .duplicate: return "doc.on.doc"
}
}
static func label(for raw: String) -> String {
AdminFeedbackStatus(rawValue: raw)?.label ?? raw
}
}
/// What the board voted on a ticket or a reply.
///
/// Read-only here, and there is no `myVote`: an operator vote would be an
/// endorsement with no identity behind it, so FTStore Admin counts what users did
/// and the honest way for the team to agree with something is to reply.
struct AdminFeedbackVotes: Equatable {
let upvotes: Int
let downvotes: Int
/// Up minus down, which is what the board ranks on.
let score: Int
static let none = AdminFeedbackVotes(upvotes: 0, downvotes: 0, score: 0)
var isEmpty: Bool { upvotes == 0 && downvotes == 0 }
/// Signed, so a report people voted down says so rather than reading like
/// one nobody has looked at.
var scoreLabel: String { score > 0 ? "+\(score)" : "\(score)" }
}
/// One reply under a tweak request, as `/api/tweak-requests/<id>/board` reports.
///
/// Unlike the request above it, a reply was written in the open, under a
/// composer that says so, which is why it carries a handle. The request itself
/// predates the board and was typed privately, so it names nobody.
struct AdminTweakRequestComment: Codable, Identifiable, Equatable {
let id: Int
let requestId: Int
/// `user` or `team`. This, never the name, is what draws the team badge.
let authorRole: String
let authorName: String
let body: String
let hidden: Bool
let hiddenReason: String?
let deletedAt: Date?
let editedAt: Date?
var editedByTeam: Bool?
var teamMentioned: Bool?
/// Which operator wrote it. Recorded for the audit trail and never published.
let operatorName: String?
let upvotes: Int?
let downvotes: Int?
let score: Int?
/// Where FTStore stands on this reply, already counted in the numbers above.
let teamVote: Int?
let createdAt: Date?
var isTeam: Bool { authorRole == "team" }
var isDeleted: Bool { deletedAt != nil }
var wasEditedByTeam: Bool { editedByTeam == true }
var asksForTeam: Bool { teamMentioned == true }
var votes: AdminFeedbackVotes {
AdminFeedbackVotes(
upvotes: upvotes ?? 0,
downvotes: downvotes ?? 0,
score: score ?? 0
)
}
}
/// `GET /api/tweak-requests/<id>/board`.
///
/// The tally rides along with the thread rather than being a second call: the
/// screen that shows one always shows the other, and two requests would let
/// them disagree on screen for as long as the slower one took.
struct AdminTweakRequestBoard: Decodable {
let success: Bool?
/// The canonical row the server resolved the id to.
let requestId: Int?
let votes: AdminTweakRequestTally?
let data: [AdminTweakRequestComment]?
let error: String?
var comments: [AdminTweakRequestComment] { data ?? [] }
}
/// Where the board stands on a wish, and where FTStore stands on it.
struct AdminTweakRequestTally: Decodable, Equatable {
let upvotes: Int
let downvotes: Int
let score: Int
/// FTStore's own, already counted in the numbers above.
var teamVote: Int?
static let none = AdminTweakRequestTally(
upvotes: 0, downvotes: 0, score: 0, teamVote: 0
)
var votes: AdminFeedbackVotes {
AdminFeedbackVotes(upvotes: upvotes, downvotes: downvotes, score: score)
}
}
/// `/api/dashboard/usage` — who is using this, and what they are taking.
///
/// Two questions the rest of the dashboard cannot answer: the catalog counts
/// what the server ingested, which says nothing about what anybody fetched, and
/// the queue counts work rather than people.
struct AdminUsage: Decodable {
let success: Bool?
let data: Payload?
let error: String?
struct Payload: Decodable {
let installs: Installs
let boards: Boards
let downloads: Downloads
}
/// Installs **that have opened the board**, not everyone running the app.
/// Somebody who only browses the store is not counted, which is why the
/// screen says so rather than calling this "users".
struct Installs: Decodable {
let known: Int
let named: Int
let muted: Int
let seenDay: Int
let seenWeek: Int
let seenMonth: Int
/// False until the recency stamp has had time to mean anything.
let measured: Bool
}
struct Boards: Decodable {
let openFeedback: Int
let pendingRequests: Int
let feedbackThisWeek: Int
let requestsThisWeek: Int
}
struct Downloads: Decodable {
let total: Int
let apps: [App]
/// Whether the ranking saw everything it could have.
let truncated: Bool
struct App: Decodable, Identifiable {
var id: String { bundleId + (tweak ?? "") }
let name: String
let bundleId: String
let tweak: String?
let downloads: Int
}
}
}
/// `/api/dashboard/alerts` — the few numbers worth waking an operator for.
///
/// Deliberately decides nothing. Whether a number becomes a notification is the
/// app's call against what it saw last time; a server that decided would have to
/// know which device had already been told.
struct AdminAlerts: Decodable {
let success: Bool?
let data: Payload?
let error: String?
struct Payload: Decodable {
let storage: Storage
let feedback: Feedback
let tweakRequests: TweakRequests
/// Absent from a server that predates private mode, which reads as
/// "nothing to say" rather than as a broken response.
let access: Access?
}
struct Storage: Decodable {
let usedBytes: Int64
/// 0 when no cap is configured, which reads as "no alarm".
let limitBytes: Int64
/// Used over the cap, or nil when there is no cap to be a fraction of.
let ratio: Double?
let overLimit: Bool
var hasLimit: Bool { limitBytes > 0 }
}
struct Feedback: Decodable {
/// The newest id rather than a count: a count moves both ways as things
/// are decided, so "is there something I have not seen?" cannot be
/// answered from it. An id only ever goes up.
let newestId: Int?
let newestTitle: String?
let newestKind: String?
let open: Int
}
struct TweakRequests: Decodable {
let newestId: Int?
let newestName: String?
let newestSource: String?
let pending: Int
}
/// Private mode, for the "somebody new signed in" alert.
///
/// Nearly the same shape as the two boards above, with one difference: they
/// answer "anything new?" from an id that only goes up, and a session has no
/// such id — its key is the digest of a token. So this answers it with the
/// moment of the newest sign-in, which is the same question and the same
/// comparison against a stored value.
///
/// The countries are the ones somebody is currently signed in from rather
/// than every one that ever knocked, because a stranger guessing is not news
/// and a device that got in from somewhere new is.
struct Access: Decodable {
/// False when nothing is being asked of anyone, so the watcher stays quiet.
let isPrivate: Bool
let newestAt: Date?
/// Whose it is, by handle.
let newestLabel: String?
/// What the device called itself, when it said anything.
let newestDevice: String?
let newestCountry: String?
let active: Int
let countries: [String]
private enum CodingKeys: String, CodingKey {
// `private` is a keyword, so this is the one field in the whole
// wire format that cannot keep the server's spelling.
case isPrivate = "private"
case newestAt, newestLabel, newestDevice, newestCountry, active, countries
}
}
}
// MARK: - Private mode
/// Everything `/api/access` answers with, which is the whole Access screen.
///
/// One response rather than four, for the reason the dashboard gives: the
/// switch means nothing without the device list under it, and the country
/// totals mean nothing without knowing whether the gate was even on.
///
/// Nothing here is a credential. An invitation link exists in the clear only in
/// the response that mints it, and a session token never leaves the server —
/// a device is named by a prefix of its digest, which is enough to sign it out
/// and worth nothing on its own.
struct AdminAccessOverview: Decodable {
/// The switch, which is the whole of whether the gate is shut.
let enabled: Bool
/// On, with nobody who can sign in and no invitation outstanding. Nobody
/// gets in and the screen says so.
let stranded: Bool
/// How many invitations could still be redeemed right now.
let openInvites: Int
/// How many accounts exist at all, which is who can sign in.
let accountCount: Int
/// Devices one account may be signed in on at once. Zero means no limit.
let maxDevices: Int
/// How long a fresh invitation stays good.
let inviteDays: Int
let note: String
let activeDevices: Int
let invites: [AdminAccessInvite]
let accounts: [AdminAccessAccount]
let devices: [AdminAccessDevice]
let countries: [AdminAccessCountry]
var limitReached: Bool { maxDevices > 0 && activeDevices >= maxDevices }
/// The gate is on and there is somebody who can answer it.
var isSealed: Bool { enabled && !stranded }
/// Whether turning it on would leave anybody able to get in.
var canClose: Bool { accountCount > 0 || openInvites > 0 }
}
/// How a row in the invitation list is doing.
///
/// One value rather than three booleans, because they are not independent and a
/// screen that renders them separately ends up saying "used, expired" about a
/// link somebody redeemed last year.
enum AdminInviteState: String, Decodable {
case open
case used
case revoked
case expired
var label: String {
switch self {
case .open: return .localized("Not used yet")
case .used: return .localized("Used")
case .revoked: return .localized("Withdrawn")
case .expired: return .localized("Expired")
}
}
}
struct AdminAccessInvite: Decodable, Identifiable {
let id: Int
let label: String
let state: AdminInviteState
/// The account it created, by handle, once it has been redeemed.
let usedBy: String?
let createdAt: Date?
let expiresAt: Date?
let usedAt: Date?
}
struct AdminAccessAccount: Decodable, Identifiable {
/// The account's own id, which is also what its posts are written under.
let installId: String
/// `@name` when they picked one, otherwise their anonymous handle.
let handle: String
let blocked: Bool
let createdAt: Date?
let lastSeenAt: Date?
/// How many devices are signed in to it right now.
let devices: Int
var id: String { installId }
}
struct AdminAccessDevice: Decodable, Identifiable {
/// A prefix of the session's digest: enough to name one row and to sign it
/// out, and worth nothing on its own.
let id: String
/// `web` for a browser, `app` for FTStore.
let kind: String
let device: String?
let country: String?
/// Whose device it is, by handle.
let account: String
let createdAt: Date?
let lastSeenAt: Date?
let expiresAt: Date?
var isApp: Bool { kind == "app" }
/// What to show under the row's title when the device said nothing about
/// itself.
var fallbackName: String { device ?? .localized("Unnamed device") }
}
struct AdminAccessCountry: Decodable, Identifiable {
let country: String
let opened: Int
let refused: Int
var id: String { country }
}
/// The one response that carries a link in the clear: the reply to making an
/// invitation. Held on screen until the operator dismisses it, because only a
/// digest is stored and there is no second chance to read it.
struct AdminAccessInviteLink: Decodable, Identifiable {
let id: Int
let label: String?
let link: String
let expiresAt: Date?
}
/// One person on the feedback board, as `/api/feedback/authors` reports them.
///
/// The install id is the identity: an opaque UUID the app minted, which is what
/// a mute and a rename act on. It identifies a phone, never a person, and it is
/// the reason this screen exists — a handle can change and an anonymous one is
/// a number, so the id is the only stable thing to address.
struct AdminFeedbackAuthorRow: Codable, Identifiable, Equatable {
var id: String { installId }
let installId: String
let displayName: String?
/// The name when there is one, `Anonymous_<n>` when there is not.
let handle: String
let handleNumber: Int
/// There is an account here: a name and a password.
///
/// This used to mean "has claimed a name", which was the same thing while a
/// name and a four-digit PIN were all there was. It is not the same thing any
/// more, and `legacyName` below is the other half of what it used to cover.
let registered: Bool
/// Claimed a name under the old PIN rules and has not signed up yet.
/// Optional so a console talking to an older server decodes rather than fails.
let legacyName: Bool?
let avatarUrl: String?
/// The account was closed by an operator. Distinct from never having
/// registered, which is the same two nulls without this: one is somebody who
/// has not signed up, the other is somebody who did and is gone.
let deletedAt: Date?
let blocked: Bool
let blockedNote: String?
let tickets: Int?
let comments: Int?
/// Reviews written. Optional, like the two counts above it.
let reviews: Int?
/// Devices currently signed in, which is what signing out everywhere undoes.
let sessions: Int?
let createdAt: Date?
var isNamed: Bool { !(displayName ?? "").isEmpty }
/// Somebody who held a name before accounts existed. Worth telling apart on
/// screen: reading one as registered is the wrong thing to tell an operator.
var isLegacyName: Bool { legacyName ?? false }
/// The account was closed. Three states, not two: this and "never
/// registered" are the same two nulls on the row and mean opposite things.
var isDeleted: Bool { deletedAt != nil }
/// How many phones are signed in, or nothing to say.
var deviceLabel: String? {
guard let sessions, sessions > 0 else { return nil }
return sessions == 1
? String.localized("1 device")
: String.localized("%lld devices", sessions)
}
/// What this person has written, as one line.
///
/// All three places, not two: reviews arrived after this row did, and a line
/// that counted reports and replies while silently ignoring reviews is how
/// somebody gets muted on a misreading of what they have actually been
/// doing.
///
/// Only the non-zero parts, so a row about somebody who wrote one review says
/// that rather than burying it between two zeroes. Nothing at all is most of
/// the rows: somebody who opened FTStore and never wrote in it.
var activityLabel: String {
var parts: [String] = []
if let tickets, tickets > 0 {
parts.append(tickets == 1
? String.localized("1 report")
: .localized("%lld reports", tickets))
}
if let reviews, reviews > 0 {
parts.append(reviews == 1
? String.localized("1 review")
: .localized("%lld reviews", reviews))
}
if let comments, comments > 0 {
parts.append(comments == 1
? String.localized("1 reply")
: .localized("%lld replies", comments))
}
return parts.isEmpty ? .localized("nothing yet") : parts.joined(separator: " · ")
}
}
/// `/api/feedback/authors/<installId>` — what one person has written.
///
/// Both boards in one payload. Somebody's pattern of posting is not split
/// across two screens just because the code is.
struct AdminFeedbackAuthorDetail: Decodable {
let success: Bool?
let data: Payload?
let error: String?
struct Payload: Decodable {
let author: AdminFeedbackAuthorRow
let tickets: [Ticket]?
let comments: [Reply]?
let requestComments: [RequestReply]?
/// The third place a person writes. Optional so a console talking to an
/// older server still decodes the rest rather than failing outright.
let reviews: [Review]?
}
/// A report they opened.
struct Ticket: Decodable, Identifiable {
let id: Int
let kind: String
let title: String
let status: String
let hidden: Bool
let commentCount: Int
let score: Int
let createdAt: Date?
var isBug: Bool { kind == "bug" }
var symbol: String { isBug ? "ladybug" : "lightbulb" }
var statusLabel: String { AdminFeedbackStatus.label(for: status) }
}
/// A reply they wrote on the feedback board.
struct Reply: Decodable, Identifiable {
let id: Int
let ticketId: Int
let ticketTitle: String?
let body: String?
let hidden: Bool
let deletedAt: Date?
let createdAt: Date?
var isDeleted: Bool { deletedAt != nil }
}
/// A reply they wrote under a tweak request.
struct RequestReply: Decodable, Identifiable {
let id: Int
let requestId: Int
let tweakName: String?
let body: String
let hidden: Bool
let deletedAt: Date?
let createdAt: Date?
var isDeleted: Bool { deletedAt != nil }
}
/// A review they wrote about an app in the store.
struct Review: Decodable, Identifiable {
let id: Int
/// The listing it is about. Two tweaks of one app share a bundle id, so
/// this is the only field that says which of them was reviewed.
let appKey: String
let appName: String
/// `stars` or `works`. Which of the next two carries the verdict.
let mode: String
let rating: Int?
let works: Bool?
let title: String?
let body: String?
let hidden: Bool
let deletedAt: Date?
/// They asked FTStore to check the build. Only ever on a thumbs down.
let reportedBroken: Bool
/// Whether FTStore has answered it, so an unanswered one is findable.
let answered: Bool
let createdAt: Date?
var isDeleted: Bool { deletedAt != nil }
var isThumb: Bool { mode == "works" }
/// What the row shows in place of a headline. A thumb has none, so the
/// first thing it wrote stands in, and a thumb with nothing written is a
/// complete answer about a build that just works.
var headline: String {
if let title, !title.isEmpty { return title }
if let body, !body.isEmpty { return body }
return isThumb
? (works == true ? .localized("Works") : .localized("Doesn't work"))
: .localized("No headline")
}
}
}
/// `/api/feedback/authors` — the list, plus the count the filter needs.
struct AdminFeedbackAuthorsResponse: Decodable {
let success: Bool?
let data: [AdminFeedbackAuthorRow]?
let stats: Stats?
let total: Int?
let totalPages: Int?
let error: String?
struct Stats: Decodable {
let blocked: Int?
}
var authors: [AdminFeedbackAuthorRow] { data ?? [] }
var blockedCount: Int { stats?.blocked ?? 0 }
}
/// One row of `/api/feedback`, and the header of `/api/feedback/<id>`.
///
/// Not to be confused with `AdminTweakRequest`: that is a queue of people asking
/// FTStore to follow a source, decided in bulk. This is a public conversation
/// with one person about the app itself.
struct AdminFeedbackTicket: Codable, Identifiable, Equatable {
let id: Int
/// `bug` or `feature`.
let kind: String
let title: String
let body: String
let status: String
let closed: Bool
/// What the reporter calls themselves, or "Anonymous". Resolved server-side.
let authorName: String
/// Distinct installs that voted it up, down, and the two subtracted. Optional
/// so an FTStore Admin build talking to a server that predates voting still lists the
/// board rather than failing to decode it.
let upvotes: Int?
let downvotes: Int?
let score: Int?
let commentCount: Int
/// Taken off the board. Still visible here, which is how it gets undone.
let hidden: Bool
let hiddenReason: String?
let editedAt: Date?
/// The last edit was ours, not the reporter's. Optional so an FTStore Admin build talking
/// to a server that predates the marker still decodes the board.
let editedByTeam: Bool?
/// Held at the top of the board, and when. Null when it is not pinned.
let pinnedAt: Date?
/// Where FTStore stands: `1`, `-1`, or `0`. Already counted in the numbers.
let teamVote: Int?
/// Somebody wrote `@FTStore` in it. Operator-only, so optional here too.
let teamMentioned: Bool?
let lastActivityAt: Date?
let createdAt: Date?
/// Operator-only triage fields, absent from every response the app gets.
let installId: String?
let clientVersion: String?
let systemVersion: String?
let deviceModel: String?
/// Screenshots on the report. Optional so a server that predates them still
/// decodes; `shots` is what the screens read.
let attachments: [AdminFeedbackAttachment]?
var shots: [AdminFeedbackAttachment] { attachments ?? [] }
var isBug: Bool { kind == "bug" }
var isPinned: Bool { pinnedAt != nil }
var wasEditedByTeam: Bool { editedByTeam == true }
var asksForTeam: Bool { teamMentioned == true }
var statusLabel: String { AdminFeedbackStatus.label(for: status) }
var symbol: String { isBug ? "ladybug" : "lightbulb" }
var votes: AdminFeedbackVotes {
AdminFeedbackVotes(
upvotes: upvotes ?? 0,
downvotes: downvotes ?? 0,
score: score ?? 0
)
}
var kindLabel: String {
isBug ? .localized("Bug") : .localized("Feature")
}
/// The device line for the triage panel, skipping whatever is absent. A
/// feature request carries none of it and shows nothing rather than dashes.
var deviceSummary: String? {
let parts = [
clientVersion.map { String.localized("FTStore %@", $0) },
systemVersion.map { String.localized("iOS %@", $0) },
deviceModel,
].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
func matches(_ query: String) -> Bool {
[title, body, authorName, "#\(id)"]
.contains { $0.localizedCaseInsensitiveContains(query) }
}
}
/// A screenshot somebody attached to a report or to a reply on one.
///
/// `bytes` and `installId` reach the operator and never the app, and both are
/// here for the same reason: an operator deciding what to take down needs to
/// know who uploaded it, and one deciding what is eating the disk needs to know
/// how big it is.
struct AdminFeedbackAttachment: Codable, Identifiable, Equatable {
let id: Int
/// Server-relative, e.g. `/media/shots/<name>.jpg`.
let url: String
let width: Int?
let height: Int?
/// Taken off the board by us. The row and the file stay, so it can go back.
let hidden: Bool
let hiddenReason: String?
let bytes: Int
let installId: String?
let createdAt: Date?
/// The shape to reserve while it loads, clamped so one very tall screenshot
/// cannot push the rest of the thread off the screen.
var aspectRatio: CGFloat {
guard let width, let height, width > 0, height > 0 else { return 1 }
return min(max(CGFloat(width) / CGFloat(height), 0.5), 2)
}
}
/// One entry in a thread: a reply, a status change, or both at once.
struct AdminFeedbackComment: Codable, Identifiable, Equatable {
let id: Int
let ticketId: Int
/// `user` or `team`. This, never the name, is what draws the team badge.
let authorRole: String
/// Always "FTStore" on a team entry, whoever actually typed it.
let authorName: String
/// Null on a bare status change, which renders as a timeline line.
let body: String?
let statusFrom: String?
let statusTo: String?
/// As on a ticket, and optional for the same reason.
let upvotes: Int?
let downvotes: Int?
let score: Int?
let hidden: Bool
let hiddenReason: String?
/// Set when the person who wrote it took it back. Gone from the app for
/// everyone, still here struck through: an operator reading a thread needs to
/// see the line the replies under it were answering, and needs to be able to
/// tell "they took this back" apart from "we took it down".
let deletedAt: Date?
let editedAt: Date?
/// See the same field on a ticket. An operator's rewrite is never silent.
let editedByTeam: Bool?
/// Where FTStore stands on this reply. Optional, as the counts are.
let teamVote: Int?
let createdAt: Date?
/// Which operator wrote it. Dashboard and FTStore Admin only, never the app.
let operatorName: String?
/// See the same field on a ticket.
let attachments: [AdminFeedbackAttachment]?
var shots: [AdminFeedbackAttachment] { attachments ?? [] }
var isTeam: Bool { authorRole == "team" }
/// Whether it was rewritten by us rather than by whoever wrote it.
var wasEditedByTeam: Bool { editedByTeam == true }
var isDeleted: Bool { deletedAt != nil }
/// A status change with nothing written alongside it.
var isStatusOnly: Bool { statusTo != nil && (body ?? "").isEmpty }
var votes: AdminFeedbackVotes {
AdminFeedbackVotes(
upvotes: upvotes ?? 0,
downvotes: downvotes ?? 0,
score: score ?? 0
)
}
}
/// One line of a diff the server computed.
///
/// Computed there rather than here because three front ends would otherwise
/// each need a diff, two of them in Swift, and a diff that disagreed between
/// the app and this app would be worse than none.
struct AdminDiffLine: Codable, Equatable {
/// `context`, `add` or `remove`.
let kind: String
let text: String
}
/// What a report or a reply said before an edit, with what that edit changed.
struct AdminFeedbackRevision: Codable, Identifiable, Equatable {
let id: Int
/// Null on a reply, which has no title.
let title: String?
let body: String
/// `user` or `team`, so a reader can tell whose hand it was.
let editorRole: String
let createdAt: Date?
let diff: [AdminDiffLine]
let titleChanged: Bool
var byTeam: Bool { editorRole == "team" }
}
/// The reporter, as `/api/feedback/<id>` describes them.
struct AdminFeedbackAuthor: Codable, Equatable {
let installId: String
let displayName: String?
/// What the board calls them, registered or not: a name they hold with a PIN,
/// or the `Anonymous_12` every install has. This is the `@` that reaches them.
let handle: String?
/// Whether the name is registered, as opposed to the handle they were given.
let registered: Bool?
/// Muted: can still read and still be replied to, cannot post.
let blocked: Bool
let blockedNote: String?
var isRegistered: Bool { registered ?? false }
}
/// `/api/feedback/<id>` — the whole thread in one payload.
struct AdminFeedbackThread: Codable, Equatable {
let ticket: AdminFeedbackTicket
let comments: [AdminFeedbackComment]
let author: AdminFeedbackAuthor?
}
/// `/api/feedback` — the queue, plus board-wide counts that ignore the filters.
struct AdminFeedbackListResponse: Decodable {
struct Stats: Decodable {
let open: Int
let planned: Int
let closed: Int
let bug: Int
let feature: Int
/// `in_progress` on the wire, which is not a valid Swift property name.
let inProgress: Int
enum CodingKeys: String, CodingKey {
case open, planned, closed, bug, feature
case inProgress = "in_progress"
}
}
let data: [AdminFeedbackTicket]
let stats: Stats?
let total: Int
let page: Int
let totalPages: Int
}
// MARK: - Request bodies
/// Payloads FTStore Admin POSTs/PUTs. Kept together (and at file scope) so every
/// screen sends the exact shape its handler destructures.
// MARK: - Reviews
/// One review of one app in the store, as `/api/reviews` reports it.
///
/// The nearest thing to it is `AdminFeedbackTicket`, and the difference is worth
/// keeping in mind while reading this screen: a ticket is a conversation and has
/// a status, a thread and a score. A review is a verdict. It has stars, one
/// answer allowed underneath it, and nothing else.
struct AdminReview: Codable, Identifiable, Equatable {
let id: Int
/// The feed's grouping key: `bundleId` or `bundleId::TweakName`.
///
/// Not the bundle id, and the distinction is the whole point: two tweaks of
/// one app share a bundle id and are two listings in the store, so this is
/// the only field that says which of them was reviewed.
let appKey: String
let bundleId: String
let tweakName: String?
/// What the store called the app when this was written. A snapshot, so a
/// review whose builds have since been swept still names something.
let appName: String
/// Which build they had, when the app said.
let appVersion: String?
/// `stars` or `works`. Which of the next two carries the verdict.
///
/// A tweak listing is somebody's work and gets a rating out of five; a plain
/// listing is somebody's build of an app that already exists, where the only
/// open question is whether this copy of it runs.
let mode: String
/// 1 to 5, on a tweak listing.
let rating: Int?
/// Whether the build works, on a plain listing.
let works: Bool?
/// The headline. Null on a thumb, which is already the whole verdict.
let title: String?
/// What they wrote. Always there beside a rating, optional beside a thumb.
let body: String?
/// They asked FTStore to look at this build. Only ever on a thumbs down, and
/// it is a request rather than a state: nothing is published until an
/// operator confirms it.
let reportedBroken: Bool
let authorName: String
let hidden: Bool
let hiddenReason: String?
let editedAt: Date?
/// The last edit was an operator's. Drawn wherever the review is, because a
/// store where somebody else can rewrite a verdict without it showing is not
/// one anybody should trust.
let editedByTeam: Bool
let response: Response?
/// Which operator answered it. Recorded for the audit trail and never
/// published: an answer is from FTStore, full stop.
let responseOperatorName: String?
/// When its author took it back. Operator-only, drawn struck through.
let deletedAt: Date?
let createdAt: Date?
/// FTStore's public answer, which is the App Store's developer response.
struct Response: Codable, Equatable {
let body: String
/// Always "FTStore". Decoded rather than assumed, so a server that ever
/// changes it does not leave the console saying something else.
let authorName: String
let at: Date?
let editedAt: Date?
}
var isAnswered: Bool { response != nil }
var isWithdrawn: Bool { deletedAt != nil }
/// Whether this one is a thumb rather than stars.
var isThumb: Bool { mode == "works" }
/// What the row shows in place of a headline. A thumb has none, so the first
/// thing it wrote stands in, and a thumb with nothing written is a complete
/// answer about a build that just works.
var headline: String {
if let title, !title.isEmpty { return title }
if let body, !body.isEmpty { return body }
return isThumb
? (works == true ? .localized("Works") : .localized("Doesn't work"))
: .localized("No headline")
}
/// What the row shows for the app: the name, and the tweak when there is one
/// and the name does not already carry it.
var appLabel: String {
guard let tweakName, !appName.localizedCaseInsensitiveContains(tweakName) else {
return appName
}
return "\(appName) · \(tweakName)"
}
func matches(_ query: String) -> Bool {
let needle = query.lowercased()
return (title ?? "").lowercased().contains(needle)
|| (body ?? "").lowercased().contains(needle)
|| appName.lowercased().contains(needle)
|| appKey.lowercased().contains(needle)
|| authorName.lowercased().contains(needle)
}
}
/// `/api/reviews` — a page of the queue plus the numbers for the whole store.
struct AdminReviewListResponse: Decodable {
let success: Bool?
let data: [AdminReview]?
let stats: Stats?
let total: Int?
let error: String?
/// About the whole store, never the current filter: an operator checking how
/// many reviews are unanswered should not get an answer that moves when they
/// type in the search box.
struct Stats: Decodable {
let count: Int
/// Null until somebody has written one. Never rendered as a zero-star
/// store, which is what a defaulted `0` would say.
let average: Double?
let unanswered: Int
let hidden: Int
let apps: Int
/// How many reviews gave each number of stars, keyed `"1"` to `"5"`.
let histogram: [String: Int]?
func bucket(_ star: Int) -> Int { histogram?[String(star)] ?? 0 }
}
}
/// One app that has been reviewed, as `/api/reviews/apps` reports it.
struct AdminReviewedApp: Decodable, Identifiable, Equatable {
var id: String { appKey }
let appKey: String
let bundleId: String
let tweakName: String?
let appName: String
/// `stars` or `works`, so a row draws the score this listing actually has.
let mode: String
let count: Int
let average: Double?
/// The share that says the build works, on a `works` listing.
let ratio: Double?
var isThumb: Bool { mode == "works" }
/// Reviews nobody has answered yet, which is what makes a row work.
let unanswered: Int
let lastReviewAt: Date?
func matches(_ query: String) -> Bool {
let needle = query.lowercased()
return appName.lowercased().contains(needle) || appKey.lowercased().contains(needle)
}
}
struct AdminReviewedAppsResponse: Decodable {
let success: Bool?
let data: [AdminReviewedApp]?
let error: String?
}
/// A one-time password reset link, as the route that mints it answers.
///
/// Shown once and stored only as a digest, so this is the only time the token
/// exists anywhere but in the link itself.
struct AdminResetLink: Decodable, Equatable {
let url: String
let expiresAt: Date?
}
/// One listing somebody says is broken, as `/api/reviews/broken` reports it.
///
/// Two things in one row, and both matter: how many people are asking for a
/// check, and what an operator already decided. A row with a flag and no reports
/// left is what "somebody fixed it and nobody has said so" looks like, and it is
/// the one most worth revisiting.
struct AdminBrokenBuild: Decodable, Identifiable, Equatable {
var id: String { appKey }
let appKey: String
let appName: String
/// What the newest reporter had installed, which is usually the one to flag.
let reportedVersion: String?
/// Open reports nobody has answered.
let reports: Int
let lastReportAt: Date?
let flag: Flag?
/// What an operator confirmed. One version, which is what turns one decision
/// into two published states: that version reads as broken, anything newer
/// reads as possibly broken until the flag is cleared.
struct Flag: Decodable, Equatable {
let version: String
let note: String?
let decidedAt: Date?
}
var isFlagged: Bool { flag != nil }
func matches(_ query: String) -> Bool {
let needle = query.lowercased()
return appName.lowercased().contains(needle) || appKey.lowercased().contains(needle)
}
}
struct AdminBrokenBuildsResponse: Decodable {
let success: Bool?
let data: [AdminBrokenBuild]?
let stats: Stats?
let error: String?
struct Stats: Decodable {
/// Reported and not yet answered.
let pending: Int
/// Confirmed broken and still carrying a warning.
let flagged: Int
}
}
/// One earlier version of a review, or of the answer to it.
///
/// `rating` is the field that earns this its own type rather than reusing
/// `AdminFeedbackRevision`: a change of stars is the most consequential edit a
/// review can take and the one a text diff cannot show at all.
struct AdminReviewRevision: Codable, Identifiable, Equatable {
let id: Int
/// `review` or `response`.
let target: String
let title: String?
let body: String
/// The stars as they stood before. Null on a response, which has none.
let rating: Int?
let editorRole: String
let createdAt: Date?
let diff: [AdminDiffLine]
let titleChanged: Bool
let ratingChanged: Bool
var byTeam: Bool { editorRole == "team" }
var isResponse: Bool { target == "response" }
}
/// `/api/reviews/<id>` — the review, its history, the app it is about, and who
/// wrote it, in one payload.
struct AdminReviewDetail: Decodable {
let review: AdminReview
let revisions: [AdminReviewRevision]?
let summary: Summary?
let author: AdminFeedbackAuthor?
/// How the app stands overall, so a decision about one review is made next
/// to the number it will move.
struct Summary: Decodable {
let mode: String
let count: Int
let average: Double?
let histogram: [String: Int]?
let up: Int?
let down: Int?
let ratio: Double?
var isThumb: Bool { mode == "works" }
func bucket(_ star: Int) -> Int { histogram?[String(star)] ?? 0 }
}
}
enum AdminRequest {
struct Enabled: Encodable {
let enabled: Bool
}
/// A partial write to `/api/access`. Every field is optional and only the
/// ones that are set are sent, so changing the device limit cannot write
/// back a stale copy of the note somebody else just edited.
struct AccessSettings: Encodable {
var enabled: Bool?
var maxDevices: Int?
var inviteDays: Int?
var note: String?
}
struct AccessInviteNew: Encodable {
let label: String
}
struct AccessInviteChange: Encodable {
let id: Int
var label: String?
var revoked: Bool?
}
struct Identified: Encodable {
let id: Int
}
struct Search: Encodable {
let query: String
}
struct RetryAll: Encodable {
let all: Bool
}
struct QueueBatch: Encodable {
let action: String
/// Omitted when nil — the handler treats `status` and `ids` as
/// alternatives, and sending both would widen a targeted delete.
var ids: [Int]?
var status: String?
}
struct Corruption: Encodable {
let id: Int
let isCorrupted: Bool
}
/// `/api/library/apply` — deletions and feed renames, in one call.
struct LibraryApply: Encodable {
struct Deletion: Encodable {
let id: Int
}
/// A rename targets a *variant group*, not a single build: every version
/// of that app (or of that tweak's variant, on `ftstore` scope) takes the
/// new name in the feed.
struct Rename: Encodable {
let groupKey: String
let bundleId: String
let matchedTweak: String?
/// `global` or `ftstore` — the item's own `renameScope`.
let scope: String
let appName: String
}
let deletions: [Deletion]
let renames: [Rename]
static func delete(_ id: Int) -> LibraryApply {
LibraryApply(deletions: [Deletion(id: id)], renames: [])
}
static func rename(_ item: AdminLibraryItem, to appName: String) -> LibraryApply {
LibraryApply(
deletions: [],
renames: [
Rename(
groupKey: item.groupKey,
bundleId: item.bundleId,
matchedTweak: item.matchedTweak,
scope: item.renameScope,
appName: appName
)
]
)
}
}
/// `/api/actions/upload-ipa` (POST) — finalize a chunked transfer.
struct UploadFinalize: Encodable {
let uploadId: String
let fileName: String
var channelId: String?
var notes: String?
}
/// `/api/auth/telegram` — `connect` · `code` · `password` · `disconnect` ·
/// `reset`.
struct TelegramAction: Encodable {
let action: String
var code: String?
var password: String?
}
/// PUT `/api/settings`, replacing the whole `known_tweaks` array.
struct KnownTweaks: Encodable {
let tweaks: [AdminTweakConfig]
enum CodingKeys: String, CodingKey {
case tweaks = "known_tweaks"
}
}
/// DELETE `/api/settings/tweak-icon`.
struct TweakIconRemoval: Encodable {
let iconUrl: String
}
struct ChannelCreate: Encodable {
let channelId: String
/// `full` or `on_demand`, decided when the channel is added: it is the
/// difference between taking everything the channel holds and taking
/// nothing until a search asks for a name.
let scanMode: String
}
struct ChannelUpdate: Encodable {
let channelId: String
let isActive: Bool
}
/// Separate from `ChannelUpdate` for the same reason `ChannelTopics` is: the
/// handler applies whichever keys are present, so one struct per control
/// keeps a switch from carrying a second one it did not mean to set.
struct ChannelScanModeUpdate: Encodable {
let channelId: String
let scanMode: String
}
/// POST `/api/actions/import-tweak` — take one tweak out of a Telegram
/// channel without the channel becoming a scraper source.
struct TweakImport: Encodable {
let channel: String
let name: String
/// Comma separated; the server splits and dedupes.
var aliases: String?
/// Ingest this tweak from that channel and nowhere else.
let lock: Bool
}
/// Separate from `ChannelUpdate` on purpose: the handler applies whichever
/// keys are present, so folding both into one optional-laden struct would
/// make it easy to send `isActive: nil` and mean it.
struct ChannelTopics: Encodable {
let channelId: String
let forumTopics: [AdminForumTopic]
}
struct ChannelRefreshTopics: Encodable {
let channelId: String
}
struct SourceUpdate: Encodable {
let id: Int
let isActive: Bool
}
/// PUT `/api/settings` — replace the whole blocklist.
///
/// The whole list rather than one entry, because that is the shape the
/// setting has: it is a single JSON value, and a partial write would have
/// to read-modify-write on the server anyway.
struct Blocklist: Encodable {
let blocked_tweaks: [AdminBlockedTweak]
}
/// PUT `/api/github/sources` — change a source's display name.
///
/// Its own payload rather than an optional field on `SourceUpdate`, because
/// the server reads "absent" and "null" differently: sending `isActive` with
/// a rename would flip the switch as a side effect of typing a name.
///
/// An empty name clears the override, and the server falls back to
/// `owner/repo` — the same thing the dashboard does with a cleared field.
struct SourceRename: Encodable {
let id: Int
let displayName: String
}
/// POST `/api/github/sources` — add a repo to scan for releases.
///
/// One field, because the server's `parseGithubRepoInput` takes either
/// `owner/repo` or a full github.com URL in `url` and works out the rest.
/// Sending it whatever was typed means FTStore Admin never has to parse a repo
/// reference, and never disagrees with the dashboard about what one looks
/// like.
///
/// No `token`, and that is the point: a private source needs a PAT, this app
/// deliberately holds no credential, and the dashboard is where that belongs.
struct SourceCreate: Encodable {
let url: String
}
struct UserUpdate: Encodable {
let id: Int
let approved: Bool
}
/// POST `/api/actions/purge-boards` — empty one board or both. Admin-only
/// server-side, and there is no undo.
struct BoardPurge: Encodable {
let feedback: Bool
let tweakRequests: Bool
}
/// PUT `/api/settings` — the board lock and what it says while it is on.
/// Both optional, so the switch and the message can be saved apart.
struct BoardLock: Encodable {
var boards_locked: Bool?
var boards_locked_note: String?
}
/// PUT `/api/feedback/authors` — mute an install, let it post again, or
/// rename it.
///
/// Every field but the id is optional and independent, and the route reads
/// "absent" and "null" differently on purpose: leaving `displayName` out
/// asks nothing about the name, sending it empty takes the name away. So a
/// mute cannot quietly make somebody anonymous.
struct FeedbackAuthorUpdate: Encodable {
let installId: String
var blocked: Bool?
var reason: String?
var displayName: String?
/// Sign every device out. Deliberately separate from `blocked`: muting
/// stops an account posting and leaves whoever is holding its session
/// signed in, which is the half of a stolen account a mute does not solve.
var signOutEverywhere: Bool?
}
/// DELETE `/api/tweak-requests` — destroy a source's requests and every
/// reply under them. Admin-only server-side.
/// DELETE `/api/tweak-requests` — remove a whole wish.
///
/// By row ids rather than by source. The route still accepts a `sourceId`,
/// which is what an older build of this app sends, but a wish filed without
/// one cannot be named that way and every such wish would share the same
/// empty name.
struct TweakRequestDelete: Encodable {
let ids: [Int]
}
/// PUT `/api/tweak-requests/<id>/board` — hold a wish at the top, or let it
/// go. Its own body rather than a field on the vote, because a pin is a
/// decision about the board's shape and not an opinion about the wish.
struct TweakRequestPin: Encodable {
let pinned: Bool
}
/// PUT `/api/tweak-requests` — decide every open request for one source at
/// once. A decision is approve or reject and nothing else; `reason` is
/// optional in both directions and is shown verbatim to everyone who asked.
struct TweakRequestDecision: Encodable {
let ids: [Int]
let status: String
var reason: String?
}
/// PUT `/api/feedback/<id>` — move a ticket.
///
/// Every field is optional and independent, so a screen sends only what its
/// control changed. `note` is written into the thread as the body of the same
/// entry the status change creates, which is what stops a status moving
/// without a word to the person who reported it.
struct FeedbackUpdate: Encodable {
var status: String?
var kind: String?
var hidden: Bool?
var reason: String?
var note: String?
/// Hold the report at the top of the board, or let it go.
var pinned: Bool?
/// Rewriting the reporter's words. `editBody` rather than `body`, because
/// `body` on this endpoint has always meant the note on a status change
/// and one field that means two things is one nobody can read.
var title: String?
var editBody: String?
}
/// PUT `/api/feedback/<id>/vote` — where FTStore stands. `commentId` picks a
/// reply; leaving it out votes on the report itself.
struct FeedbackVote: Encodable {
let value: Int
var commentId: Int?
}
/// POST `/api/feedback/<id>` — reply. It goes out as "FTStore".
struct FeedbackReply: Encodable {
let body: String
}
/// POST `/api/tweak-requests/<id>/board` — answer a wish as FTStore.
struct TweakRequestReply: Encodable {
let body: String
}
/// PUT `/api/tweak-requests/<id>/board`.
///
/// One body for two jobs, because the route reads it as one: naming a reply
/// moderates that reply, and naming none votes on the wish itself. Keeping
/// them in one type is what makes the two impossible to send at once.
struct TweakRequestBoardUpdate: Encodable {
var value: Int?
var commentId: Int?
var hidden: Bool?
var body: String?
}
/// PUT `/api/tweak-requests/<id>/board` — FTStore's vote on one reply. A
/// `value` is what makes it a vote; the `commentId` beside it says which
/// reply. Its own type so a vote and a moderation cannot be sent as one.
struct TweakRequestReplyVote: Encodable {
let value: Int
let commentId: Int
}
/// PUT `/api/feedback/comments/<id>` — hide, restore or rewrite one entry.
struct FeedbackCommentModeration: Encodable {
var hidden: Bool?
var reason: String?
/// Rewriting somebody else's reply. Sent on its own, so an edit does not
/// quietly un-hide the entry it is editing.
var body: String?
}
/// PUT `/api/feedback/attachments/<id>` — hide or restore one screenshot.
///
/// Hiding does not free the space it takes: the file stays on the disk and
/// keeps counting against `max_storage_gb`. Freeing it is the delete, which
/// is admin-only and has no undo.
struct FeedbackAttachmentModeration: Encodable {
let hidden: Bool
var hiddenReason: String?
}
/// PUT `/api/reviews/<id>` — every moderation lever on a review.
///
/// Every field is optional and independent, so a takedown cannot write back
/// a stale copy of the answer somebody else just wrote.
///
/// `editBody` rather than `body`, mirroring `FeedbackUpdate`: `body` on this
/// endpoint would be ambiguous next to `response`, and one field that means
/// two things is one nobody can read.
struct ReviewUpdate: Encodable {
var hidden: Bool?
var reason: String?
var title: String?
var editBody: String?
/// Changing the stars. The one edit that moves the app's average, which
/// is why the console asks before sending it.
var rating: Int?
/// FTStore's public answer. Writing it again rewords it.
var response: String?
/// Take the answer down. A separate flag rather than an empty
/// `response`, because "" is what an empty text field sends and
/// withdrawing a published answer should be something somebody meant.
var clearResponse: Bool?
}
/// PUT `/api/github/sources` — say which app a dylib-only repo's tweak is
/// for, or clear it. Empty is the clear, which is how a repository that
/// started shipping IPAs stops being treated as one that ships tweaks.
struct GithubSourceDylib: Encodable {
let id: Int
let dylibBundleId: String
/// Sent alongside, because clearing the bundle id clears this too and
/// sending them one at a time would make the order matter.
let dylibBaseIpaUrl: String
}
/// PUT `/api/users/password` — change the signed-in operator's password.
///
/// No current password: the session is what proves this is your account, and
/// it is the same thing every other operator route already trusts. See
/// `AdminPasswordView` for why that is a decision rather than an oversight.
struct OperatorPassword: Encodable {
let password: String
}
/// PUT `/api/reviews/broken` — confirm a build broken, or clear it.
///
/// One body for both, because an operator looking at a flagged listing is
/// choosing between them rather than navigating somewhere else. `version` is
/// what makes a confirmation mean anything: it is the build actually looked
/// at, and everything newer is published as possibly broken until cleared.
struct BrokenBuildDecision: Encodable {
let app: String
var version: String?
var note: String?
/// Take the warning off, and the one on every newer version with it.
var clear: Bool?
}
/// PUT `/api/feedback/authors` — mute or unmute an install. What it already
/// posted stays up; taking that down is a separate, per-row decision.
struct FeedbackAuthorModeration: Encodable {
let installId: String
let blocked: Bool
var reason: String?
}
struct ResetStorage: Encodable {
let clearTweakIcons: Bool
}
}
// MARK: - Settings
/// `/api/settings` returns a flat, dynamically-keyed map, so it is decoded as
/// one. Secrets (`github_token`, `ftstore_app_github_token`) arrive masked and
/// are displayed as-is — this app never handles the real values.
struct AdminSettingsResponse: Decodable {
let data: [String: AnyCodable]
}
/// One rule on the blocklist: a tweak name this server will not take.
///
/// `sourceId` nil means everywhere. Identified by scope and folded name rather
/// than by position, so removing one row cannot delete a different one after a
/// reload has reordered the list.
struct AdminBlockedTweak: Codable, Identifiable, Equatable {
let name: String
let sourceId: String?
var note: String?
var id: String { "\(sourceId ?? "*")::\(name.lowercased())" }
}
extension Dictionary where Key == String, Value == AnyCodable {
func string(_ key: String) -> String? {
guard let value = self[key]?.value else { return nil }
if let string = value as? String { return string }
if let int = value as? Int { return String(int) }
if let double = value as? Double { return String(double) }
if let bool = value as? Bool { return bool ? "true" : "false" }
return nil
}
/// The blocklist, decoded out of the settings blob.
///
/// `/api/settings` answers one flat map of `AnyCodable`, so a structured
/// value has to be re-encoded and read back rather than cast. Anything that
/// does not survive the round trip is dropped: a rule this app cannot read
/// is one it must not offer to rewrite, because saving the list back would
/// delete it.
var blockedTweaks: [AdminBlockedTweak] {
guard let raw = self["blocked_tweaks"]?.value else { return [] }
guard
let data = try? JSONSerialization.data(withJSONObject: raw),
let decoded = try? JSONDecoder().decode([AdminBlockedTweak].self, from: data)
else {
return []
}
return decoded
}
func int(_ key: String) -> Int? {
guard let value = self[key]?.value else { return nil }
if let int = value as? Int { return int }
if let double = value as? Double { return Int(double) }
if let string = value as? String { return Int(string) }
return nil
}
func bool(_ key: String) -> Bool? {
guard let value = self[key]?.value else { return nil }
if let bool = value as? Bool { return bool }
if let string = value as? String { return string == "true" }
return nil
}
}