// // 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() @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() } } }