diff --git a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift index caaa99c..55db2c8 100644 --- a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift +++ b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift @@ -146,6 +146,33 @@ public extension View { } #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 { func cardStyle() -> some View { modifier(CardStyleModifier()) diff --git a/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift b/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift new file mode 100644 index 0000000..f0a6e42 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift @@ -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) + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscalePeerView.swift b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift new file mode 100644 index 0000000..eda5f0e --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift @@ -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 + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift b/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift new file mode 100644 index 0000000..69cae6e --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift @@ -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? + + 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 + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift b/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift new file mode 100644 index 0000000..185bb65 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift @@ -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? + + 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 + ) + } + } +} diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift index d7f3fbf..021076e 100644 --- a/ApplicationLibrary/Views/Tools/ToolsView.swift +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -1,10 +1,12 @@ import Library +import NetworkExtension import SwiftUI @MainActor public struct ToolsView: View { @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var viewModel = SettingViewModel() + @StateObject private var tailscaleViewModel = TailscaleStatusViewModel() #if os(iOS) @State private var showCrashReportList = false @State private var showOOMReportList = false @@ -14,6 +16,22 @@ public struct ToolsView: View { public var body: some View { 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") { FormNavigationLink { 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() + } + } + } } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3767efc..a39e81c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -597,6 +597,9 @@ } } } + }, + "Auth URL" : { + }, "Authorize" : { "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..." : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "shouldTranslate" : false }, @@ -3334,6 +3477,34 @@ "excludeLocalNetworks" : { "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" : { "localizations" : { "fa" : { @@ -4079,7 +4250,7 @@ "shouldTranslate" : false }, "HTTP/3" : { - + "shouldTranslate" : false }, "https://sing-box.sagernet.org/" : { "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." : { "localizations" : { "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." : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "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" : { "localizations" : { "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." : { "localizations" : { "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" : { "localizations" : { "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." : { "localizations" : { "fa" : {