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.
2220 lines
72 KiB
Swift
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
|
|
}
|
|
}
|