Files
FTStore/admin/FTStoreAdmin/App/AdminTelegramView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

263 lines
7.1 KiB
Swift

//
// AdminTelegramView.swift
// FTStore Admin
//
// The Telegram login the scraper runs on. The app never talks to Telegram —
// it enqueues a command for the worker, which owns the TDLib session, and
// polls the resulting state. So the code and the 2FA password are typed here
// but only ever handled by your own server.
//
import SwiftUI
struct AdminTelegramView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _status = AdminLoader<AdminTelegramStatus>()
@State private var _code = ""
@State private var _password = ""
@State private var _busy = false
@State private var _banner: String?
private var _state: AdminTelegramStatus? { _status.value }
var body: some View {
Form {
_statusSection
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
if _state?.isWaitingForCode == true {
_codeSection
}
if _state?.isWaitingForPassword == true {
_passwordSection
}
_actionSection
_diagnosticsSection
}
.navigationTitle("Telegram")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
// The worker picks a command up asynchronously, so the only way to see
// "waiting_code" appear is to keep asking. The poll stops with the view.
.task {
while !Task.isCancelled {
await _reload()
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
// MARK: Status
@ViewBuilder
private var _statusSection: some View {
Section {
if let state = _state {
HStack(spacing: 12) {
Image(systemName: _icon(state))
.font(.title3)
.foregroundStyle(_tint(state))
VStack(alignment: .leading, spacing: 3) {
Text(state.title)
.font(.subheadline.weight(.semibold))
Text(state.workerOnline ? "Worker online" : "Worker offline")
.font(.caption)
// Both branches spelled `Color.…`: a leading `.secondary`
// resolves to `HierarchicalShapeStyle` and pins the
// ternary to a type `.orange` doesn't exist in.
.foregroundStyle(state.workerOnline ? Color.secondary : Color.orange)
}
Spacer()
if state.busy || _busy { ProgressView() }
}
if let error = state.error, !error.isEmpty {
Label(error, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
if let label = state.progressLabel, !label.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
if let current = state.progressCurrent, let total = state.progressTotal, total > 0 {
ProgressView(value: Double(current), total: Double(total))
.tint(AdminTheme.brand)
}
}
}
} else if let error = _status.errorMessage {
AdminErrorRow(message: error) { Task { await _reload() } }
} else {
AdminLoadingRow()
}
} header: {
Text("Connection")
} footer: {
Text("The worker holds the TDLib session. Nothing Telegram-related is stored on this device.")
}
}
private func _icon(_ state: AdminTelegramStatus) -> String {
switch state.state {
case "ready": return "checkmark.seal.fill"
case "error": return "exclamationmark.triangle.fill"
case "waiting_code", "waiting_password": return "key.fill"
case "connecting": return "arrow.triangle.2.circlepath"
default: return "bolt.horizontal.circle"
}
}
private func _tint(_ state: AdminTelegramStatus) -> Color {
switch state.state {
case "ready": return .green
case "error": return .red
case "waiting_code", "waiting_password": return .orange
default: return .secondary
}
}
// MARK: Input
private var _codeSection: some View {
Section {
TextField("Verification code", text: $_code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
Button("Submit Code") {
_send(AdminRequest.TelegramAction(action: "code", code: _code.trimmingCharacters(in: .whitespaces)))
_code = ""
}
.disabled(_code.trimmingCharacters(in: .whitespaces).isEmpty || _busy)
} header: {
Text("Verification Code")
} footer: {
Text("Telegram sent a login code to the configured phone number, in the Telegram app itself.")
}
}
private var _passwordSection: some View {
Section {
SecureField("2FA password", text: $_password)
.textContentType(.password)
Button("Submit Password") {
_send(AdminRequest.TelegramAction(action: "password", password: _password))
_password = ""
}
.disabled(_password.isEmpty || _busy)
} header: {
Text("Two-Factor Password")
} footer: {
if let hint = _state?.passwordHint, !hint.isEmpty {
Text("Hint: \(hint)")
}
}
}
// MARK: Actions
@ViewBuilder
private var _actionSection: some View {
if let state = _state {
Section {
if !state.sessionReady {
Button {
_send(AdminRequest.TelegramAction(action: "connect"))
} label: {
Label("Connect", systemImage: "link")
}
.disabled(_busy || state.isWaitingForCode || state.isWaitingForPassword)
}
if state.sessionReady || state.state != "disconnected" {
Button {
_send(AdminRequest.TelegramAction(action: "disconnect"))
} label: {
Label("Disconnect", systemImage: "link.badge.plus")
}
.disabled(_busy)
}
AdminActionRow(
title: "Reset Session",
icon: "trash",
detail: "Deletes the stored TDLib session, so you log in again from scratch",
destructive: true,
confirmation: "Delete the worker's Telegram session?"
) {
let status = try await session.send(
.post,
"/api/auth/telegram",
body: AdminRequest.TelegramAction(action: "reset"),
as: AdminTelegramStatus.self
)
await _reload()
return .localized("Reset queued. State is now %@.", status.title.lowercased())
}
} header: {
Text("Actions")
}
}
}
@ViewBuilder
private var _diagnosticsSection: some View {
if let state = _state {
Section("Diagnostics") {
LabeledContent("State", value: state.state)
LabeledContent("Session ready", value: state.sessionReady ? "Yes" : "No")
if let command = state.currentCommandType {
LabeledContent("Running", value: command)
}
if let retries = state.retryCount, retries > 0 {
LabeledContent("Retries", value: String(retries))
}
LabeledContent("Worker heartbeat", value: AdminFormat.relative(state.lastHeartbeatAt))
LabeledContent("Last connected", value: AdminFormat.relative(state.lastConnectedAt))
LabeledContent("Last authenticated", value: AdminFormat.relative(state.lastAuthAt))
}
}
}
// MARK: Networking
private func _reload() async {
await _status.load {
try await session.get("/api/auth/telegram", as: AdminTelegramStatus.self)
}
}
private func _send(_ action: AdminRequest.TelegramAction) {
_busy = true
_banner = nil
Task { @MainActor in
do {
let status = try await session.send(
.post,
"/api/auth/telegram",
body: action,
as: AdminTelegramStatus.self
)
_banner = .localized("Queued. State is now %@.", status.title.lowercased())
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busy = false
await _reload()
}
}
}