import SwiftUI struct ContentView: View { @ObservedObject var model: AppModel /// Injected rather than owned: window management belongs to the delegate. var onOpenSettings: () -> Void = {} /// Zoom multiplier. Every size below is expressed in terms of it, which is /// what makes ⌘+ / ⌘− an actual re-layout instead of a stretched bitmap. private var s: CGFloat { model.zoomScale } private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { .system(size: size * s, weight: weight) } var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() if !missingRequirements.isEmpty { requirementsSection Divider() } vpnSection Divider() sharesSection Divider() footer } // Width is a floor, not a fixture: larger zoom steps need more room, // and the window is resizable. .frame(minWidth: 430 * s) } // MARK: - Header private var header: some View { HStack(spacing: 10 * s) { Image(systemName: model.state.symbolName) .font(.system(size: 22 * s)) .foregroundStyle(tint) .frame(width: 28 * s) VStack(alignment: .leading, spacing: 2 * s) { Text(model.state.title) .font(font(13, .semibold)) Text(model.host) .font(font(11)) .foregroundStyle(.secondary) } Spacer() if model.busy { ProgressView().controlSize(.small) } } .padding(12 * s) } private var tint: Color { switch model.state { case .ok: return .green case .nasDown: return .orange case .vpnDown: return .red case .unknown: return .secondary } } // MARK: - Requirements /// Only unmet requirements are listed. A permanent green checklist of things /// that are fine is noise; a missing dependency is not. private var missingRequirements: [VPNService.Requirement] { model.requirements.filter { !$0.satisfied } } private var requirementsSection: some View { VStack(alignment: .leading, spacing: 6 * s) { Text("Не хватает для запуска VPN") .font(font(12, .medium)) .foregroundStyle(.orange) ForEach(missingRequirements) { requirement in HStack(alignment: .firstTextBaseline, spacing: 8 * s) { VStack(alignment: .leading, spacing: 1 * s) { Text(requirement.title).font(font(12)) Text(requirement.detail) .font(font(10)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer() if let fix = requirement.fix { Button("Установить") { model.runFixInTerminal(fix) } .buttonStyle(ScaledButtonStyle(scale: s)) } } } } .padding(.horizontal, 12 * s) .padding(.vertical, 10 * s) } // MARK: - VPN private var vpnSection: some View { VStack(alignment: .leading, spacing: 6 * s) { HStack(spacing: 8 * s) { Text("VPN") .font(font(12, .medium)) .frame(width: 40 * s, alignment: .leading) Button("Подключить") { model.connectVPN() } .disabled(model.busy || model.state.isTunnelUp) Button("Отключить") { model.disconnectVPN() } .disabled(model.busy || !model.state.isTunnelUp) Button("Маршрут") { model.rerouteVPN() } .disabled(model.busy) Button("Лог") { model.openVPNLog() } Spacer() } .buttonStyle(ScaledButtonStyle(scale: s)) if let message = model.vpnMessage { Text(message) .font(font(10)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } .padding(.horizontal, 12 * s) .padding(.vertical, 10 * s) } // MARK: - Shares private var sharesSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { Text("Шары") .font(font(12, .medium)) Spacer() Text("смонтировано \(model.mounted.count) из \(model.shares.count)") .font(font(11)) .foregroundStyle(.secondary) } .padding(.horizontal, 12 * s) .padding(.top, 10 * s) .padding(.bottom, 6 * s) // The list is whatever the server reports, so it can be long — // scroll rather than growing the window past the screen. MeasuredScrollView(minHeight: 48 * s, maxHeight: 420 * s) { VStack(alignment: .leading, spacing: 0) { ForEach(model.shares, id: \.self) { share in shareRow(share) if share != model.shares.last { Divider().padding(.leading, 12 * s) } } } } } .padding(.bottom, 8 * s) } private func shareRow(_ share: String) -> some View { let isMounted = model.isMounted(share) return HStack(spacing: 8 * s) { Circle() .fill(isMounted ? Color.green : Color.secondary.opacity(0.35)) .frame(width: 8 * s, height: 8 * s) VStack(alignment: .leading, spacing: 1 * s) { Text(share) .font(font(13)) Text(model.mountPoint(share) ?? "не подключена") .font(font(10)) .foregroundStyle(.secondary) } Spacer() if isMounted { Button("Открыть") { model.openInFinder(share) } .buttonStyle(ScaledButtonStyle(scale: s, borderless: true)) } Button(isMounted ? "Отключить" : "Подключить") { model.toggle(share) } .buttonStyle(ScaledButtonStyle(scale: s)) .disabled(model.busy || (!isMounted && model.state != .ok)) } .padding(.horizontal, 12 * s) .padding(.vertical, 6 * s) } // MARK: - Footer private var footer: some View { VStack(alignment: .leading, spacing: 8 * s) { if let error = model.lastError { Text(error) .font(font(11)) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8 * s) { Button("Подключить все") { model.mountMissing(allowUI: true) } .disabled(model.busy || model.state != .ok) .fixedSize() Button("Отключить все") { model.unmount(model.shares) } .disabled(model.busy || model.mounted.isEmpty) .fixedSize() Spacer() Button("Настройки") { onOpenSettings() } .fixedSize() Button("Обновить") { model.refreshAll() } .fixedSize() } .buttonStyle(ScaledButtonStyle(scale: s)) } .padding(12 * s) } }