tools: Tailscale status
This commit is contained in:
@@ -146,6 +146,33 @@ public extension View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
public struct ActionIconButton: View {
|
||||||
|
let systemImage: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
public init(_ systemImage: String, action: @escaping () -> Void) {
|
||||||
|
self.systemImage = systemImage
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
#if !os(tvOS)
|
||||||
|
.frame(width: 44, height: 32)
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
#endif
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#if os(tvOS)
|
||||||
|
.actionButtonStyle()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public extension View {
|
public extension View {
|
||||||
func cardStyle() -> some View {
|
func cardStyle() -> some View {
|
||||||
modifier(CardStyleModifier())
|
modifier(CardStyleModifier())
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct TailscaleEndpointView: View {
|
||||||
|
@ObservedObject var viewModel: TailscaleStatusViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showAuthURLQRCode = false
|
||||||
|
let endpointTag: String
|
||||||
|
|
||||||
|
public init(viewModel: TailscaleStatusViewModel, endpointTag: String) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.endpointTag = endpointTag
|
||||||
|
}
|
||||||
|
|
||||||
|
private var endpoint: TailscaleEndpointData? {
|
||||||
|
viewModel.endpoint(tag: endpointTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
FormView {
|
||||||
|
if let endpoint {
|
||||||
|
Section("Status") {
|
||||||
|
FormTextItem("State", "power") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(stateColor(endpoint.backendState))
|
||||||
|
Text(endpoint.backendState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !endpoint.networkName.isEmpty {
|
||||||
|
FormTextItem("Network", "network") {
|
||||||
|
Text(endpoint.networkName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !endpoint.magicDNSSuffix.isEmpty {
|
||||||
|
FormTextItem("MagicDNS", "globe") {
|
||||||
|
Text(endpoint.magicDNSSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !endpoint.authURL.isEmpty {
|
||||||
|
if let url = URL(string: endpoint.authURL) {
|
||||||
|
#if !os(tvOS)
|
||||||
|
Link(destination: url) {
|
||||||
|
Label("Open Auth URL", systemImage: "arrow.up.forward.app")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
Button {
|
||||||
|
showAuthURLQRCode = true
|
||||||
|
} label: {
|
||||||
|
Label("Open Auth URL as QR Code", systemImage: "qrcode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.backendState == "Running", let selfPeer = endpoint.selfPeer {
|
||||||
|
Section("This Device") {
|
||||||
|
peerLink(selfPeer, isSelf: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(endpoint.userGroups) { group in
|
||||||
|
Section {
|
||||||
|
ForEach(group.peers) { peer in
|
||||||
|
peerLink(peer, isSelf: false)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(group.displayName.isEmpty ? group.loginName : group.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(endpointTag)
|
||||||
|
.sheet(isPresented: $showAuthURLQRCode) {
|
||||||
|
if let endpoint {
|
||||||
|
URLQRCodeSheet(url: endpoint.authURL, title: String(localized: "Auth URL"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: endpoint == nil) { isNil in
|
||||||
|
if isNil {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func peerLink(_ peer: TailscalePeerData, isSelf: Bool) -> some View {
|
||||||
|
FormNavigationLink {
|
||||||
|
TailscalePeerView(peer: peer, endpointTag: endpointTag, isSelf: isSelf)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(peer.online ? .green : Color(.systemGray))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(peer.hostName)
|
||||||
|
if let firstIP = peer.tailscaleIPs.first {
|
||||||
|
Text(firstIP)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stateColor(_ state: String) -> Color {
|
||||||
|
switch state {
|
||||||
|
case "Running": .green
|
||||||
|
case "NeedsLogin", "NeedsMachineAuth": .orange
|
||||||
|
case "Starting": .yellow
|
||||||
|
default: Color(.systemGray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct TailscalePeerView: View {
|
||||||
|
let peer: TailscalePeerData
|
||||||
|
let endpointTag: String
|
||||||
|
let isSelf: Bool
|
||||||
|
|
||||||
|
@State private var copiedAddress: String?
|
||||||
|
@StateObject private var pingViewModel = TailscalePingViewModel()
|
||||||
|
|
||||||
|
public init(peer: TailscalePeerData, endpointTag: String, isSelf: Bool) {
|
||||||
|
self.peer = peer
|
||||||
|
self.endpointTag = endpointTag
|
||||||
|
self.isSelf = isSelf
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
FormView {
|
||||||
|
Section("Tailscale Addresses") {
|
||||||
|
if !peer.dnsName.isEmpty {
|
||||||
|
addressRow(LibboxFormatFQDN(peer.dnsName), label: "MagicDNS")
|
||||||
|
}
|
||||||
|
ForEach(Array(peer.tailscaleIPs.enumerated()), id: \.offset) { _, ip in
|
||||||
|
if ip.contains(":") {
|
||||||
|
addressRow(ip, label: "IPv6")
|
||||||
|
} else {
|
||||||
|
addressRow(ip, label: "IPv4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSelf, peer.online, let peerIP = peer.tailscaleIPs.first {
|
||||||
|
Section {
|
||||||
|
if pingViewModel.hasResult {
|
||||||
|
connectionTypeRow
|
||||||
|
}
|
||||||
|
if pingViewModel.isRunning, pingViewModel.hasResult {
|
||||||
|
pingChartView
|
||||||
|
}
|
||||||
|
if !pingViewModel.hasResult {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Ping")
|
||||||
|
Spacer()
|
||||||
|
ActionIconButton(pingViewModel.isRunning ? "stop.fill" : "play.fill") {
|
||||||
|
if pingViewModel.isRunning {
|
||||||
|
pingViewModel.stop()
|
||||||
|
} else {
|
||||||
|
pingViewModel.start(endpointTag: endpointTag, peerIP: peerIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if peer.keyExpiry > 0 || !peer.os.isEmpty || peer.exitNode {
|
||||||
|
Section("Details") {
|
||||||
|
if peer.keyExpiry > 0 {
|
||||||
|
FormTextItem("Key Expiry", "key") {
|
||||||
|
Text(keyExpiryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !peer.os.isEmpty {
|
||||||
|
FormTextItem("OS", "desktopcomputer") {
|
||||||
|
Text(peer.os)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peer.exitNode {
|
||||||
|
FormTextItem("Exit Node", "arrow.triangle.turn.up.right.diamond") {
|
||||||
|
Text("Active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(peer.hostName)
|
||||||
|
.font(.headline)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.font(.system(size: 6))
|
||||||
|
.foregroundStyle(peer.online ? .green : Color(.systemGray))
|
||||||
|
Text(peer.online ? "Connected" : "Not Connected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if pingViewModel.isRunning {
|
||||||
|
pingViewModel.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionTypeRow: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if pingViewModel.isDirect {
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Direct connection")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("DERP-relayed connection")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(verbatim: "\(Int(pingViewModel.latencyMs)) ms")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pingChartView: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
let chartHeight: CGFloat = 160
|
||||||
|
let labelWidth: CGFloat = 80
|
||||||
|
#else
|
||||||
|
let chartHeight: CGFloat = 80
|
||||||
|
let labelWidth: CGFloat = 50
|
||||||
|
#endif
|
||||||
|
return HStack(alignment: .center) {
|
||||||
|
TrafficLineChart(
|
||||||
|
data: pingViewModel.latencyHistory,
|
||||||
|
lineColor: pingViewModel.isDirect ? .green : .blue,
|
||||||
|
chartHeight: chartHeight
|
||||||
|
)
|
||||||
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
|
let maxMs = max(Int((pingViewModel.latencyHistory.max() ?? 1) * 1.2), 1)
|
||||||
|
Text(verbatim: "\(maxMs)ms")
|
||||||
|
Spacer()
|
||||||
|
Text(verbatim: "\(maxMs * 2 / 3)ms")
|
||||||
|
Spacer()
|
||||||
|
Text(verbatim: "\(maxMs / 3)ms")
|
||||||
|
Spacer()
|
||||||
|
Text(verbatim: "0ms")
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: labelWidth)
|
||||||
|
}
|
||||||
|
.frame(height: chartHeight)
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keyExpiryText: String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(peer.keyExpiry))
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addressRow(_ address: String, label: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(address)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
#if !os(tvOS)
|
||||||
|
Button {
|
||||||
|
copyToClipboard(address)
|
||||||
|
} label: {
|
||||||
|
if copiedAddress == address {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyToClipboard(_ text: String) {
|
||||||
|
#if os(iOS)
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
let generator = UINotificationFeedbackGenerator()
|
||||||
|
generator.notificationOccurred(.success)
|
||||||
|
#elseif os(macOS)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
#endif
|
||||||
|
withAnimation {
|
||||||
|
copiedAddress = text
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
|
||||||
|
withAnimation {
|
||||||
|
if copiedAddress == text {
|
||||||
|
copiedAddress = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class TailscalePingViewModel: BaseViewModel {
|
||||||
|
@Published public var isRunning = false
|
||||||
|
@Published public var latencyMs: Double = 0
|
||||||
|
@Published public var isDirect: Bool = false
|
||||||
|
@Published public var derpRegionCode: String = ""
|
||||||
|
@Published public var endpoint: String = ""
|
||||||
|
@Published public var hasResult = false
|
||||||
|
@Published public var latencyHistory: [CGFloat] = []
|
||||||
|
|
||||||
|
private let maxHistorySize = 30
|
||||||
|
private var commandClient: LibboxCommandClient?
|
||||||
|
private var runningTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public func start(endpointTag: String, peerIP: String) {
|
||||||
|
latencyHistory = []
|
||||||
|
hasResult = false
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
let client = LibboxNewStandaloneCommandClient()!
|
||||||
|
commandClient = client
|
||||||
|
let handler = PingHandler(self)
|
||||||
|
|
||||||
|
runningTask = Task { [weak self] in
|
||||||
|
await Task.detached {
|
||||||
|
try? client.startTailscalePing(endpointTag, peerIP: peerIP, handler: handler)
|
||||||
|
}.value
|
||||||
|
self?.runningTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
runningTask?.cancel()
|
||||||
|
runningTask = nil
|
||||||
|
try? commandClient?.disconnect()
|
||||||
|
commandClient = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func appendLatency(_ ms: Double) {
|
||||||
|
latencyHistory.append(CGFloat(ms))
|
||||||
|
if latencyHistory.count > maxHistorySize {
|
||||||
|
latencyHistory.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PingHandler: NSObject, LibboxTailscalePingHandlerProtocol, @unchecked Sendable {
|
||||||
|
private weak var viewModel: TailscalePingViewModel?
|
||||||
|
|
||||||
|
init(_ viewModel: TailscalePingViewModel?) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func onPingResult(_ result: LibboxTailscalePingResult?) {
|
||||||
|
guard let result else { return }
|
||||||
|
let latencyMs = result.latencyMs
|
||||||
|
let isDirect = result.isDirect
|
||||||
|
let derpRegionCode = result.derpRegionCode
|
||||||
|
let endpoint = result.endpoint
|
||||||
|
let error = result.error
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
if !error.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.latencyMs = latencyMs
|
||||||
|
viewModel.isDirect = isDirect
|
||||||
|
viewModel.derpRegionCode = derpRegionCode
|
||||||
|
viewModel.endpoint = endpoint
|
||||||
|
viewModel.hasResult = true
|
||||||
|
viewModel.appendLatency(latencyMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_: String?) {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.isRunning = false
|
||||||
|
viewModel.commandClient = nil
|
||||||
|
viewModel.runningTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct TailscalePeerData: Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let hostName: String
|
||||||
|
public let dnsName: String
|
||||||
|
public let os: String
|
||||||
|
public let tailscaleIPs: [String]
|
||||||
|
public let online: Bool
|
||||||
|
public let exitNode: Bool
|
||||||
|
public let exitNodeOption: Bool
|
||||||
|
public let active: Bool
|
||||||
|
public let rxBytes: Int64
|
||||||
|
public let txBytes: Int64
|
||||||
|
public let keyExpiry: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TailscaleUserGroupData: Identifiable {
|
||||||
|
public let id: Int64
|
||||||
|
public let loginName: String
|
||||||
|
public let displayName: String
|
||||||
|
public let profilePicURL: String
|
||||||
|
public let peers: [TailscalePeerData]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TailscaleEndpointData: Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let endpointTag: String
|
||||||
|
public let backendState: String
|
||||||
|
public let authURL: String
|
||||||
|
public let networkName: String
|
||||||
|
public let magicDNSSuffix: String
|
||||||
|
public let selfPeer: TailscalePeerData?
|
||||||
|
public let userGroups: [TailscaleUserGroupData]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class TailscaleStatusViewModel: BaseViewModel {
|
||||||
|
@Published public var endpoints: [TailscaleEndpointData] = []
|
||||||
|
@Published public var isSubscribed = false
|
||||||
|
|
||||||
|
private var runningTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public func subscribe() {
|
||||||
|
guard !isSubscribed else { return }
|
||||||
|
isSubscribed = true
|
||||||
|
|
||||||
|
let handler = StatusHandler(self)
|
||||||
|
runningTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.detached {
|
||||||
|
try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler)
|
||||||
|
}.value
|
||||||
|
} catch {
|
||||||
|
guard let self else { return }
|
||||||
|
self.isSubscribed = false
|
||||||
|
self.endpoints = []
|
||||||
|
}
|
||||||
|
self?.runningTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
runningTask?.cancel()
|
||||||
|
runningTask = nil
|
||||||
|
isSubscribed = false
|
||||||
|
endpoints = []
|
||||||
|
}
|
||||||
|
|
||||||
|
public func endpoint(tag: String) -> TailscaleEndpointData? {
|
||||||
|
endpoints.first { $0.endpointTag == tag }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable {
|
||||||
|
private weak var viewModel: TailscaleStatusViewModel?
|
||||||
|
|
||||||
|
init(_ viewModel: TailscaleStatusViewModel?) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) {
|
||||||
|
guard let status else { return }
|
||||||
|
let endpoints = Self.convertUpdate(status)
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isSubscribed else { return }
|
||||||
|
viewModel.endpoints = endpoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ message: String?) {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isSubscribed else { return }
|
||||||
|
viewModel.isSubscribed = false
|
||||||
|
viewModel.endpoints = []
|
||||||
|
if let message {
|
||||||
|
viewModel.alert = AlertState(errorMessage: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] {
|
||||||
|
var endpoints: [TailscaleEndpointData] = []
|
||||||
|
if let iterator = status.endpoints() {
|
||||||
|
while iterator.hasNext() {
|
||||||
|
if let endpoint = iterator.next() {
|
||||||
|
endpoints.append(convertEndpoint(endpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData {
|
||||||
|
var userGroups: [TailscaleUserGroupData] = []
|
||||||
|
if let groupIterator = endpoint.userGroups() {
|
||||||
|
while groupIterator.hasNext() {
|
||||||
|
if let group = groupIterator.next() {
|
||||||
|
userGroups.append(convertUserGroup(group))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TailscaleEndpointData(
|
||||||
|
id: endpoint.endpointTag,
|
||||||
|
endpointTag: endpoint.endpointTag,
|
||||||
|
backendState: endpoint.backendState,
|
||||||
|
authURL: endpoint.authURL,
|
||||||
|
networkName: endpoint.networkName,
|
||||||
|
magicDNSSuffix: endpoint.magicDNSSuffix,
|
||||||
|
selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil,
|
||||||
|
userGroups: userGroups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData {
|
||||||
|
var peers: [TailscalePeerData] = []
|
||||||
|
if let peerIterator = group.peers() {
|
||||||
|
while peerIterator.hasNext() {
|
||||||
|
if let peer = peerIterator.next() {
|
||||||
|
peers.append(convertPeer(peer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TailscaleUserGroupData(
|
||||||
|
id: group.userID,
|
||||||
|
loginName: group.loginName,
|
||||||
|
displayName: group.displayName,
|
||||||
|
profilePicURL: group.profilePicURL,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData {
|
||||||
|
var ips: [String] = []
|
||||||
|
if let ipIterator = peer.tailscaleIPs() {
|
||||||
|
while ipIterator.hasNext() {
|
||||||
|
ips.append(ipIterator.next())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TailscalePeerData(
|
||||||
|
id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName,
|
||||||
|
hostName: peer.hostName,
|
||||||
|
dnsName: peer.dnsName,
|
||||||
|
os: peer.os,
|
||||||
|
tailscaleIPs: ips,
|
||||||
|
online: peer.online,
|
||||||
|
exitNode: peer.exitNode,
|
||||||
|
exitNodeOption: peer.exitNodeOption,
|
||||||
|
active: peer.active,
|
||||||
|
rxBytes: peer.rxBytes,
|
||||||
|
txBytes: peer.txBytes,
|
||||||
|
keyExpiry: peer.keyExpiry
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import Library
|
import Library
|
||||||
|
import NetworkExtension
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct ToolsView: View {
|
public struct ToolsView: View {
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@StateObject private var viewModel = SettingViewModel()
|
@StateObject private var viewModel = SettingViewModel()
|
||||||
|
@StateObject private var tailscaleViewModel = TailscaleStatusViewModel()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showCrashReportList = false
|
@State private var showCrashReportList = false
|
||||||
@State private var showOOMReportList = false
|
@State private var showOOMReportList = false
|
||||||
@@ -14,6 +16,22 @@ public struct ToolsView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
FormView {
|
FormView {
|
||||||
|
if !tailscaleViewModel.endpoints.isEmpty {
|
||||||
|
Section("Endpoints") {
|
||||||
|
ForEach(tailscaleViewModel.endpoints) { endpoint in
|
||||||
|
FormNavigationLink {
|
||||||
|
TailscaleEndpointView(viewModel: tailscaleViewModel, endpointTag: endpoint.endpointTag)
|
||||||
|
} label: {
|
||||||
|
if tailscaleViewModel.endpoints.count == 1 {
|
||||||
|
Label("Tailscale", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
} else {
|
||||||
|
Label("Tailscale: \(endpoint.endpointTag)", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Network") {
|
Section("Network") {
|
||||||
FormNavigationLink {
|
FormNavigationLink {
|
||||||
NetworkQualityView()
|
NetworkQualityView()
|
||||||
@@ -106,5 +124,42 @@ public struct ToolsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(TailscaleStatusObserver(profile: environments.extensionProfile, viewModel: tailscaleViewModel))
|
||||||
|
.alert($tailscaleViewModel.alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TailscaleStatusObserver: ViewModifier {
|
||||||
|
var profile: ExtensionProfile?
|
||||||
|
var viewModel: TailscaleStatusViewModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if let profile {
|
||||||
|
content
|
||||||
|
.modifier(ActiveObserver(profile: profile, viewModel: viewModel))
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveObserver: ViewModifier {
|
||||||
|
@ObservedObject var profile: ExtensionProfile
|
||||||
|
var viewModel: TailscaleStatusViewModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onChangeCompat(of: profile.status) { status in
|
||||||
|
if status.isConnectedStrict {
|
||||||
|
viewModel.subscribe()
|
||||||
|
} else {
|
||||||
|
viewModel.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if profile.status.isConnectedStrict {
|
||||||
|
viewModel.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+483
-1
@@ -597,6 +597,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Auth URL" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Authorize" : {
|
"Authorize" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1635,6 +1638,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Connected" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "متصل"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Подключено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "已连接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "已連線"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Connecting..." : {
|
"Connecting..." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -2283,6 +2314,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DERP-relayed connection" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "اتصال رلهشده DERP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "DERP-ретранслируемое соединение"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "DERP 中继连接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "DERP 中繼連接"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Destination" : {
|
"Destination" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -2339,6 +2398,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Details" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "جزئیات"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Подробности"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "详细信息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "詳細資訊"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Direct connection" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "اتصال مستقیم"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Прямое соединение"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "直接连接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "直接連接"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Disable Deprecated Warnings" : {
|
"Disable Deprecated Warnings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -3179,6 +3294,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Endpoints" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "نقاط اتصال"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Точки подключения"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "端点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "端點"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"enforceRoutes" : {
|
"enforceRoutes" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
@@ -3334,6 +3477,34 @@
|
|||||||
"excludeLocalNetworks" : {
|
"excludeLocalNetworks" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"Exit Node" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "گره خروجی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Выходной узел"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "出口节点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "出口節點"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Export Complete" : {
|
"Export Complete" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -4079,7 +4250,7 @@
|
|||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
"HTTP/3" : {
|
"HTTP/3" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
"https://sing-box.sagernet.org/" : {
|
"https://sing-box.sagernet.org/" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4838,6 +5009,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Key Expiry" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "انقضای کلید"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Срок действия ключа"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "密钥过期"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "金鑰到期"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Kill all connections to free memory when the service memory exceeds the limit." : {
|
"Kill all connections to free memory when the service memory exceeds the limit." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -5175,6 +5374,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MagicDNS" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MagicDNS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MagicDNS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MagicDNS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MagicDNS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Managing working directory requires Helper Service." : {
|
"Managing working directory requires Helper Service." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -5960,6 +6187,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No data" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "بدون داده"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Нет данных"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "无数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "無資料"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"No Default Route" : {
|
"No Default Route" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -6047,6 +6302,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Not Connected" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "متصل نیست"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Не подключено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "未连接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "未連線"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Not supported by server" : {
|
"Not supported by server" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -6214,6 +6497,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Open Auth URL" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "باز کردن لینک احراز هویت"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Открыть URL авторизации"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "打开认证链接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "開啟認證連結"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Open Auth URL as QR Code" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Open Settings" : {
|
"Open Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6243,6 +6557,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"OS" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "سیستمعامل"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ОС"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "操作系统"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "作業系統"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Other methods" : {
|
"Other methods" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -6468,6 +6810,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Ping" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "پینگ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Пинг"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ping"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Please enable camera access in Settings to scan QR codes." : {
|
"Please enable camera access in Settings to scan QR codes." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8558,6 +8928,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Tailscale" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tailscale Addresses" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "آدرسهای Tailscale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Адреса Tailscale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale 地址"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale 位址"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tailscale: %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tailscale: %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Taiwan Flag Available" : {
|
"Taiwan Flag Available" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8698,6 +9152,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"This Device" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "این دستگاه"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Это устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "此设备"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "此裝置"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"This helper service provides process lookup for `process_name` and `process_path` routing rules, and manages the working directory." : {
|
"This helper service provides process lookup for `process_name` and `process_path` routing rules, and manages the working directory." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
|
|||||||
Reference in New Issue
Block a user