// // 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/` — 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//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//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_` 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/` — 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/`. /// /// 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/.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/` 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/` — 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/` — 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//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/` — 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//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/` — reply. It goes out as "FTStore". struct FeedbackReply: Encodable { let body: String } /// POST `/api/tweak-requests//board` — answer a wish as FTStore. struct TweakRequestReply: Encodable { let body: String } /// PUT `/api/tweak-requests//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//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/` — 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/` — 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/` — 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 } }