Files
FTStore/admin/FTStoreAdmin/App/AdminBlocklistView.swift
Felitendo 0a56b98a99 feat: a blocklist for tweak names, global or per source
A store that scrapes channels takes whatever the channels publish, and some of
it is not wanted. Deleting it afterwards works once; the next sweep brings it
back. This is the list that makes a no stick.

One list rather than two. A rule is a name plus a scope, and "global" is just
the unscoped case — two lists would mean two places to look, two places to
edit, and a rule for what happens when a name is in both.

Enforced twice, on purpose. Before the download it can only see the file name,
which is the cheap check and the one that saves the bandwidth; after extraction
it sees the app's real name and the tweaks it injects, which is the one that
catches a build named nothing like its contents. The same matcher answers both,
so a rule cannot catch a build on one path and miss it on the other.

The post-extraction check ignores the flag that lets a hand-upload bypass the
source rules. That flag is about which channel owns a tweak, and an operator
uploading by hand has already decided to take it. A refusal by name is a
different decision and is not theirs to skip by choosing another door.

Matching folds case and punctuation and looks anywhere in the name, which is
broad, so every rule carries the reason it was added — months later that is the
only way to tell a deliberate rule from an over-eager one.

An unreadable list is never silently an empty one: a blocklist that fails to
load is a blocklist that permits everything it was added to refuse. It is in
the settings backup with its own shape check for the same reason.

Both operator front ends, eleven tests, and the strings in both catalogue
locales.
2026-08-15 12:57:41 +02:00

190 lines
5.4 KiB
Swift

//
// AdminBlocklistView.swift
// FTStore Admin
//
// Tweak names this server will not take. Mirrors the panel on /admin/settings.
//
// A rule is a name plus a scope, and an empty scope means everywhere — which
// is the common case, so it is what happens when the field is left alone
// rather than something to switch on.
//
// The note is not decoration. Matching folds case and punctuation and looks
// anywhere in the name, which is broad on purpose; months later the only way
// to tell a deliberate rule from an over-eager one is to have written down
// why.
//
// Saved on its own, immediately, like everything else in this app — there is
// no Save button here either.
//
import SwiftUI
struct AdminBlocklistView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _settings = AdminLoader<AdminSettingsResponse>()
@State private var _entries: [AdminBlockedTweak] = []
@State private var _busy = false
@State private var _banner: String?
@State private var _adding = false
@State private var _newName = ""
@State private var _newSource = ""
@State private var _newNote = ""
var body: some View {
List {
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
Section {
if _settings.isLoading && !_settings.hasLoaded {
AdminLoadingRow()
} else if let error = _settings.errorMessage, !_settings.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _entries.isEmpty {
AdminEmptyRow(
icon: "hand.raised",
title: "Nothing is blocked",
detail: "Everything a source publishes is considered."
)
} else {
ForEach(_entries) { entry in
_row(entry)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
_remove(entry)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
} header: {
Text("Blocked")
} footer: {
Text("Builds whose name matches are never taken. Checked on the file name before downloading and on the app's real name after, so a build named nothing like its contents is still caught. Case and punctuation are ignored and the match is anywhere in the name.")
}
}
.navigationTitle("Blocked Tweaks")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
.task { await _reload() }
.disabled(_busy)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if _busy {
ProgressView()
} else {
Button {
_newName = ""
_newSource = ""
_newNote = ""
_adding = true
} label: {
Image(systemName: "plus")
}
}
}
}
.alert("Block a name", isPresented: $_adding) {
TextField("Tweak or app name", text: $_newName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Source, or empty for everywhere", text: $_newSource)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Why (optional)", text: $_newNote)
Button("Block") { _add() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Leave the source empty to block the name everywhere. Write @channel or github:owner/repo to block it from one source only.")
}
}
@ViewBuilder
private func _row(_ entry: AdminBlockedTweak) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(entry.name)
.font(.subheadline.weight(.medium))
.lineLimit(1)
// The scope, always stated. A rule whose reach is not on screen
// is one somebody will misread as global.
Label {
Text(entry.sourceId ?? .localized("everywhere"))
} icon: {
Image(systemName: entry.sourceId == nil ? "globe" : "antenna.radiowaves.left.and.right")
}
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color(uiColor: .tertiarySystemFill), in: Capsule())
.foregroundStyle(.secondary)
}
if let note = entry.note, !note.isEmpty {
Text(note)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
// MARK: Actions
private func _reload() async {
await _settings.load {
try await session.get("/api/settings", as: AdminSettingsResponse.self)
}
if let list = _settings.value?.data.blockedTweaks {
_entries = list
}
}
private func _add() {
let name = _newName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
let entry = AdminBlockedTweak(
name: name,
sourceId: _newSource.trimmingCharacters(in: .whitespaces).nilIfEmpty,
note: _newNote.trimmingCharacters(in: .whitespaces).nilIfEmpty
)
_save(_entries + [entry], .localized("\"%@\" is blocked.", name))
}
private func _remove(_ entry: AdminBlockedTweak) {
_save(_entries.filter { $0.id != entry.id }, .localized("Rule removed."))
}
private func _save(_ next: [AdminBlockedTweak], _ fallback: String) {
_busy = true
Task { @MainActor in
do {
let ack = try await session.act(
.put,
"/api/settings",
body: AdminRequest.Blocklist(blocked_tweaks: next)
)
_banner = ack.message ?? fallback
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busy = false
await _reload()
}
}
}
private extension String {
/// An empty scope means "everywhere", and the server reads that as null.
var nilIfEmpty: String? { isEmpty ? nil : self }
}