diff --git a/ApplicationLibrary/Views/Abstract/DeviceCensorship.swift b/ApplicationLibrary/Views/Abstract/DeviceCensorship.swift new file mode 100644 index 0000000..9d7993a --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/DeviceCensorship.swift @@ -0,0 +1,80 @@ +import Foundation +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +public enum DeviceCensorship { + public static func isChinaDevice() -> Bool { + let bannedCharacter = "\u{1F1F9}\u{1F1FC}" as NSString + var imageData: Data + #if canImport(UIKit) + let attributes = [NSAttributedString.Key.font: + UIFont.systemFont(ofSize: 8)] + UIGraphicsBeginImageContext(bannedCharacter.size(withAttributes: attributes)) + bannedCharacter.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes) + var imagePNG: Data? + if let charImage = UIGraphicsGetImageFromCurrentImageContext() { + imagePNG = charImage.pngData() + } + UIGraphicsEndImageContext() + guard let imagePNG else { + return false + } + guard let uiImage = UIImage(data: imagePNG) else { + return false + } + guard let cgImage = uiImage.cgImage else { return false } + guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false } + imageData = cgImageData + #elseif canImport(AppKit) + let attributes = [NSAttributedString.Key.font: + NSFont.systemFont(ofSize: 8)] + let characterSize = bannedCharacter.size(withAttributes: attributes) + let characterRect = NSRect(origin: .zero, size: characterSize) + + let characterBitmap = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(characterSize.width), + pixelsHigh: Int(characterSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: NSColorSpaceName.calibratedRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: characterBitmap!) + NSGraphicsContext.current?.imageInterpolation = .high + + bannedCharacter.draw(in: characterRect, withAttributes: attributes) + + NSGraphicsContext.restoreGraphicsState() + + guard let imagePNG = characterBitmap?.representation(using: .png, properties: [:]) else { + return false + } + + guard let nsImage = NSImage(data: imagePNG) else { + return false + } + guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return false + } + guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false } + imageData = cgImageData + #endif + let rawData: UnsafePointer = CFDataGetBytePtr(imageData as CFData) + for index in stride(from: 0, to: imageData.count, by: 4) { + let r = rawData[index] + let g = rawData[index + 1] + let b = rawData[index + 2] + if !(r == g && g == b) { + return false + } + } + return true + } +} diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 7436d02..808b498 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -34,30 +34,38 @@ public struct ActiveDashboardView: View { if profileList.isEmpty { Text("Empty profiles") } else { - #if os(iOS) - StartStopButton() - #endif - if profile.status.isConnected { - Section("Status") { - ExtensionStatusView() - } - } - Section("Profile") { + VStack { #if os(iOS) - Picker(selection: $selectedProfileID) { - ForEach(profileList, id: \.id) { profile in - Text(profile.name).tag(profile.id) + if profile.status.isConnected { + ExtensionStatusView() + .listStyle(.automatic) + .navigationBarTitleDisplayMode(.inline) + } + FormView { + StartStopButton() + Section("Profile") { + Picker(selection: $selectedProfileID) { + ForEach(profileList, id: \.id) { profile in + Text(profile.name).tag(profile.id) + } + } label: {} + .pickerStyle(.inline) } - } label: {} - .pickerStyle(.inline) - - #elseif os(macOS) - ForEach(profileList, id: \.id) { profile in - Picker(profile.name, selection: $selectedProfileID) { - Text("").tag(profile.id) + } + #else + if profile.status.isConnected { + ExtensionStatusView() + } + FormView { + Section("Profile") { + ForEach(profileList, id: \.id) { profile in + Picker(profile.name, selection: $selectedProfileID) { + Text("").tag(profile.id) + } + } + .pickerStyle(.radioGroup) } } - .pickerStyle(.radioGroup) #endif } .onChange(of: selectedProfileID) { _ in diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index d6d4698..8d6fc0b 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -6,11 +6,13 @@ public struct DashboardView: View { public init() {} public var body: some View { - FormView { + viewBuilder { if let profile = extensionProfile.wrappedValue { ActiveDashboardView().environmentObject(profile) } else { - InstallProfileButton() + FormView { + InstallProfileButton() + } } }.navigationTitle("Dashboard") } diff --git a/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift b/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift index ff022f5..2d7c8fc 100644 --- a/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift +++ b/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift @@ -6,6 +6,7 @@ public struct ExtensionStatusView: View { @State private var commandClient: LibboxCommandClient? @State private var message: LibboxStatusMessage? @State private var connectTask: Task? + @State private var columnCount: Int = 4 @State private var errorPresented = false @State private var errorMessage = "" @@ -15,23 +16,43 @@ public struct ExtensionStatusView: View { public var body: some View { viewBuilder { - if let message { - FormTextItem("Memory", LibboxFormatBytes(message.memory)) - FormTextItem("Goroutines", "\(message.goroutines)") - FormTextItem("Connections", "\(message.connections)").contextMenu { - Button("Close", role: .destructive) { - Task.detached { - closeConnections() + VStack { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount), alignment: .leading) { + if let message { + StatusItem("Memory", LibboxFormatBytes(message.memory)) + StatusItem("Goroutines", "\(message.goroutines)") + if message.trafficAvailable { + StatusItem("Inbound Connections", "\(message.connectionsIn)") + StatusItem("Outbound Connections", "\(message.connectionsOut)") + StatusItem("Uplink", LibboxFormatBytes(message.uplink) + "/s") + StatusItem("Downlink", LibboxFormatBytes(message.downlink) + "/s") + StatusItem("Uplink Total", LibboxFormatBytes(message.uplinkTotal)) + StatusItem("Downlink Total", LibboxFormatBytes(message.downlinkTotal)) + } else { + StatusItem("Connections", "\(message.connectionsOut)") } + } else { + StatusItem("Memory", "Loading...") + StatusItem("Goroutines", "Loading...") + StatusItem("Connections", "Loading...") } + }.background { + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .frame(height: 1) + .onChange(of: geometry.size.width) { newValue in + updateColumnCount(newValue) + } + .onAppear { + updateColumnCount(geometry.size.width) + } + }.padding() } - } else { - FormTextItem("Memory", "Loading...") - FormTextItem("Goroutines", "Loading...") - FormTextItem("Connections", "Loading...") } + .frame(alignment: .topLeading) + .padding([.top, .leading, .trailing]) } - .onAppear(perform: doReload) .onDisappear { connectTask?.cancel() @@ -85,6 +106,15 @@ public struct ExtensionStatusView: View { } } + private func updateColumnCount(_ width: Double) { + let v = Int(Int(width) / 155) + let new = v < 1 ? 1 : (v > 4 ? 4 : (v % 2 == 0 ? v : v - 1)) + + if new != columnCount { + columnCount = new + } + } + private func closeConnections() { do { try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.closeConnections() @@ -113,4 +143,38 @@ public struct ExtensionStatusView: View { func writeGroups(_: LibboxOutboundGroupIteratorProtocol?) {} } + + private struct StatusItem: View { + private let name: String + private let value: String + init(_ name: String, _ value: String) { + self.name = name + self.value = value + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + Text(value) + .font(.system(size: 16)) + } + .frame(minWidth: 125) + .padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13)) + .background(backgroundColor) + .cornerRadius(10) + } + + private var backgroundColor: Color { + #if os(iOS) + return Color(uiColor: .secondarySystemGroupedBackground) + #elseif os(macOS) + return Color(nsColor: .textBackgroundColor) + #endif + } + } } diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index f5dc268..9cfd705 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -21,6 +21,7 @@ public struct SettingView: View { @State private var disableMemoryLimit = false @State private var version = "" @State private var dataSize = "" + @State private var taiwanFlagAvailable = false @State private var errorPresented = false @State private var errorMessage = "" @@ -80,6 +81,9 @@ public struct SettingView: View { } .foregroundColor(.red) } + Section("Debug") { + FormTextItem("Taiwan Flag Available", taiwanFlagAvailable.description) + } } } } @@ -121,6 +125,7 @@ public struct SettingView: View { dataSize = "Loading..." isLoading = false dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" + taiwanFlagAvailable = !DeviceCensorship.isChinaDevice() } private func clearWorkingDirectory() { diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index 0da39d7..6c4efc5 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -16,4 +16,8 @@ public enum SharedPreferences { @Preference("show_menu_bar_extra", defaultValue: true) public static var showMenuBarExtra @Preference("started_by_user", defaultValue: false) public static var startedByUser #endif + + #if os(iOS) + @Preference("network_permission_requested", defaultValue: false) public static var networkPermissionRequested + #endif } diff --git a/SFI/ApplicationDelegate.swift b/SFI/ApplicationDelegate.swift index d1cd727..d79a1b6 100644 --- a/SFI/ApplicationDelegate.swift +++ b/SFI/ApplicationDelegate.swift @@ -15,6 +15,29 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate { NSLog("setup background task error: \(error.localizedDescription)") } } + Task.detached { + await self.requestNetworkPermission() + } return true } + + private func requestNetworkPermission() { + if UIDevice.current.userInterfaceIdiom != .phone { + return + } + if SharedPreferences.networkPermissionRequested { + return + } + if !DeviceCensorship.isChinaDevice() { + SharedPreferences.networkPermissionRequested = true + return + } + URLSession.shared.dataTask(with: URL(string: "http://captive.apple.com")!) { _, response, _ in + if let response = response as? HTTPURLResponse { + if response.statusCode == 200 { + SharedPreferences.networkPermissionRequested = true + } + } + }.resume() + } } diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 1046b3f..2853f17 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ 3AC1944F2A50247300BD8CB9 /* ApplicationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC1944E2A50247300BD8CB9 /* ApplicationDelegate.swift */; }; 3AC194502A502DFE00BD8CB9 /* ServiceNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC1944C2A50206C00BD8CB9 /* ServiceNotification.swift */; }; 3AC194522A50303300BD8CB9 /* ApplicationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC194512A50303300BD8CB9 /* ApplicationDelegate.swift */; }; + 3AC5EC082A6417470077AF34 /* DeviceCensorship.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC5EC072A6417470077AF34 /* DeviceCensorship.swift */; }; 3ADF8DFB2A4AFDF500900CC8 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AEC210D2A459B1900A63465 /* MainView.swift */; }; 3AEAEE992A4F16430059612D /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3AEC20F62A459AB400A63465 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AEC20F52A459AB400A63465 /* Application.swift */; }; @@ -298,6 +299,7 @@ 3AC1944C2A50206C00BD8CB9 /* ServiceNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceNotification.swift; sourceTree = ""; }; 3AC1944E2A50247300BD8CB9 /* ApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDelegate.swift; sourceTree = ""; }; 3AC194512A50303300BD8CB9 /* ApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDelegate.swift; sourceTree = ""; }; + 3AC5EC072A6417470077AF34 /* DeviceCensorship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceCensorship.swift; sourceTree = ""; }; 3ADF8DF12A4AF59900900CC8 /* ActiveDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveDashboardView.swift; sourceTree = ""; }; 3ADF8DF32A4AF9B500900CC8 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 3ADF8DF62A4AFB2C00900CC8 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; @@ -615,9 +617,9 @@ isa = PBXGroup; children = ( 3AF342A22A4A9B9B002B34AC /* InstallProfileButton.swift */, - 3AF342CC2A4AA88C002B34AC /* StartStopButton.swift */, 3AF342D02A4AACC4002B34AC /* ExtensionStatusView.swift */, 3ADF8DF12A4AF59900900CC8 /* ActiveDashboardView.swift */, + 3AF342CC2A4AA88C002B34AC /* StartStopButton.swift */, 3ADF8DF32A4AF9B500900CC8 /* DashboardView.swift */, ); path = Dashboard; @@ -629,6 +631,7 @@ 3A7E90302A46745A00D53052 /* ViewBuilder.swift */, 3AF342D32A4AADB2002B34AC /* Formtem.swift */, 3ADF8E022A4B118700900CC8 /* Binding+Unwrap.swift */, + 3AC5EC072A6417470077AF34 /* DeviceCensorship.swift */, ); path = Abstract; sourceTree = ""; @@ -921,6 +924,7 @@ 3A4EAD262A4FEB65005435B3 /* ExtensionStatusView.swift in Sources */, 3A4EAD252A4FEB65005435B3 /* StartStopButton.swift in Sources */, 3A4EAD2B2A4FEB6D005435B3 /* ViewBuilder.swift in Sources */, + 3AC5EC082A6417470077AF34 /* DeviceCensorship.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1431,7 +1435,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.3.1; OTHER_CODE_SIGN_FLAGS = "--deep"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa; @@ -1473,7 +1477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.3.1; OTHER_CODE_SIGN_FLAGS = "--deep"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa; @@ -1513,7 +1517,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.3.1; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa; PRODUCT_NAME = "sing-box β"; @@ -1551,7 +1555,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.3.1; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa; PRODUCT_NAME = "sing-box β"; diff --git a/sing-box.xcodeproj/xcuserdata/sekai.xcuserdatad/xcschemes/xcschememanagement.plist b/sing-box.xcodeproj/xcuserdata/sekai.xcuserdatad/xcschemes/xcschememanagement.plist index a6e861a..af35d17 100644 --- a/sing-box.xcodeproj/xcuserdata/sekai.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/sing-box.xcodeproj/xcuserdata/sekai.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,42 +12,42 @@ Associations (Playground) 1.xcscheme isShown - + orderHint 8 Associations (Playground) 2.xcscheme isShown - + orderHint 9 Associations (Playground) 3.xcscheme isShown - + orderHint 27 Associations (Playground) 4.xcscheme isShown - + orderHint 28 Associations (Playground) 5.xcscheme isShown - + orderHint 29 Associations (Playground).xcscheme isShown - + orderHint 7 @@ -69,42 +69,42 @@ MyPlayground (Playground) 1.xcscheme isShown - + orderHint 4 MyPlayground (Playground) 2.xcscheme isShown - + orderHint 6 MyPlayground (Playground) 3.xcscheme isShown - + orderHint 23 MyPlayground (Playground) 4.xcscheme isShown - + orderHint 24 MyPlayground (Playground) 5.xcscheme isShown - + orderHint 26 MyPlayground (Playground).xcscheme isShown - + orderHint 3 @@ -136,84 +136,84 @@ Tour (Playground) 1.xcscheme isShown - + orderHint 15 Tour (Playground) 2.xcscheme isShown - + orderHint 17 Tour (Playground) 3.xcscheme isShown - + orderHint 33 Tour (Playground) 4.xcscheme isShown - + orderHint 34 Tour (Playground) 5.xcscheme isShown - + orderHint 35 Tour (Playground).xcscheme isShown - + orderHint 14 TransactionObserver (Playground) 1.xcscheme isShown - + orderHint 12 TransactionObserver (Playground) 2.xcscheme isShown - + orderHint 13 TransactionObserver (Playground) 3.xcscheme isShown - + orderHint 30 TransactionObserver (Playground) 4.xcscheme isShown - + orderHint 31 TransactionObserver (Playground) 5.xcscheme isShown - + orderHint 32 TransactionObserver (Playground).xcscheme isShown - + orderHint 11 @@ -238,17 +238,17 @@ 3A096F852A4ED3DE00D4A2ED primary - + 3A77016C2A4E6B34008F031F primary - + 3AEC211C2A459B4700A63465 primary - +