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

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

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

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

673 lines
22 KiB
Swift

//
// AdminSourcesView.swift
// FTStore Admin
//
// Where builds come from: the Telegram channels the worker scans and the
// GitHub Releases sources. Both can be added, enabled/disabled and removed from
// here; channel discovery (topic refresh) is handed to the worker, same as the
// web page does it.
//
// Adding a GitHub source takes only a repo reference, and the input goes to the
// server unparsed — `parseGithubRepoInput` accepts `owner/repo` or a github.com
// URL, and a second parser on this side would only be a second thing to
// disagree with it. Private sources are not offered here: they need a PAT, and
// see the note below about this app never holding one.
//
// A Telegram channel also carries a scan mode, and it is the more consequential
// of its two controls. `full` is a scrape: every sweep queues every IPA in the
// channel. `on_demand` is never swept at all, so the channel supplies only the
// tweaks a targeted import asked for by name. The mode is picked when the
// channel is added and can be changed from the row afterwards.
//
// A forum channel expands into its topics, each with its own switch, because
// scanning a forum wholesale means importing every off-topic thread in it. The
// web dashboard has had that control since forums were supported; this screen
// is the other half of the same product, so it has it too. One difference is
// deliberate: the dashboard collects topic edits and writes them with its Save
// button, while a tap here is written immediately. There is no Save button on
// this screen to collect them into.
//
// Note that no credential of a private GitHub source is ever sent to this app —
// the server masks `token` before serialising, so FTStore Admin can show that a
// source is private without being able to read its PAT.
//
import SwiftUI
struct AdminSourcesView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _channels = AdminLoader<AdminEnvelope<[AdminChannel]>>()
@StateObject private var _sources = AdminLoader<AdminEnvelope<[AdminGithubSource]>>()
/// Only for the topic-refresh button: the refresh is a worker command, so
/// offering it while the worker is down would queue work nothing picks up.
@StateObject private var _telegram = AdminLoader<AdminTelegramStatus>()
@State private var _busyIds: Set<String> = []
@State private var _banner: String?
@State private var _addingChannel = false
@State private var _newChannel = ""
@State private var _addingSource = false
@State private var _newSource = ""
/// The source being renamed, if any, and the name being typed for it.
@State private var _renaming: AdminGithubSource?
/// The source whose "this repo ships a dylib" setting is being edited.
@State private var _editingDylib: AdminGithubSource?
@State private var _dylibBundleId = ""
@State private var _dylibBaseIpaUrl = ""
@State private var _newName = ""
@State private var _expandedChannels: Set<String> = []
var body: some View {
List {
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
_channelSection
_githubSection
}
.navigationTitle("Channels & Sources")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
.task { await _reload() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
// A menu rather than one button, because this screen has two
// kinds of source on it and the plus used to add only one of
// them: a GitHub repo could be switched off here but not put
// here, which made the screen look like it did less than the
// dashboard rather than differently.
Menu {
Button {
_newChannel = ""
_addingChannel = true
} label: {
Label("Telegram Channel", systemImage: "paperplane")
}
Button {
_newSource = ""
_addingSource = true
} label: {
Label("GitHub Source", systemImage: "shippingbox")
}
} label: {
Image(systemName: "plus")
}
}
}
.alert("Rename Source", isPresented: Binding(
get: { _renaming != nil },
set: { if !$0 { _renaming = nil } }
)) {
TextField("Display name", text: $_newName)
Button("Save") { if let source = _renaming { _renameSource(source) } }
Button("Cancel", role: .cancel) { _renaming = nil }
} message: {
Text("What this source is called in the feed and in every list here. Leave it empty to go back to owner/repo.")
}
.alert("Ships a dylib, not an IPA", isPresented: Binding(
get: { _editingDylib != nil },
set: { if !$0 { _editingDylib = nil } }
)) {
TextField("Bundle id, e.g. com.example.app", text: $_dylibBundleId)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
// The second half of the same decision, so it lives in the same
// alert: naming the app says which build to use, and this says where
// to get one when the Library has none that is current.
TextField("Base IPA address, * for the version", text: $_dylibBaseIpaUrl)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
Button("Save") { if let source = _editingDylib { _setDylibBundleId(source) } }
Button("Cancel", role: .cancel) { _editingDylib = nil }
} message: {
Text("Some repositories publish only the tweak, because the author has nothing to attach it to. Name the app it goes into and a release with no IPA is built from a clean build of that app. The Library is used first. The address is where one is fetched when the Library has no build of the version the App Store is shipping: put a * where the version goes and it is filled in. Leave both empty if this repository publishes finished IPAs.")
}
.alert("Add GitHub Source", isPresented: $_addingSource) {
TextField("owner/repo", text: $_newSource)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Add") { _addSource() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Either owner/repo or the repository's github.com address. Public repositories only: a private one needs a token, and this app never holds one. Add those from the dashboard.")
}
.alert("Add Telegram Channel", isPresented: $_addingChannel) {
TextField("@channel or chat id", text: $_newChannel)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
// Two buttons rather than one plus a picker: an alert cannot hold a
// picker, and the choice is the whole decision being made here.
Button("Full Scan") { _addChannel(mode: .full) }
Button("On Demand") { _addChannel(mode: .onDemand) }
Button("Cancel", role: .cancel) {}
} message: {
Text("A full scan takes every IPA the channel holds. On demand takes none of them: the channel is skipped by every scan and is only read when a tweak import asks for a name. The worker refreshes the forum topics either way.")
}
}
// MARK: Telegram
@ViewBuilder
private var _channelSection: some View {
Section {
if _channels.isLoading {
AdminLoadingRow()
} else if let error = _channels.errorMessage, !_channels.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if let channels = _channels.value?.data, channels.isEmpty {
AdminEmptyRow(icon: "paperplane", title: "No channels configured", assetIcon: "Telegram")
} else if let channels = _channels.value?.data {
ForEach(channels) { channel in
_channelRow(channel)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
_removeChannel(channel)
} label: {
Label("Remove", systemImage: "trash")
}
}
if channel.isForum {
_forumBar(channel)
if _expandedChannels.contains(channel.channelId) {
ForEach(channel.forumTopics ?? []) { topic in
_topicRow(channel, topic)
}
}
}
}
}
} header: {
Text("Telegram Channels")
} footer: {
Text("Disabled channels stay configured but are skipped by every scan. An on demand channel is skipped too, until a tweak import asks it for a name. In a forum, only the topics switched on below are scanned.")
}
}
private func _channelRow(_ channel: AdminChannel) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(channel.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
if channel.isOnDemand {
Text("On demand")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(AdminTheme.brand.opacity(0.15), in: Capsule())
.foregroundStyle(AdminTheme.brand)
}
}
Text(String.localized(
"%@ IPAs · %@ messages",
AdminFormat.count(channel.ipaCount),
AdminFormat.count(channel.totalMessages)
))
.font(.caption)
.foregroundStyle(.secondary)
Text(String.localized("Scanned %@", AdminFormat.relative(channel.lastScannedAt)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer(minLength: 0)
if _busyIds.contains(channel.channelId) {
ProgressView()
} else {
// The mode sits in a menu rather than beside the switch: a row
// with two controls of equal weight reads as two switches, and
// these two say very different things.
Menu {
Picker("Scan mode", selection: Binding(
get: { channel.mode },
set: { _setChannelScanMode(channel, to: $0) }
)) {
ForEach(AdminChannelScanMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
} label: {
Image(systemName: channel.isOnDemand ? "scope" : "arrow.clockwise")
.font(.footnote)
.foregroundStyle(.secondary)
}
.accessibilityLabel("Scan mode")
Toggle("", isOn: Binding(
get: { channel.isActive },
set: { _setChannelActive(channel, to: $0) }
))
.labelsHidden()
}
}
.padding(.vertical, 2)
}
/// The disclosure line under a forum channel: how many topics are on, and
/// the refresh that asks the worker to re-read the list from Telegram.
private func _forumBar(_ channel: AdminChannel) -> some View {
let topics = channel.forumTopics ?? []
let isExpanded = _expandedChannels.contains(channel.channelId)
let workerOnline = _telegram.value?.workerOnline ?? false
return HStack(spacing: 8) {
Button {
_toggleExpanded(channel)
} label: {
HStack(spacing: 6) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption2.weight(.semibold))
Text(
topics.isEmpty
? String.localized("Forum · no topics yet")
: String.localized(
"Forum · %lld topics (%lld on)",
topics.count,
channel.enabledTopicCount
)
)
.font(.caption)
}
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(topics.isEmpty)
Spacer(minLength: 0)
if _busyIds.contains("topics:\(channel.channelId)") {
ProgressView()
} else {
Button {
_refreshTopics(channel)
} label: {
Image(systemName: "arrow.clockwise")
.font(.caption)
}
.buttonStyle(.plain)
.foregroundStyle(workerOnline ? AdminTheme.brand : Color.secondary)
.disabled(!workerOnline)
.accessibilityLabel("Refresh topics from Telegram")
}
}
.padding(.leading, 8)
}
private func _topicRow(_ channel: AdminChannel, _ topic: AdminForumTopic) -> some View {
Button {
_setTopicEnabled(channel, topic, to: !topic.enabled)
} label: {
HStack(spacing: 10) {
Image(systemName: topic.enabled ? "checkmark.circle.fill" : "circle")
.foregroundStyle(topic.enabled ? AdminTheme.brand : Color.secondary)
Text(topic.name)
.font(.subheadline)
.foregroundStyle(topic.enabled ? Color.primary : Color.secondary)
.strikethrough(!topic.enabled, color: .secondary)
.lineLimit(1)
Spacer(minLength: 0)
if _busyIds.contains(_topicKey(channel, topic)) {
ProgressView()
}
}
.padding(.leading, 16)
}
.buttonStyle(.plain)
.disabled(_busyIds.contains(_topicKey(channel, topic)))
}
// MARK: GitHub
@ViewBuilder
private var _githubSection: some View {
Section {
if let sources = _sources.value?.data, sources.isEmpty {
AdminEmptyRow(icon: "shippingbox", title: "No GitHub sources")
} else if let sources = _sources.value?.data {
ForEach(sources) { source in
_githubRow(source)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
_removeSource(source)
} label: {
Label("Remove", systemImage: "trash")
}
// The name shown in the feed and in every list. The
// field starts on the override rather than on the
// slug, so clearing it is how you go back to
// `owner/repo` — same as the dashboard.
Button {
_newName = source.displayName ?? ""
_renaming = source
} label: {
Label("Rename", systemImage: "pencil")
}
.tint(.indigo)
}
}
} else if _sources.isLoading {
AdminLoadingRow()
}
} header: {
Text("GitHub Release Sources")
} footer: {
Text("Private sources authenticate with a token held on the server; it is never sent to this app.")
}
}
/// What this source's base build situation is, or nil for a source that
/// takes its base from the Library alone.
///
/// The version is the one a build actually went into, which is worth a line
/// of its own precisely because it can lag: an address that has not mirrored
/// today's App Store release yet shows yesterday's version here, and that is
/// the difference between "waiting" and "broken".
private func _baseLine(_ source: AdminGithubSource) -> String? {
guard let url = source.dylibBaseIpaUrl, !url.isEmpty else { return nil }
guard let version = source.dylibBaseVersion, !version.isEmpty else {
return String.localized("Base fetched when needed")
}
return String.localized("Base fetched when needed · built against %@", version)
}
private func _githubRow(_ source: AdminGithubSource) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(source.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
if source.isPrivate {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Text(source.slug)
.font(.caption)
.foregroundStyle(.secondary)
Text(String.localized(
"%@ IPAs · scanned %@",
AdminFormat.count(source.ipaCount),
AdminFormat.relative(source.lastScannedAt)
))
.font(.caption2)
.foregroundStyle(.tertiary)
// Only for the handful of repositories that publish the tweak on
// its own. Saying which app it is for is what turns a release
// with no IPA in it from nothing into a build.
if let bundleId = source.dylibBundleId, !bundleId.isEmpty {
Label(bundleId, systemImage: "arrow.triangle.merge")
.font(.caption2)
.foregroundStyle(AdminTheme.brand)
.lineLimit(1)
.truncationMode(.middle)
if let baseLine = _baseLine(source) {
Text(baseLine)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
if _busyIds.contains("github:\(source.id)") {
ProgressView()
} else {
Toggle("", isOn: Binding(
get: { source.isActive },
set: { _setSourceActive(source, to: $0) }
))
.labelsHidden()
}
}
.padding(.vertical, 2)
.swipeActions(edge: .leading) {
Button {
_editingDylib = source
_dylibBundleId = source.dylibBundleId ?? ""
_dylibBaseIpaUrl = source.dylibBaseIpaUrl ?? ""
} label: {
Label("Ships a dylib", systemImage: "arrow.triangle.merge")
}
.tint(AdminTheme.brand)
}
}
// MARK: Actions
private func _reload() async {
async let channels: Void = _channels.load {
try await session.get("/api/channels", as: AdminEnvelope<[AdminChannel]>.self)
}
async let sources: Void = _sources.load {
try await session.get("/api/github/sources", as: AdminEnvelope<[AdminGithubSource]>.self)
}
async let telegram: Void = _telegram.load {
try await session.get("/api/auth/telegram", as: AdminTelegramStatus.self)
}
_ = await (channels, sources, telegram)
}
private func _toggleExpanded(_ channel: AdminChannel) {
if _expandedChannels.contains(channel.channelId) {
_expandedChannels.remove(channel.channelId)
} else {
_expandedChannels.insert(channel.channelId)
}
}
private func _topicKey(_ channel: AdminChannel, _ topic: AdminForumTopic) -> String {
"topic:\(channel.channelId):\(topic.id)"
}
/// `PUT /api/channels` replaces `forumTopics` wholesale, so the whole list
/// goes back with one entry flipped rather than a patch for that entry.
private func _setTopicEnabled(_ channel: AdminChannel, _ topic: AdminForumTopic, to enabled: Bool) {
let updated = (channel.forumTopics ?? []).map {
$0.id == topic.id ? $0.setting(enabled: enabled) : $0
}
_run(_topicKey(channel, topic)) {
_ = try await session.act(
.put,
"/api/channels",
body: AdminRequest.ChannelTopics(
channelId: channel.channelId,
forumTopics: updated
)
)
return enabled
? .localized("%@ will be scanned.", topic.name)
: .localized("%@ will be skipped.", topic.name)
}
}
/// Queued on the worker rather than done here, so the answer is "asked for"
/// and the new list turns up on a later reload.
private func _refreshTopics(_ channel: AdminChannel) {
_run("topics:\(channel.channelId)") {
_ = try await session.act(
.post,
"/api/channels/refresh-topics",
body: AdminRequest.ChannelRefreshTopics(channelId: channel.channelId)
)
return .localized("Topic refresh queued for %@.", channel.title)
}
}
private func _addChannel(mode: AdminChannelScanMode) {
let id = _newChannel.trimmingCharacters(in: .whitespaces)
guard !id.isEmpty else { return }
_run(id) {
_ = try await session.act(
.post,
"/api/channels",
body: AdminRequest.ChannelCreate(channelId: id, scanMode: mode.rawValue)
)
return mode == .onDemand
? .localized("Added %@ on demand.", id)
: .localized("Added %@.", id)
}
}
/// Switching to on demand does not undo anything already imported from the
/// channel. It stops the next sweep from walking it, and nothing more.
private func _setChannelScanMode(_ channel: AdminChannel, to mode: AdminChannelScanMode) {
guard mode != channel.mode else { return }
_run(channel.channelId) {
_ = try await session.act(
.put,
"/api/channels",
body: AdminRequest.ChannelScanModeUpdate(
channelId: channel.channelId,
scanMode: mode.rawValue
)
)
return mode == .onDemand
? .localized("%@ is only read by a targeted search now.", channel.title)
: .localized("%@ is scanned in full now.", channel.title)
}
}
private func _setChannelActive(_ channel: AdminChannel, to active: Bool) {
_run(channel.channelId) {
_ = try await session.act(
.put,
"/api/channels",
body: AdminRequest.ChannelUpdate(channelId: channel.channelId, isActive: active)
)
return active
? .localized("%@ enabled.", channel.title)
: .localized("%@ disabled.", channel.title)
}
}
private func _removeChannel(_ channel: AdminChannel) {
_run(channel.channelId) {
let ack = try await session.act(
.delete,
"/api/channels",
query: ["channelId": channel.channelId]
)
return ack.summary
}
}
/// Whatever was typed goes straight to the server, which owns the one
/// definition of what a repo reference looks like. A second parser here
/// would only be a second thing to disagree with it.
private func _addSource() {
let input = _newSource.trimmingCharacters(in: .whitespaces)
guard !input.isEmpty else { return }
_run("github:new") {
_ = try await session.act(
.post,
"/api/github/sources",
body: AdminRequest.SourceCreate(url: input)
)
return .localized("Added %@.", input)
}
}
/// The display name only. It changes nothing about what is scanned — the
/// repo is still `owner/repo` — so it is the one edit here with no
/// consequences to warn about.
/// Say which app this repository's dylib goes into, or clear it.
private func _setDylibBundleId(_ source: AdminGithubSource) {
let bundleId = _dylibBundleId.trimmingCharacters(in: .whitespacesAndNewlines)
let baseUrl = _dylibBaseIpaUrl.trimmingCharacters(in: .whitespacesAndNewlines)
_editingDylib = nil
_run("github:\(source.id)") {
_ = try await session.act(
.put,
"/api/github/sources",
body: AdminRequest.GithubSourceDylib(
id: source.id,
dylibBundleId: bundleId,
dylibBaseIpaUrl: baseUrl
)
)
if bundleId.isEmpty {
return .localized("%@ is back to publishing IPAs.", source.slug)
}
return baseUrl.isEmpty
? .localized("Releases without an IPA are built into %@.", bundleId)
: .localized("Releases without an IPA are built into %@, fetching a base when needed.", bundleId)
}
}
private func _renameSource(_ source: AdminGithubSource) {
let name = _newName.trimmingCharacters(in: .whitespaces)
_renaming = nil
_run("github:\(source.id)") {
_ = try await session.act(
.put,
"/api/github/sources",
body: AdminRequest.SourceRename(id: source.id, displayName: name)
)
return name.isEmpty
? .localized("%@ goes by its repo again.", source.slug)
: .localized("Renamed to %@.", name)
}
}
/// Removing a source stops it being scanned and forgets it. What it already
/// imported stays in the library, exactly as removing a channel behaves.
private func _removeSource(_ source: AdminGithubSource) {
_run("github:\(source.id)") {
let ack = try await session.act(
.delete,
"/api/github/sources",
query: ["id": String(source.id)]
)
return ack.summary
}
}
private func _setSourceActive(_ source: AdminGithubSource, to active: Bool) {
_run("github:\(source.id)") {
_ = try await session.act(
.put,
"/api/github/sources",
body: AdminRequest.SourceUpdate(id: source.id, isActive: active)
)
return active
? .localized("%@ enabled.", source.slug)
: .localized("%@ disabled.", source.slug)
}
}
private func _run(_ key: String, _ operation: @escaping @MainActor () async throws -> String) {
_busyIds.insert(key)
Task { @MainActor in
do {
_banner = try await operation()
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busyIds.remove(key)
await _reload()
}
}
}