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.
263 lines
7.1 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|