Files
Felitendo 3bc21834a8 fix: stop buzzing when a download is stopped, and let games be sorted as games
Two unrelated reports, both about something the store got wrong.

Stopping no longer gives haptic feedback, anywhere: not the square in the
progress ring, not a row's cross in the downloads list, and not Stop All. It was
wrong on the ring in particular, which is the control people hit by accident
while aiming at the cell around it, so the buzz made a misfire feel like
something had happened. Stopping is also not an outcome worth confirming. The
ring emptying and the row leaving are the feedback. Everything else keeps what
it had: pressing install, signing finishing, and the install itself landing or
failing.

Games could not be sorted as games. Which half of the store an app lands in
comes from its App Store genre, looked up by bundle identifier, and there are
two ordinary ways for that to come back with nothing: an app that was never on
the App Store has no genre at all, and a modified build can ship under a
rewritten identifier that the lookup then misses. Both fall through to Apps,
which is how a pool game or a chess game ends up filed next to the utilities.

The override for exactly this already existed in the feed, was already read when
generating it, and was already covered by tests. It had no way in. Neither
console could set it, so the escape hatch had no door. Both consoles now carry
it, on the tweak an operator already curates: three choices, defaulting to
letting the genre decide, because that is right nearly always and the two
explicit answers are for when it cannot answer at all. The default stays absent
in the setting, so a catalogue nobody has classified is unchanged byte for byte.

The bulk fix had no door either. Filling in the genre for cached lookups that
predate it being stored is what corrects a whole catalogue at once rather than
one entry at a time, and it existed only as an endpoint nothing called. It is a
maintenance action in both consoles now, safe to run repeatedly: it only touches
rows that have no genre, and leaves the rest alone.
2026-08-17 11:55:38 +02:00

1376 lines
44 KiB
Swift

//
// AdminTweaksView.swift
// FTStore Admin
//
// The `known_tweaks` setting — what makes a tweaked build group with the rest
// of its variant instead of showing up as its own app, and what the file-name
// filters keep out of the catalog entirely.
//
// The whole array is replaced on save (that is what `PUT /api/settings` does
// with a JSON setting), so the editor holds a working copy and writes it back
// in one call rather than patching entries one at a time.
//
import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
/// One entry of the "locked to" list: a Telegram channel or a GitHub release
/// source, flattened to the id the setting stores for it.
struct AdminTweakLockTarget: Identifiable, Hashable {
enum Kind {
case telegram
case github
/// Telegram gets its real logo (a bundled template asset) rather than SF
/// Symbols' generic paper plane, so the row says which service it means.
@ViewBuilder
var mark: some View {
switch self {
case .telegram: TelegramMark(size: 13)
case .github: Image(systemName: "shippingbox.fill")
}
}
}
let id: String
let label: String
/// The channel id or `owner/repo` — shown under the label, since a display
/// name alone doesn't say which repo or chat it stands for.
let detail: String
let kind: Kind
}
/// Where a known tweak's builds may come from. Three answers, one choice, which
/// is why it is an enum here and two fields on the wire: `any` and `manual` are
/// opposites that both leave `lockedChannelId` empty.
enum AdminTweakSourceRule: Equatable {
/// Ingest it wherever it turns up.
case any
/// Only this channel or repository.
case locked(String)
/// No scanned source at all. The operator uploads the IPA.
case manual
init(_ tweak: AdminTweakConfig) {
if tweak.manualOnly == true {
self = .manual
} else if let id = tweak.lockedChannelId, !id.isEmpty {
self = .locked(id)
} else {
self = .any
}
}
/// The pair of fields this rule writes back. `manualOnly` is written only
/// when true, like `recommended`: the feed generator reads an absent key as
/// false, and a run of `false`s would be noise in the setting.
var wireValues: (lockedChannelId: String?, manualOnly: Bool?) {
switch self {
case .any: return (nil, nil)
case .locked(let id): return (id, nil)
case .manual: return (nil, true)
}
}
}
/// Which half of the store a known tweak lands in.
///
/// Three answers and one optional field on the wire, because the useful default
/// is neither Apps nor Games: it is "let the App Store genre decide", which is
/// what an absent `category` means. The two explicit answers exist for the cases
/// the genre lookup cannot serve, and there are two of them. It matches on
/// bundle id, so anything that was never on the App Store has no genre at all;
/// and a tweaked build that ships under a rewritten identifier is a lookup that
/// quietly finds nothing. Both land in Apps, which is why a game can turn up in
/// the wrong tab with nothing obviously wrong anywhere.
enum AdminTweakCategory: String, CaseIterable, Identifiable, Equatable {
/// Whatever the App Store genre says. The absent field.
case automatic
case app
case game
var id: String { rawValue }
init(wire: String?) {
switch wire?.lowercased() {
case "game": self = .game
case "app": self = .app
default: self = .automatic
}
}
/// Written only for the two explicit answers, so a tweak nobody classified
/// stays byte for byte what it was in the setting.
var wire: String? {
switch self {
case .automatic: return nil
case .app: return "app"
case .game: return "game"
}
}
var title: String {
switch self {
case .automatic: return String.localized("From the App Store")
case .app: return String.localized("App")
case .game: return String.localized("Game")
}
}
/// What the list row shows, and nothing at all for the default: a badge for
/// "nobody decided" would be a badge on every row.
var badge: String? {
switch self {
case .automatic: return nil
case .app: return String.localized("App")
case .game: return String.localized("Game")
}
}
}
/// Turns the stored price tiers into the one line both the list row and the
/// store show. Only the tiers that carry a figure appear, which is the whole
/// contract of the pricing fields.
enum AdminTweakPricingText {
static func summary(_ pricing: AdminTweakPricing?) -> String? {
guard let pricing, !pricing.isEmpty else { return nil }
let tiers: [(String, Double?)] = [
("Monthly", pricing.monthly),
("Yearly", pricing.yearly),
("Lifetime", pricing.lifetime)
]
let parts = tiers.compactMap { label, amount -> String? in
guard let amount, amount > 0 else { return nil }
return "\(label) \(AdminFormat.price(amount, currency: pricing.currency))"
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}
/// The purchase link, shortened to the host, for the row that has no room for a
/// whole URL.
///
/// A scheme-less entry is read the way the server reads it (`web/src/lib/pricing.ts`):
/// an operator pasting `discord.gg/…` out of a channel means a web address, so
/// `https` is assumed rather than the line being treated as prose. Anything the
/// server would refuse to publish shows nothing here either.
enum AdminTweakPurchaseLink {
static func host(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)"
guard
let url = URL(string: candidate),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
// Nothing in front of the host, for the reason the server has it:
// `mailto:dev@example.com` parses back as a user at a domain.
url.user(percentEncoded: false) == nil,
let host = url.host(percentEncoded: false),
host.contains(".")
else {
return nil
}
return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
}
}
struct AdminTweaksView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _loader = AdminLoader<AdminKnownTweaksResponse>()
@StateObject private var _channels = AdminLoader<AdminEnvelope<[AdminChannel]>>()
@StateObject private var _sources = AdminLoader<AdminEnvelope<[AdminGithubSource]>>()
/// Tweak name to the icon its ingested builds carry. A known tweak has no
/// artwork of its own unless somebody uploaded some, and that upload is for
/// the apps the App Store lookup can't answer for, so without this the list
/// showed placeholders next to names like SoundCloud that plainly have one.
@StateObject private var _icons = AdminLoader<AdminEnvelope<[String: String]>>()
@State private var _tweaks: [AdminTweakConfig] = []
@State private var _editing: AdminTweakConfig?
@State private var _adding = false
@State private var _importing = false
@State private var _saving = false
@State private var _banner: String?
/// Whether the working copy differs from what the server last handed us.
private var _dirty: Bool { _tweaks != (_loader.value?.data.tweaks ?? []) }
/// Everything a tweak may be locked to, in the order the dashboard lists it:
/// Telegram channels first, then the GitHub release sources.
private var _lockTargets: [AdminTweakLockTarget] {
let channels = (_channels.value?.data ?? []).map {
AdminTweakLockTarget(
id: $0.channelId,
label: $0.title,
detail: $0.channelId,
kind: .telegram
)
}
let sources = (_sources.value?.data ?? []).map {
AdminTweakLockTarget(
id: $0.lockId,
label: $0.title,
detail: $0.slug,
kind: .github
)
}
return channels + sources
}
/// Why the "locked to" choices are missing, when they are missing because a
/// request failed rather than because nothing is configured.
private var _lockTargetsError: String? {
_channels.errorMessage ?? _sources.errorMessage
}
/// Held here rather than inline: a `Label` has both a `String` and a
/// `LocalizedStringKey` initialiser, and handing it `.localized(…)` directly
/// leaves the overload to be guessed.
var body: some View {
List {
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
if _loader.isLoading {
AdminLoadingRow()
} else if let error = _loader.errorMessage, !_loader.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _tweaks.isEmpty {
AdminEmptyRow(
icon: "wrench.and.screwdriver",
title: "No known tweaks",
detail: "Add one to group its builds under a single app in the feed."
)
} else {
Section {
ForEach(_tweaks) { tweak in
Button {
_editing = tweak
} label: {
_row(tweak)
}
.buttonStyle(.plain)
}
.onDelete { offsets in
_tweaks.remove(atOffsets: offsets)
}
} header: {
Text(
_tweaks.count == 1
? String.localized("1 tweak")
: String.localized("%lld tweaks", _tweaks.count)
)
} footer: {
Text("A build matches by name or alias. Locking a tweak to a Telegram channel or a GitHub repository makes the scanners ignore it everywhere else, and a tweak set to no source is left alone by all of them; the file-name filters drop non-matching uploads before they reach the queue.")
}
}
}
.navigationTitle("Known Tweaks")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
.task { await _reload() }
// Every change to the list — an edit sheet's Done, an add, a delete, an
// import — writes itself. Single-argument onChange: the two-argument
// form is iOS 17.
.onChange(of: _tweaks) { _ in _saveIfNeeded() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
_adding = true
} label: {
Label("New Tweak", systemImage: "plus")
}
Button {
_importing = true
} label: {
Label("Import from Channel", systemImage: "arrow.down.circle")
}
} label: {
Image(systemName: "plus")
}
.disabled(_saving)
}
ToolbarItem(placement: .confirmationAction) {
if _saving { ProgressView() }
}
}
.sheet(item: $_editing) { tweak in
NavigationStack {
AdminTweakEditorView(
tweak: tweak,
lockTargets: _lockTargets,
lockTargetsError: _lockTargetsError,
onSave: { updated in _replace(tweak, with: updated) }
)
}
.environmentObject(session)
}
.sheet(isPresented: $_adding) {
NavigationStack {
AdminTweakEditorView(
tweak: AdminTweakConfig(name: ""),
lockTargets: _lockTargets,
lockTargetsError: _lockTargetsError,
isNew: true,
onSave: { created in _append(created) }
)
}
.environmentObject(session)
}
.sheet(isPresented: $_importing) {
NavigationStack {
AdminTweakImportView(
channels: _channels.value?.data ?? [],
onImported: { imported, summary in
_adopt(imported)
_banner = summary
Task { await _reload() }
}
)
}
.environmentObject(session)
}
}
// MARK: Row
/// The custom icon when the operator set one, otherwise whatever the
/// tweak's builds carry. Nil leaves `AdminRemoteIcon` on its placeholder,
/// which is now only what a tweak with no builds yet gets.
private func _icon(for tweak: AdminTweakConfig) -> String? {
if let custom = tweak.iconUrl, !custom.isEmpty { return custom }
guard let map = _icons.value?.data else { return nil }
return map[tweak.name]
}
private func _row(_ tweak: AdminTweakConfig) -> some View {
HStack(spacing: 12) {
// A custom icon is an override and always wins; everything else falls
// back to what the tweak's own builds already carry. The editor
// deliberately does not do this: the icon beside its Choose buttons is
// the custom one, and filling it in would make an unset tweak look set.
AdminRemoteIcon(url: AdminAPI.mediaURL(_icon(for: tweak)), size: 34)
VStack(alignment: .leading, spacing: 3) {
Text(tweak.name)
.font(.subheadline.weight(.medium))
HStack(spacing: 6) {
if let aliases = tweak.aliases, !aliases.isEmpty {
Text(
aliases.count == 1
? String.localized("1 alias")
: String.localized("%lld aliases", aliases.count)
)
.font(.caption2)
.foregroundStyle(.tertiary)
}
if tweak.recommended == true {
Text("Recommended")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(AdminTheme.brand.opacity(0.15), in: Capsule())
.foregroundStyle(AdminTheme.brand)
}
if tweak.tested == true {
Text("Tested")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.15), in: Capsule())
.foregroundStyle(Color.green)
}
// Only when the operator overrode it. An absent category is the
// App Store genre deciding, and a badge for that would claim a
// decision nobody made.
if let category = AdminTweakCategory(wire: tweak.category).badge {
Label(category, systemImage: "gamecontroller.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if tweak.paid == true {
Text("Paid")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.15), in: Capsule())
.foregroundStyle(Color.red)
if let prices = AdminTweakPricingText.summary(tweak.pricing) {
Text(prices)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
if let host = AdminTweakPurchaseLink.host(tweak.purchaseUrl) {
Label(host, systemImage: "link")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
if tweak.manualOnly == true {
// Its own badge, not a lock: a lock says which source may
// write, this says none may.
Label(String.localized("By hand only"), systemImage: "hand.raised.fill")
.font(.caption2)
.foregroundStyle(.orange)
.lineLimit(1)
} else if let lock = tweak.lockedChannelId {
// Naming the source beats a bare "Locked" badge now that
// it can be either a channel or a repo.
Label(_lockLabel(lock), systemImage: "lock.fill")
.font(.caption2)
.foregroundStyle(.orange)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
}
/// The lock's display name, or the raw id when it points at a channel or
/// repo that is no longer configured.
private func _lockLabel(_ id: String) -> String {
_lockTargets.first { $0.id == id }?.label ?? id
}
// MARK: Mutations
private func _replace(_ original: AdminTweakConfig, with updated: AdminTweakConfig) {
guard let index = _tweaks.firstIndex(where: { $0.name == original.name }) else { return }
_tweaks[index] = updated
}
private func _append(_ tweak: AdminTweakConfig) {
let name = tweak.name.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
guard !_tweaks.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) else {
_banner = .localized("A tweak called %@ already exists.", name)
return
}
_tweaks.append(tweak)
}
/// Fold in an entry the *server* already wrote (a tweak import).
///
/// Unlike `_append`, an existing name is overwritten rather than refused:
/// the stored list now says this, so the working copy has to as well, or the
/// next Save would put the old entry back.
private func _adopt(_ tweak: AdminTweakConfig) {
if let index = _tweaks.firstIndex(where: {
$0.name.caseInsensitiveCompare(tweak.name) == .orderedSame
}) {
_tweaks[index] = tweak
} else {
_tweaks.append(tweak)
}
}
private func _reload() async {
async let tweaks: Void = _loader.load {
try await session.get("/api/settings", as: AdminKnownTweaksResponse.self)
}
async let icons: Void = _icons.load {
try await session.get("/api/tweak-icons", as: AdminEnvelope<[String: String]>.self)
}
async let channels: Void = _channels.load {
try await session.get("/api/channels", as: AdminEnvelope<[AdminChannel]>.self)
}
// Both source kinds, because a tweak can be locked to either one.
async let sources: Void = _sources.load {
try await session.get("/api/github/sources", as: AdminEnvelope<[AdminGithubSource]>.self)
}
_ = await (tweaks, icons, channels, sources)
// Only adopt the server's copy when there is nothing unsaved to lose —
// a background refresh must not silently discard an edit in progress.
if let fresh = _loader.value?.data.tweaks, _tweaks.isEmpty || !_dirty {
_tweaks = fresh
}
}
/// Write the list if it differs from the server's, and not while a write is
/// already in flight.
///
/// The list goes up whole, because that is the shape the setting has — one
/// `known_tweaks` array — so two overlapping PUTs would race to decide which
/// version wins. `_reload` at the end of a save re-checks, which is what
/// picks up an edit made while the previous one was still going.
private func _saveIfNeeded() {
guard _dirty, !_saving, _loader.hasLoaded else { return }
_save()
}
private func _save() {
_saving = true
Task { @MainActor in
do {
let ack = try await session.act(
.put,
"/api/settings",
body: AdminRequest.KnownTweaks(tweaks: _tweaks)
)
_banner = ack.summary
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_saving = false
await _reload()
// An edit that landed while that was in flight is still unwritten,
// and `_reload` deliberately does not adopt the server's copy over
// it. Nothing else would ever come back for it.
_saveIfNeeded()
}
}
}
// MARK: - Editor
struct AdminTweakEditorView: View {
let tweak: AdminTweakConfig
let lockTargets: [AdminTweakLockTarget]
/// Why `lockTargets` is empty, when the reason is a failed load rather than
/// an operator who has configured nothing.
var lockTargetsError: String? = nil
var isNew = false
let onSave: (AdminTweakConfig) -> Void
@EnvironmentObject private var session: AdminSession
@Environment(\.dismiss) private var dismiss
@State private var _name = ""
@State private var _aliases = ""
@State private var _mustContain = ""
@State private var _mustNotContain = ""
@State private var _sourceRule: AdminTweakSourceRule = .any
@State private var _recommended = false
@State private var _tested = false
@State private var _category: AdminTweakCategory = .automatic
@State private var _iconUrl: String?
@State private var _paid = false
@State private var _currency = AdminTweakEditorView.defaultCurrency
@State private var _monthly = ""
@State private var _yearly = ""
@State private var _lifetime = ""
@State private var _purchaseUrl = ""
/// Whether `_hydrate()` has already run. See there for why the fields
/// themselves can't answer this.
@State private var _hydrated = false
@State private var _pickingFile = false
@State private var _pickingPhoto = false
@State private var _pickingCamera = false
@State private var _photo: PhotosPickerItem?
@State private var _iconBusy = false
@State private var _error: String?
/// Amounts are stored without a currency until the operator picks one, and
/// everything downstream reads an absent code as this.
static let defaultCurrency = "USD"
/// What the picker offers.
static let currencies = ["USD", "EUR", "GBP"]
private static let _maxIconBytes = 1024 * 1024
/// Icons are re-encoded to this square before upload. Enough for the largest
/// artwork the feed renders, and small enough to stay inside the 1 MB cap
/// that a straight-from-the-camera photo would blow through many times over.
private static let _iconSide = 512
var body: some View {
Form {
Section {
TextField("Name", text: $_name)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Name")
} footer: {
Text("Matched against the app name and the file name. It is also the identity of the tweak, so renaming one starts a fresh group.")
}
Section {
TextField("Comma separated", text: $_aliases, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.lineLimit(1...3)
} header: {
Text("Aliases")
} footer: {
Text("Other spellings that mean the same tweak.")
}
Section {
Toggle("Recommended", isOn: $_recommended)
// Beside it rather than instead of it: an operator can have run a
// build without rating it, and can rate one without running it.
Toggle("Tested", isOn: $_tested)
// Three choices and not a toggle, because the useful default is
// neither Apps nor Games: it is "whatever the App Store says".
Picker("Category", selection: $_category) {
ForEach(AdminTweakCategory.allCases) { option in
Text(option.title).tag(option)
}
}
// A pushed list rather than a Picker: the choices come from two
// groups (channels and repos) that need their own headers, which
// a Form's menu-style picker can't show.
NavigationLink {
AdminTweakLockPickerView(
targets: lockTargets,
loadError: lockTargetsError,
selection: $_sourceRule
)
} label: {
LabeledContent("Locked to source", value: _lockLabel)
}
} header: {
Text("Grouping")
} footer: {
Text("A locked tweak is only ingested from that channel or repository. Builds of it found elsewhere stay in the Library but are left out of the feed. With no source, no scan ingests it at all and your own uploads are the only way it changes.")
}
Section {
Toggle("Costs money", isOn: $_paid)
if _paid {
Picker("Currency", selection: $_currency) {
ForEach(_currencyOptions, id: \.self) { code in
Text(code).tag(code)
}
}
_priceField("Per month", text: $_monthly)
_priceField("Per year", text: $_yearly)
_priceField("Lifetime", text: $_lifetime)
// Full width rather than a trailing field like the tiers: an
// address is long, and a price is four characters.
TextField("Where to buy", text: $_purchaseUrl)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
} header: {
Text("Pricing")
} footer: {
Text(_paid
? "Leave a tier empty to hide it. The store tags the tweak red and lists only the prices filled in here. The link is where it sends people to buy: a website, a Discord invite or a shop page."
: "Turn this on for a tweak that has to be bought. The store tags it red so nobody downloads it expecting a free app.")
}
Section {
TextField("Must contain (comma separated)", text: $_mustContain, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.lineLimit(1...3)
TextField("Must not contain (comma separated)", text: $_mustNotContain, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.lineLimit(1...3)
} header: {
Text("File Name Filters")
} footer: {
Text("Case-insensitive. A file matching this tweak has to contain every term in the first list and none from the second.")
}
Section {
HStack(spacing: 14) {
AdminRemoteIcon(url: AdminAPI.mediaURL(_iconUrl), size: 48)
VStack(alignment: .leading, spacing: 4) {
Menu {
Button {
_pickingFile = true
} label: {
Label("Choose from Files", systemImage: "folder")
}
Button {
_pickingPhoto = true
} label: {
Label("Choose from Photos", systemImage: "photo")
}
if AdminCameraPickerView.isAvailable {
Button {
_pickingCamera = true
} label: {
Label("Take Photo", systemImage: "camera")
}
}
} label: {
Text(_iconUrl == nil ? "Choose Icon" : "Replace Icon")
}
.disabled(_iconBusy || _trimmedName.isEmpty)
if _iconUrl != nil {
Button("Remove Icon", role: .destructive) { _removeIcon() }
.disabled(_iconBusy)
}
}
Spacer()
if _iconBusy { ProgressView() }
}
} header: {
Text("Icon")
} footer: {
Text("PNG, JPEG or WebP, up to 1 MB. Anything larger, and every photo, is cropped square and scaled down first. Stored on the server and used in the feed instead of the App Store artwork.")
}
if let error = _error {
Section {
Text(error).font(.footnote).foregroundStyle(.red)
}
}
}
.navigationTitle(isNew ? "New Tweak" : tweak.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { _commit() }
.disabled(_trimmedName.isEmpty)
}
}
.fileImporter(
isPresented: $_pickingFile,
allowedContentTypes: [.png, .jpeg, .webP],
allowsMultipleSelection: false
) { result in
_uploadIcon(result)
}
.photosPicker(isPresented: $_pickingPhoto, selection: $_photo, matching: .images)
.onChange(of: _photo) { item in
guard let item else { return }
_photo = nil
_uploadPhoto(item)
}
.fullScreenCover(isPresented: $_pickingCamera) {
AdminCameraPickerView { image in
_pickingCamera = false
guard let image else { return }
_uploadImage(image)
}
.ignoresSafeArea()
}
.onAppear(perform: _hydrate)
}
private var _trimmedName: String {
_name.trimmingCharacters(in: .whitespaces)
}
private var _lockLabel: String {
switch _sourceRule {
case .any:
return .localized("Any source")
case .manual:
return .localized("No source")
case .locked(let id):
return lockTargets.first { $0.id == id }?.label ?? id
}
}
/// The picker's choices, plus whatever this tweak was already priced in. A
/// currency set through the dashboard (or an older build of this app) has to stay
/// selectable, or opening the editor would silently re-price the tweak in
/// the first entry of the list.
private var _currencyOptions: [String] {
Self.currencies.contains(_currency)
? Self.currencies
: Self.currencies + [_currency]
}
/// One price tier. `.decimalPad` rather than `.numberPad` because every tier
/// is a money amount, and a keyboard without a separator would force whole
/// units only.
@ViewBuilder
private func _priceField(_ label: String, text: Binding<String>) -> some View {
LabeledContent(label) {
TextField("Not sold", text: text)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
}
// MARK: State
/// Hydration runs once and is latched on its own flag.
///
/// `.onAppear` fires again every time the lock picker is popped, so this has
/// to refuse the second call. It used to do that by testing whether the name
/// field was still empty, which is true for the whole of a **new** tweak until
/// the operator types one: picking a source before naming it re-ran hydration
/// on the way back and reset the choice to the empty tweak's `nil`. The
/// picker worked, and the screen threw the answer away.
private func _hydrate() {
guard !_hydrated else { return }
_hydrated = true
_name = tweak.name
_aliases = (tweak.aliases ?? []).joined(separator: ", ")
_mustContain = (tweak.fileNameMustContain ?? []).joined(separator: ", ")
_mustNotContain = (tweak.fileNameMustNotContain ?? []).joined(separator: ", ")
_sourceRule = AdminTweakSourceRule(tweak)
_recommended = tweak.recommended ?? false
_tested = tweak.tested ?? false
_category = AdminTweakCategory(wire: tweak.category)
_iconUrl = tweak.iconUrl
_paid = tweak.paid ?? false
_currency = tweak.pricing?.currency?.uppercased() ?? Self.defaultCurrency
_monthly = Self._amountText(tweak.pricing?.monthly)
_yearly = Self._amountText(tweak.pricing?.yearly)
_lifetime = Self._amountText(tweak.pricing?.lifetime)
_purchaseUrl = tweak.purchaseUrl ?? ""
}
/// A stored amount back in the text field. Whole prices lose the ".0" so a
/// tweak that costs 5 doesn't come back as "5.0" every time it's opened.
private static func _amountText(_ amount: Double?) -> String {
guard let amount, amount > 0 else { return "" }
return amount == amount.rounded()
? String(Int(amount))
: String(format: "%.2f", amount)
}
/// A typed price to a number, accepting both decimal separators: the field
/// uses the system keypad, which offers a comma in a German locale and a dot
/// in an English one.
private static func _amount(_ text: String) -> Double? {
let cleaned = text
.trimmingCharacters(in: .whitespaces)
.replacingOccurrences(of: ",", with: ".")
guard let value = Double(cleaned), value > 0 else { return nil }
return (value * 100).rounded() / 100
}
private func _commit() {
var updated = tweak
updated.name = _trimmedName
updated.aliases = Self._list(_aliases)
updated.fileNameMustContain = Self._list(_mustContain)
updated.fileNameMustNotContain = Self._list(_mustNotContain)
let source = _sourceRule.wireValues
updated.lockedChannelId = source.lockedChannelId
updated.manualOnly = source.manualOnly
// Written only when true: the setting is shared with the feed generator,
// which treats the key's absence as false anyway.
updated.recommended = _recommended ? true : nil
updated.tested = _tested ? true : nil
updated.category = _category.wire
updated.iconUrl = _iconUrl
updated.paid = _paid ? true : nil
// Prices and the purchase link only travel with the paid flag. Clearing
// the toggle drops both rather than leaving figures and a shop link
// behind that nothing would ever show, and an all-empty tier set is
// dropped too so the feed stays clean.
if _paid {
var pricing = AdminTweakPricing(
currency: _currency == Self.defaultCurrency ? nil : _currency,
monthly: Self._amount(_monthly),
yearly: Self._amount(_yearly),
lifetime: Self._amount(_lifetime)
)
if pricing.isEmpty { pricing.currency = nil }
updated.pricing = pricing.isEmpty ? nil : pricing
let link = _purchaseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
updated.purchaseUrl = link.isEmpty ? nil : link
} else {
updated.pricing = nil
updated.purchaseUrl = nil
}
onSave(updated)
dismiss()
}
/// Comma-separated text to a list, or nil when there is nothing to store.
private static func _list(_ text: String) -> [String]? {
let items = text
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return items.isEmpty ? nil : items
}
// MARK: Icon
/// An icon on its way to the server: the bytes, and what to call them in the
/// multipart part.
private struct _IconPayload {
let data: Data
let mimeType: String
let fileName: String
}
private func _uploadIcon(_ result: Result<[URL], Error>) {
_error = nil
guard case .success(let urls) = result, let url = urls.first else {
if case .failure(let error) = result { _error = error.localizedDescription }
return
}
let mime: String
switch url.pathExtension.lowercased() {
case "png": mime = "image/png"
case "jpg", "jpeg": mime = "image/jpeg"
case "webp": mime = "image/webp"
default:
_error = .localized("Use a PNG, JPEG or WebP file.")
return
}
_iconBusy = true
Task { @MainActor in
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
// A file that already fits goes up untouched — re-encoding it
// would only lose quality (and WebP support).
if data.count <= Self._maxIconBytes {
await _send(_IconPayload(data: data, mimeType: mime, fileName: url.lastPathComponent))
} else if let image = UIImage(data: data) {
await _shrinkAndSend(image)
} else {
_error = .localized(
"That icon is %@ and could not be read as an image.",
AdminFormat.bytes(Int64(data.count))
)
}
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_iconBusy = false
}
}
private func _uploadPhoto(_ item: PhotosPickerItem) {
_error = nil
_iconBusy = true
Task { @MainActor in
do {
guard
let data = try await item.loadTransferable(type: Data.self),
let image = UIImage(data: data)
else {
_error = .localized("That photo could not be read.")
_iconBusy = false
return
}
await _shrinkAndSend(image)
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_iconBusy = false
}
}
private func _uploadImage(_ image: UIImage) {
_error = nil
_iconBusy = true
Task { @MainActor in
await _shrinkAndSend(image)
_iconBusy = false
}
}
/// Crop to a square, scale to icon size and re-encode small enough for the
/// server's 1 MB cap, then upload.
@MainActor
private func _shrinkAndSend(_ image: UIImage) async {
guard let payload = Self._encode(image) else {
_error = .localized("That image could not be converted into an icon.")
return
}
await _send(payload)
}
private static func _encode(_ image: UIImage) -> _IconPayload? {
guard let square = image.resizeToSquare() else { return nil }
// Never upscale — re-rendering a 128 px icon at 512 only makes it
// blurrier and heavier.
let pixels = Int((square.size.width * square.scale).rounded())
let side = max(min(_iconSide, pixels), 1)
let icon = square.resize(side, side)
// PNG first — artwork with flat colour stays crisp and small. A photo
// compresses far better as JPEG, so fall back to that (and keep
// lowering the quality) rather than rejecting the pick.
if let png = icon.pngData(), png.count <= _maxIconBytes {
return _IconPayload(data: png, mimeType: "image/png", fileName: "icon.png")
}
for quality in [0.9, 0.75, 0.6, 0.4] as [CGFloat] {
if
let jpeg = icon.jpegData(compressionQuality: quality),
jpeg.count <= _maxIconBytes
{
return _IconPayload(data: jpeg, mimeType: "image/jpeg", fileName: "icon.jpg")
}
}
return nil
}
@MainActor
private func _send(_ payload: _IconPayload) async {
do {
let response = try await session.upload(
"/api/settings/tweak-icon",
fields: ["tweakName": _trimmedName],
fileField: "file",
fileName: payload.fileName,
mimeType: payload.mimeType,
fileData: payload.data,
as: AdminTweakIcon.self
)
guard let uploaded = response.data?.url else {
_error = response.error ?? "The server didn't return an icon URL."
return
}
_iconUrl = uploaded
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
private func _removeIcon() {
guard let iconUrl = _iconUrl else { return }
_iconBusy = true
Task { @MainActor in
do {
_ = try await session.act(
.delete,
"/api/settings/tweak-icon",
body: AdminRequest.TweakIconRemoval(iconUrl: iconUrl)
)
_iconUrl = nil
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_iconBusy = false
}
}
}
// MARK: - Lock picker
/// The "locked to" list, grouped the way the dashboard groups it: Telegram
/// channels and GitHub release sources, either of which a tweak can be pinned
/// to.
struct AdminTweakLockPickerView: View {
let targets: [AdminTweakLockTarget]
/// Set when the channel or source list failed to load. Without it an empty
/// list reads as "you have not configured any", which sends the operator off
/// to add a source they already have.
var loadError: String? = nil
@Binding var selection: AdminTweakSourceRule
@Environment(\.dismiss) private var dismiss
private var _channels: [AdminTweakLockTarget] { targets.filter { $0.kind == .telegram } }
private var _sources: [AdminTweakLockTarget] { targets.filter { $0.kind == .github } }
var body: some View {
List {
Section {
// `.localized(…)`, not a literal: `_row` takes `String`s (a channel
// title is server data and never translated), and SwiftUI renders a
// `String` verbatim. A literal here would have shown in English on
// the one row of this screen that isn't server data.
_row(
rule: .any,
label: .localized("Any source"),
detail: .localized("Ingest this tweak wherever it turns up.")
) { Image(systemName: "globe") }
// Deliberately next to "Any source" and not at the bottom: it is the
// other end of the same question, and an operator looking for it is
// looking here.
_row(
rule: .manual,
label: .localized("No source"),
detail: .localized("Never ingested by a scan. You upload the IPA yourself.")
) { Image(systemName: "hand.raised.fill") }
}
if !_channels.isEmpty {
Section("Telegram Channels") {
ForEach(_channels) { _row($0) }
}
}
if !_sources.isEmpty {
Section("GitHub Repositories") {
ForEach(_sources) { _row($0) }
}
}
// A lock whose channel or repo has since been removed on the server:
// keep it selectable so opening this screen can't silently drop it.
if
case .locked(let id) = selection,
!targets.contains(where: { $0.id == id })
{
Section("Current") {
_row(
rule: .locked(id),
label: id,
detail: .localized("No longer configured.")
) { Image(systemName: "questionmark.circle") }
}
}
if _channels.isEmpty && _sources.isEmpty {
if let loadError {
AdminErrorRow(message: loadError)
} else {
AdminEmptyRow(
icon: "antenna.radiowaves.left.and.right",
title: "No sources configured",
detail: "Add a Telegram channel or a GitHub release source first."
)
}
}
}
.navigationTitle("Locked Source")
.navigationBarTitleDisplayMode(.inline)
}
private func _row(_ target: AdminTweakLockTarget) -> some View {
_row(rule: .locked(target.id), label: target.label, detail: target.detail) { target.kind.mark }
}
private func _row<Mark: View>(
rule: AdminTweakSourceRule,
label: String,
detail: String,
@ViewBuilder mark: () -> Mark
) -> some View {
Button {
selection = rule
dismiss()
} label: {
HStack(spacing: 12) {
mark()
.font(.footnote)
.frame(width: 22)
.foregroundStyle(AdminTheme.brand)
VStack(alignment: .leading, spacing: 2) {
Text(label)
.foregroundStyle(.primary)
.lineLimit(1)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
if selection == rule {
Image(systemName: "checkmark")
.font(.footnote.weight(.semibold))
.foregroundStyle(AdminTheme.brand)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Import from a channel
/// Take one tweak out of a Telegram channel, without the channel becoming a
/// source that gets scraped.
///
/// Adding a channel under Channels & Sources says "take everything in here".
/// This screen is the other answer, for the far commoner case where a channel
/// carries one tweak somebody asked for and a hundred apps nobody did: the
/// server registers the channel on demand, writes the tweak into `known_tweaks`
/// and queues a search for that name in that channel alone. Nothing else in the
/// channel is ever downloaded.
///
/// The channel field takes a handle or a `t.me` link; the server normalises it
/// and refuses a private invite link, which the scanner could not read anyway.
struct AdminTweakImportView: View {
/// Offered as suggestions. Typing a channel that is not configured yet is
/// the normal case, which is why this is a text field with a list under it
/// rather than a picker.
let channels: [AdminChannel]
let onImported: (AdminTweakConfig, String) -> Void
@EnvironmentObject private var session: AdminSession
@Environment(\.dismiss) private var dismiss
@State private var _channel = ""
@State private var _name = ""
@State private var _aliases = ""
@State private var _lock = true
@State private var _busy = false
@State private var _error: String?
var body: some View {
Form {
Section {
TextField("@channel or t.me link", text: $_channel)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !_suggestions.isEmpty {
ForEach(_suggestions) { channel in
Button {
_channel = channel.channelId
} label: {
HStack(spacing: 10) {
TelegramMark(size: 13)
.foregroundStyle(AdminTheme.brand)
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
.foregroundStyle(.primary)
.lineLimit(1)
Text(channel.isOnDemand
? String.localized("%@ · on demand", channel.channelId)
: String.localized("%@ · full scan", channel.channelId))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
} header: {
Text("Channel")
} footer: {
Text("Public channels only, private invite links can't be read. A new channel is registered on demand, so no scan ever walks it. One that is already configured keeps the mode it has.")
}
Section {
TextField("Name", text: $_name)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Aliases (comma separated)", text: $_aliases, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.lineLimit(1...3)
} header: {
Text("Tweak")
} footer: {
Text("Matched against the file names in the channel. Only the builds that match are imported, up to the version limit in Server Settings. An existing tweak of this name is reused rather than replaced.")
}
Section {
Toggle("Only from this channel", isOn: $_lock)
} footer: {
Text(_lock
? "The tweak is locked to this channel, so builds of it found anywhere else stay in the Library and out of the feed."
: "The tweak keeps whatever sources it already has. This import still reads only the channel above.")
}
if let error = _error {
Section {
Text(error).font(.footnote).foregroundStyle(.red)
}
}
}
.navigationTitle("Import Tweak")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
if _busy {
ProgressView()
} else {
Button("Import") { _import() }
.disabled(_trimmedChannel.isEmpty || _trimmedName.isEmpty)
}
}
}
}
private var _trimmedChannel: String {
_channel.trimmingCharacters(in: .whitespaces)
}
private var _trimmedName: String {
_name.trimmingCharacters(in: .whitespaces)
}
/// Configured channels whose id or name contains what has been typed so far.
/// An empty field offers the first few, which is the shortcut for "the one I
/// already imported from last time"; a long list would bury the fields under
/// it, and typing a channel that is not configured yet is the normal case.
private var _suggestions: [AdminChannel] {
let query = _trimmedChannel.lowercased().replacingOccurrences(of: "@", with: "")
let matches = query.isEmpty
? channels
: channels.filter {
$0.channelId.lowercased().contains(query) || $0.title.lowercased().contains(query)
}
return Array(matches.prefix(5))
}
private func _import() {
_busy = true
_error = nil
Task { @MainActor in
do {
let ack = try await session.send(
.post,
"/api/actions/import-tweak",
body: AdminRequest.TweakImport(
channel: _trimmedChannel,
name: _trimmedName,
aliases: _aliases.trimmingCharacters(in: .whitespaces),
lock: _lock
),
as: AdminTweakImportAck.self
)
// A 4xx already threw with the server's own sentence. A 2xx
// without a tweak would mean the response shape changed, so it
// is shown rather than swallowed.
guard let tweak = ack.tweak else {
_error = ack.summary
_busy = false
return
}
onImported(tweak, ack.summary)
dismiss()
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busy = false
}
}
}