From 6b589cb77140475b87c4a34c22ad0ab1bc442026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 7 Jan 2026 14:12:54 +0800 Subject: [PATCH] Add screenshot generator --- .gitignore | 6 +- ApplicationLibrary/ApplicationLibrary.swift | 1 - .../Connections/ConnectionListView.swift | 1 + .../Connections/ConnectionListViewModel.swift | 2 +- .../Views/Dashboard/ActiveDashboardView.swift | 12 +- .../Dashboard/ButtonVisibilityState.swift | 12 +- .../Dashboard/Cards/ConnectionsCard.swift | 2 +- .../Dashboard/Cards/DownloadTrafficCard.swift | 2 +- .../Views/Dashboard/Cards/StatusCard.swift | 2 +- .../Dashboard/Cards/UploadTrafficCard.swift | 2 +- .../Components/ExtensionStatusView.swift | 2 +- .../Components/StartStopButton.swift | 19 +- .../Views/Dashboard/DashboardView.swift | 4 +- .../Views/Dashboard/DashboardViewModel.swift | 2 +- .../Dashboard/Overview/OverviewView.swift | 6 +- .../Views/Groups/GroupListViewModel.swift | 17 +- ApplicationLibrary/Views/Log/LogView.swift | 6 +- ApplicationLibrary/Views/NavigationPage.swift | 19 + .../Views/Setting/CoreView.swift | 2 +- .../Views/Setting/SettingViewModel.swift | 2 +- Library/Network/CommandClient.swift | 9 + Library/Network/ExtensionEnvironments.swift | 10 +- Library/Network/ExtensionProfile.swift | 44 ++- Library/Shared/ScreenshotLocalization.swift | 42 +++ Library/Shared/Variant.swift | 6 + MacLibrary/ApplicationDelegate.swift | 7 +- MacLibrary/EditProfileContentWindow.swift | 14 - MacLibrary/MainView.swift | 60 ++- MacLibrary/MainViewModel.swift | 7 +- MacLibrary/SidebarView.swift | 24 +- MacLibrary/StatusBarController.swift | 2 +- MacLibrary/WindowAccessor.swift | 29 ++ SFI/Info.plist | 2 +- SFI/MainView.swift | 2 +- SFIUITests/SnapshotHelper.swift | 319 ++++++++++++++++ SFIUITests/SnapshotTests.swift | 43 +++ SFM/Application.swift | 5 + SFM/Info.plist | 2 +- SFMUITests/SnapshotHelper.swift | 142 +++++++ SFMUITests/SnapshotTests.swift | 78 ++++ SFT/Application.swift | 4 + SFT/Info.plist | 2 +- SFT/MainView.swift | 18 +- SFTUITests/SnapshotHelper.swift | 58 +++ SFTUITests/SnapshotTests.swift | 72 ++++ UITests/SnapshotTests.swift | 74 ++++ UITests/UITests.swift | 40 ++ UITests/UITestsLaunchTests.swift | 32 ++ fastlane/Fastfile | 261 +++++++++++++ fastlane/README.md | 58 +++ fastlane/Snapfile | 30 ++ sing-box.xcodeproj/project.pbxproj | 356 ++++++++++++++++++ .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/SFI.xcscheme | 18 +- .../xcschemes/SFM.System.xcscheme | 5 +- .../xcshareddata/xcschemes/SFM.xcscheme | 18 +- .../xcshareddata/xcschemes/SFT.xcscheme | 18 +- 57 files changed, 1931 insertions(+), 109 deletions(-) create mode 100644 Library/Shared/ScreenshotLocalization.swift create mode 100644 MacLibrary/WindowAccessor.swift create mode 100644 SFIUITests/SnapshotHelper.swift create mode 100644 SFIUITests/SnapshotTests.swift create mode 100644 SFMUITests/SnapshotHelper.swift create mode 100644 SFMUITests/SnapshotTests.swift create mode 100644 SFTUITests/SnapshotHelper.swift create mode 100644 SFTUITests/SnapshotTests.swift create mode 100644 UITests/SnapshotTests.swift create mode 100644 UITests/UITests.swift create mode 100644 UITests/UITestsLaunchTests.swift create mode 100644 fastlane/Fastfile create mode 100644 fastlane/README.md create mode 100644 fastlane/Snapfile create mode 100644 sing-box.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/.gitignore b/.gitignore index 43038f6..7baaeb5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ xcuserdata/ CLAUDE.md .claude .mcp.json -.serena +/screenshots/ +/fastlane/report.xml +/fastlane/test_output/ +/fastlane/.bundle/ +/fastlane/.cache/ diff --git a/ApplicationLibrary/ApplicationLibrary.swift b/ApplicationLibrary/ApplicationLibrary.swift index b8246da..3a001dd 100644 --- a/ApplicationLibrary/ApplicationLibrary.swift +++ b/ApplicationLibrary/ApplicationLibrary.swift @@ -2,5 +2,4 @@ import Foundation public class ApplicationLibrary { public static let bundle = Bundle(for: ApplicationLibrary.self) - public static let inPreview = false } diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift index e9c58f6..ed0f865 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -191,6 +191,7 @@ public struct ConnectionListView: View { return UIMenu(children: [stateMenu, sortMenu, closeAction]) } } + #elseif os(macOS) private struct ConnectionMenuView: View { @Binding var connectionStateFilter: ConnectionStateFilter diff --git a/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift b/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift index 51efe96..9f5d26e 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift @@ -56,7 +56,7 @@ public class ConnectionListViewModel: BaseViewModel { } public func connect() { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { isLoading = false return } diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 55faf2b..6b852c0 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -39,14 +39,14 @@ import SwiftUI #endif } else { content.onAppear { - guard !ApplicationLibrary.inPreview, profile.status.isConnected else { + guard !Variant.screenshotMode, profile.status.isConnected else { return } Task { await coordinator.reloadSystemProxy() } }.onChangeCompat(of: profile.status) { status in - guard !ApplicationLibrary.inPreview, status == .connected else { + guard !Variant.screenshotMode, status == .connected else { return } Task { @@ -60,7 +60,7 @@ import SwiftUI Group { #if os(iOS) if useLegacyTabView { - if ApplicationLibrary.inPreview || profile.status.isConnectedStrict { + if Variant.screenshotMode || profile.status.isConnectedStrict { VStack { pageSelector pageContent @@ -126,11 +126,7 @@ import SwiftUI #endif #endif .onAppear { - if ApplicationLibrary.inPreview { - environments.commandClient.connect() - } else { - environments.connect() - } + environments.connect() }.onChangeCompat(of: scenePhase) { phase in guard phase == .active else { return diff --git a/ApplicationLibrary/Views/Dashboard/ButtonVisibilityState.swift b/ApplicationLibrary/Views/Dashboard/ButtonVisibilityState.swift index 9bd5e9f..b0894ee 100644 --- a/ApplicationLibrary/Views/Dashboard/ButtonVisibilityState.swift +++ b/ApplicationLibrary/Views/Dashboard/ButtonVisibilityState.swift @@ -19,13 +19,19 @@ public struct ButtonVisibilityState { return } - groupsCount = commandClient.groups?.count ?? 0 + let actualGroupsCount = commandClient.groups?.count ?? 0 + let screenshotFallbackGroupsCount = 2 + groupsCount = Variant.screenshotMode && actualGroupsCount == 0 + ? screenshotFallbackGroupsCount + : actualGroupsCount connectionsCount = commandClient.connections?.count ?? 0 - let isConnected = ApplicationLibrary.inPreview || profile.status.isConnectedStrict + let isConnected = Variant.screenshotMode || profile.status.isConnectedStrict + + let hasGroups = Variant.screenshotMode || (commandClient.groups?.isEmpty == false) showConnectionsButton = isConnected - showGroupsButton = isConnected && (commandClient.groups?.isEmpty == false) + showGroupsButton = isConnected && hasGroups } private mutating func reset() { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift index 9b41c2e..6165607 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift @@ -11,7 +11,7 @@ public struct ConnectionsCard: View { DashboardCardView(title: "", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { DashboardCardHeader(icon: "link.circle.fill", title: "Connections") - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { DashboardCardLine(String(localized: "Inbound"), "34") DashboardCardLine(String(localized: "Outbound"), "28") } else if let message = commandClient.status { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift index b1b9aec..1fb65b9 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift @@ -12,7 +12,7 @@ public struct DownloadTrafficCard: View { VStack(alignment: .leading, spacing: 8) { DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download") - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { Text("249 MB/s") .font(.title2) .fontWeight(.medium) diff --git a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift index 40abf71..ba12cbb 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift @@ -11,7 +11,7 @@ public struct StatusCard: View { DashboardCardView(title: "", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { DashboardCardHeader(icon: "info.circle.fill", title: "Status") - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { DashboardCardLine(String(localized: "Memory"), "6.4 MB") DashboardCardLine(String(localized: "Goroutines"), "89") } else if let message = commandClient.status { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift index 75bfeee..a37c0f0 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift @@ -12,7 +12,7 @@ public struct UploadTrafficCard: View { VStack(alignment: .leading, spacing: 8) { DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload") - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { Text("38 B/s") .font(.title2) .fontWeight(.medium) diff --git a/ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift b/ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift index 9edf83e..441651b 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift @@ -28,7 +28,7 @@ public struct ExtensionStatusView: View { Group { VStack { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount), alignment: .leading) { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { StatusItem(String(localized: "Status")) { StatusLine(String(localized: "Memory"), "6.4 MB") StatusLine(String(localized: "Goroutines"), "89") diff --git a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 875d67b..8671e9a 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -10,16 +10,7 @@ public struct StartStopButton: View { public var body: some View { Group { - if ApplicationLibrary.inPreview { - Button {} label: { - #if os(tvOS) - Image(systemName: "stop.fill") - #else - Label("Stop", systemImage: "stop.fill") - #endif - } - .labelStyle(.iconOnly) - } else if let profile = environments.extensionProfile { + if let profile = environments.extensionProfile { ToggleConnectionButton().environmentObject(profile) } else { Button {} label: { @@ -108,6 +99,7 @@ public struct StartStopButton: View { .disabled(!profile.status.isEnabled) .alert($alert) .onReceive(timer) { _ in + guard !Variant.screenshotMode else { return } Task { @MainActor in currentTime = Date() } @@ -140,7 +132,12 @@ public struct StartStopButton: View { private var runtimeDuration: String? { guard let connectedDate = profile.connectedDate else { return nil } - let interval = currentTime.timeIntervalSince(connectedDate) + let interval: TimeInterval + if Variant.screenshotMode { + interval = 3600 + } else { + interval = currentTime.timeIntervalSince(connectedDate) + } guard interval >= 0 else { return nil } let hours = Int(interval) / 3600 diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index ac24a3d..981e6fc 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -77,9 +77,7 @@ public struct DashboardView: View { @ViewBuilder private var mainContent: some View { - if ApplicationLibrary.inPreview { - activeDashboardView - } else if environments.extensionProfileLoading { + if environments.extensionProfileLoading { ProgressView() } else if let profile = environments.extensionProfile { activeDashboardView diff --git a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift index c23e87f..59c79b4 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift @@ -48,7 +48,7 @@ public final class DashboardViewModel: BaseViewModel { defer { isLoading = false } - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { profileList = [ ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")), ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))), diff --git a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift index 830df67..2324bbe 100644 --- a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift @@ -41,7 +41,7 @@ public struct OverviewView: View { } } .alert($coordinator.alert) - .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || coordinator.reasserting)) + .disabled(!Variant.screenshotMode && (!profile.status.isSwitchable || coordinator.reasserting)) } @ViewBuilder @@ -85,9 +85,9 @@ public struct OverviewView: View { private func shouldShowCard(_ card: DashboardCard) -> Bool { switch card { case .status, .connections, .uploadTraffic, .downloadTraffic, .clashMode: - return ApplicationLibrary.inPreview || profile.status.isConnected + return Variant.screenshotMode || profile.status.isConnected case .httpProxy: - return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable + return (Variant.screenshotMode || profile.status.isConnectedStrict) && systemProxyAvailable case .profile: return true } diff --git a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift index c9457b3..ca4b66a 100644 --- a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift +++ b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift @@ -14,17 +14,18 @@ public class GroupListViewModel: BaseViewModel { } public func connect() { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { groups = [ OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [ - OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12), - OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34), - OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 100), + OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 10), + OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 20), + OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 30), + ]), + OutboundGroup(tag: "Auto", type: "urltest", selected: "Tokyo", selectable: true, isExpand: false, items: [ + OutboundGroupItem(tag: "Tokyo", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 10), + OutboundGroupItem(tag: "Singapore", type: "VMess", urlTestTime: .now, urlTestDelay: 20), + OutboundGroupItem(tag: "Hong Kong", type: "Trojan", urlTestTime: .now, urlTestDelay: 15), ]), - OutboundGroup(tag: "group2", type: "urltest", selected: "client", selectable: true, isExpand: false, items: - (0 ..< 234).map { index in - OutboundGroupItem(tag: "client\(index)", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: UInt16(100 + index * 10)) - }), ] isLoading = false } diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 3faab0d..7f68609 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -98,7 +98,7 @@ private struct LogViewContent: View { func updateUIView(_ uiView: UIButton, context _: Context) { uiView.menu = createMenu() - if #available(iOS 26.0, *) { + if #available(iOS 17.0, *) { uiView.tintColor = colorScheme == .dark ? .white : .black } } @@ -225,7 +225,7 @@ private struct LogContentInnerView: View { var body: some View { Group { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { previewContent } else if dataModel.isEmpty { emptyContent @@ -240,8 +240,6 @@ private struct LogContentInnerView: View { private var previewContent: some View { let logList = [ "(packet-tunnel) log server started", - "INFO[0000] router: loaded geoip database: 250 codes", - "INFO[0000] router: loaded geosite database: 1400 codes", "INFO[0000] router: updated default interface en0, index 11", "inbound/tun[0]: started at utun3", "sing-box started (1.666s)", diff --git a/ApplicationLibrary/Views/NavigationPage.swift b/ApplicationLibrary/Views/NavigationPage.swift index f459e5e..6ff2771 100644 --- a/ApplicationLibrary/Views/NavigationPage.swift +++ b/ApplicationLibrary/Views/NavigationPage.swift @@ -17,6 +17,25 @@ public enum NavigationPage: Int, CaseIterable, Identifiable { } public extension NavigationPage { + init?(snapshotValue: String) { + switch snapshotValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "dashboard": + self = .dashboard + case "logs": + self = .logs + case "settings": + self = .settings + #if os(macOS) + case "groups": + self = .groups + case "connections": + self = .connections + #endif + default: + return nil + } + } + #if os(macOS) static var macosDefaultPages: [NavigationPage] { [.logs, .settings] diff --git a/ApplicationLibrary/Views/Setting/CoreView.swift b/ApplicationLibrary/Views/Setting/CoreView.swift index d59652c..86e4e49 100644 --- a/ApplicationLibrary/Views/Setting/CoreView.swift +++ b/ApplicationLibrary/Views/Setting/CoreView.swift @@ -98,7 +98,7 @@ public struct CoreView: View { } private nonisolated func loadSettings() async { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { await MainActor.run { version = "" dataSize = LibboxFormatBytes(1000 * 1000 * 10) diff --git a/ApplicationLibrary/Views/Setting/SettingViewModel.swift b/ApplicationLibrary/Views/Setting/SettingViewModel.swift index 286ddae..36227ac 100644 --- a/ApplicationLibrary/Views/Setting/SettingViewModel.swift +++ b/ApplicationLibrary/Views/Setting/SettingViewModel.swift @@ -12,7 +12,7 @@ final class SettingViewModel: BaseViewModel { nonisolated func checkTaiwanFlagAvailability() async { let available: Bool - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { available = true } else { available = !DeviceCensorship.isChinaDevice() diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 56847eb..6a68dc7 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -91,6 +91,15 @@ public class CommandClient: ObservableObject { self.init([connectionType], logMaxLines: logMaxLines) } + public func setupMockData() { + isConnected = true + clashModeList = ["rule", "global", "direct"] + clashMode = "rule" + uplinkHistory = Array(repeating: CGFloat(1000), count: 30) + downlinkHistory = Array(repeating: CGFloat(5000), count: 30) + hasAnyConnection = true + } + public func connect() { if isConnected { return diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index 581bc21..2cf3653 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -141,7 +141,13 @@ public class ExtensionEnvironments: ObservableObject { public let selectedProfileUpdate = ObjectWillChangePublisher() public let openSettings = ObjectWillChangePublisher() - public init() {} + public init() { + if Variant.screenshotMode { + extensionProfileLoading = false + extensionProfile = .mock + commandClient.setupMockData() + } + } public func postReload() { Task { @@ -150,6 +156,7 @@ public class ExtensionEnvironments: ObservableObject { } public func reload() async { + if Variant.screenshotMode { return } if let newProfile = try? await ExtensionProfile.load() { if extensionProfile == nil || extensionProfile?.status == .invalid { newProfile.register() @@ -163,6 +170,7 @@ public class ExtensionEnvironments: ObservableObject { } public func connect() { + if Variant.screenshotMode { return } guard let profile = extensionProfile else { return } diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index 20a72d9..e19a1a0 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -12,9 +12,10 @@ private let logger = Logger(category: "ExtensionProfile") public class ExtensionProfile: ObservableObject { public static let controlKind = AppConfiguration.widgetControlKind - private let manager: NEVPNManager - private var connection: NEVPNConnection + private let manager: NEVPNManager? + private var connection: NEVPNConnection? private var observer: Any? + private let isMock: Bool @Published public var status: NEVPNStatus @Published public var connectedDate: Date? @@ -24,9 +25,28 @@ public class ExtensionProfile: ObservableObject { connection = manager.connection status = manager.connection.status connectedDate = manager.connection.connectedDate + isMock = false + } + + private init(mockStatus: NEVPNStatus, mockConnectedDate: Date?) { + manager = nil + connection = nil + status = mockStatus + connectedDate = mockConnectedDate + isMock = true + } + + private static var _mock: ExtensionProfile? + + public static var mock: ExtensionProfile { + if _mock == nil { + _mock = ExtensionProfile(mockStatus: .connected, mockConnectedDate: Date().addingTimeInterval(-3600)) + } + return _mock! } public func register() { + guard !isMock, let manager else { return } observer = NotificationCenter.default.addObserver( forName: NSNotification.Name.NEVPNStatusDidChange, object: manager.connection, @@ -88,6 +108,7 @@ public class ExtensionProfile: ObservableObject { } private func setOnDemandRules(useDefaultRules: Bool) async { + guard let manager else { return } if useDefaultRules { manager.onDemandRules = Self.makeDefaultOnDemandRules() } else { @@ -97,6 +118,7 @@ public class ExtensionProfile: ObservableObject { } public func updateOnDemand(enabled: Bool, useDefaultRules: Bool) async throws { + guard let manager else { return } manager.isOnDemandEnabled = enabled await setOnDemandRules(useDefaultRules: useDefaultRules) try await manager.saveToPreferences() @@ -104,10 +126,19 @@ public class ExtensionProfile: ObservableObject { @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) public func fetchLastDisconnectError() async throws { + guard let connection else { return } try await connection.fetchLastDisconnectError() } public func start() async throws { + if isMock { + status = .connecting + try await Task.sleep(nanoseconds: 500_000_000) + status = .connected + connectedDate = Date() + return + } + guard let manager else { return } try await fetchProfile() manager.isEnabled = true let alwaysOn = await SharedPreferences.alwaysOn.get() @@ -131,6 +162,7 @@ public class ExtensionProfile: ObservableObject { } public func reloadService() async throws { + if isMock { return } let options = try await prepareStartOptions() let data = try ExtensionStartOptions.encode(options) guard let session = connection as? NETunnelProviderSession else { @@ -197,6 +229,14 @@ public class ExtensionProfile: ObservableObject { } public func stop() async throws { + if isMock { + status = .disconnecting + try await Task.sleep(nanoseconds: 300_000_000) + status = .disconnected + connectedDate = nil + return + } + guard let manager else { return } if manager.isOnDemandEnabled { manager.isOnDemandEnabled = false try await manager.saveToPreferences() diff --git a/Library/Shared/ScreenshotLocalization.swift b/Library/Shared/ScreenshotLocalization.swift new file mode 100644 index 0000000..03d6bc3 --- /dev/null +++ b/Library/Shared/ScreenshotLocalization.swift @@ -0,0 +1,42 @@ +import Foundation +import ObjectiveC + +private var screenshotLocalizationBundleKey: UInt8 = 0 + +private final class ScreenshotBundle: Bundle, @unchecked Sendable { + override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String { + if let bundle = objc_getAssociatedObject(self, &screenshotLocalizationBundleKey) as? Bundle { + return bundle.localizedString(forKey: key, value: value, table: tableName) + } + return super.localizedString(forKey: key, value: value, table: tableName) + } +} + +public enum ScreenshotLocalization { + public static func applyIfNeeded() { + let environment = ProcessInfo.processInfo.environment + guard let language = environment["SCREENSHOT_LANGUAGE"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !language.isEmpty + else { + return + } + UserDefaults.standard.set([language], forKey: "AppleLanguages") + if let locale = environment["SCREENSHOT_LOCALE"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !locale.isEmpty + { + UserDefaults.standard.set(locale, forKey: "AppleLocale") + } + if let path = Bundle.main.path(forResource: language, ofType: "lproj"), + let localizedBundle = Bundle(path: path) + { + objc_setAssociatedObject(Bundle.main, &screenshotLocalizationBundleKey, localizedBundle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + object_setClass(Bundle.main, ScreenshotBundle.self) + } + } +} + +private let _screenshotLocalizationApplied: Void = { + ScreenshotLocalization.applyIfNeeded() +}() diff --git a/Library/Shared/Variant.swift b/Library/Shared/Variant.swift index 0a84001..0a35c58 100644 --- a/Library/Shared/Variant.swift +++ b/Library/Shared/Variant.swift @@ -22,4 +22,10 @@ public enum Variant { public static var debugNoIOS26 = false public static var debugNoIOS18 = false #endif + + #if targetEnvironment(simulator) + public static let screenshotMode = true + #else + public static let screenshotMode = ProcessInfo.processInfo.arguments.contains("-FASTLANE_SNAPSHOT") + #endif } diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index 33c55f7..1877423 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -32,7 +32,12 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat let launchedAsLogInItem = event?.eventID == kAEOpenApplication && event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem - if SharedPreferences.inDebug || !launchedAsLogInItem || !SharedPreferences.showMenuBarExtra.getBlocking() || !SharedPreferences.menuBarExtraInBackground.getBlocking() { + let shouldShowWindow = Variant.screenshotMode || + SharedPreferences.inDebug || + !launchedAsLogInItem || + !SharedPreferences.showMenuBarExtra.getBlocking() || + !SharedPreferences.menuBarExtraInBackground.getBlocking() + if shouldShowWindow { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) } else { diff --git a/MacLibrary/EditProfileContentWindow.swift b/MacLibrary/EditProfileContentWindow.swift index 1e67e18..f0abaf8 100644 --- a/MacLibrary/EditProfileContentWindow.swift +++ b/MacLibrary/EditProfileContentWindow.swift @@ -157,20 +157,6 @@ private class WindowState { } } -private struct WindowAccessor: NSViewRepresentable { - let callback: (NSWindow?) -> Void - - func makeNSView(context _: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - callback(view.window) - } - return view - } - - func updateNSView(_: NSView, context _: Context) {} -} - private class WindowCloseDelegate: NSObject, NSWindowDelegate { var allowClose = false private let windowState: WindowState diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 1e360d9..c5c5f3f 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -1,4 +1,6 @@ +import AppKit import ApplicationLibrary +import Foundation import Library import SwiftUI @@ -6,17 +8,49 @@ import SwiftUI public struct MainView: View { @Environment(\.controlActiveState) private var controlActiveState @EnvironmentObject private var environments: ExtensionEnvironments - @StateObject private var viewModel = MainViewModel() + @StateObject private var viewModel: MainViewModel @State private var showCardManagement = false @State private var cardConfigurationVersion = 0 @State private var settingsNavigationPath = NavigationPath() @State private var pendingSettingsPage: SettingsPage? + @State private var didConfigureScreenshotWindow = false + @State private var pendingScreenshotSelection: NavigationPage? private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) } - public init() {} + private let screenshotDefaultPixelHeight: CGFloat = 1000 + + public init() { + let initialSelection: NavigationPage = .dashboard + if Variant.screenshotMode, + let pageValue = ProcessInfo.processInfo.environment["SCREENSHOT_PAGE"], + let page = NavigationPage(snapshotValue: pageValue) + { + _pendingScreenshotSelection = State(initialValue: page) + } else { + _pendingScreenshotSelection = State(initialValue: nil) + } + _viewModel = StateObject(wrappedValue: MainViewModel(selection: initialSelection)) + } + + private func screenshotTargetHeight(baseHeight _: CGFloat, window: NSWindow) -> CGFloat { + let scale = max(window.backingScaleFactor, 1) + if let pixelOverride = ProcessInfo.processInfo.environment["SCREENSHOT_WINDOW_PIXEL_HEIGHT"] { + let trimmed = pixelOverride.trimmingCharacters(in: .whitespacesAndNewlines) + if let height = Double(trimmed), height > 0 { + return CGFloat(height) / scale + } + } + if let override = ProcessInfo.processInfo.environment["SCREENSHOT_WINDOW_HEIGHT"] { + let trimmed = override.trimmingCharacters(in: .whitespacesAndNewlines) + if let height = Double(trimmed), height > 0 { + return CGFloat(height) + } + } + return screenshotDefaultPixelHeight / scale + } public var body: some View { NavigationSplitView { @@ -31,7 +65,27 @@ public struct MainView: View { .environment(\.settingsNavigationPath, $settingsNavigationPath) .navigationSplitViewColumnWidth(650) } - .frame(minHeight: 500) + .frame(minHeight: Variant.screenshotMode ? 0 : 500) + .background(WindowAccessor { window in + guard Variant.screenshotMode, !didConfigureScreenshotWindow, let window else { return } + didConfigureScreenshotWindow = true + DispatchQueue.main.async { + window.hasShadow = true + let baseSize = window.contentLayoutRect.size + let targetHeight = screenshotTargetHeight(baseHeight: baseSize.height, window: window) + let targetSize = NSSize(width: baseSize.width, height: targetHeight) + guard targetSize.width > 0, targetSize.height > 0 else { return } + window.contentMinSize = targetSize + window.contentMaxSize = targetSize + window.minSize = targetSize + window.maxSize = targetSize + window.setContentSize(targetSize) + if let pending = pendingScreenshotSelection, pending != viewModel.selection { + viewModel.selection = pending + pendingScreenshotSelection = nil + } + } + }) .onAppear { viewModel.onAppear(environments: environments) } diff --git a/MacLibrary/MainViewModel.swift b/MacLibrary/MainViewModel.swift index fda83ad..27a09f8 100644 --- a/MacLibrary/MainViewModel.swift +++ b/MacLibrary/MainViewModel.swift @@ -6,10 +6,15 @@ import SwiftUI @MainActor public class MainViewModel: BaseViewModel { - @Published public var selection = NavigationPage.dashboard + @Published public var selection: NavigationPage @Published public var importProfile: LibboxProfileContent? @Published public var importRemoteProfile: LibboxImportRemoteProfile? + public init(selection: NavigationPage = .dashboard) { + self.selection = selection + super.init() + } + public func onAppear(environments: ExtensionEnvironments) { environments.postReload() #if !DEBUG diff --git a/MacLibrary/SidebarView.swift b/MacLibrary/SidebarView.swift index b8622ad..224a0f0 100644 --- a/MacLibrary/SidebarView.swift +++ b/MacLibrary/SidebarView.swift @@ -9,7 +9,7 @@ private struct SidebarContentView: View { var environments: ExtensionEnvironments private var hasGroups: Bool { - environments.commandClient.groups?.isEmpty == false + Variant.screenshotMode || environments.commandClient.groups?.isEmpty == false } var body: some View { @@ -77,9 +77,7 @@ public struct SidebarView: View { } public var body: some View { - if ApplicationLibrary.inPreview { - previewContent - } else if environments.extensionProfileLoading { + if environments.extensionProfileLoading { ProgressView() } else if let profile = environments.extensionProfile { SidebarContentView( @@ -93,24 +91,6 @@ public struct SidebarView: View { } } - @ViewBuilder - private var previewContent: some View { - List(selection: $localSelection) { - Section(NavigationPage.dashboard.title) { - Label("Overview", systemImage: "text.and.command.macwindow") - .tint(.textColor) - .tag(NavigationPage.dashboard) - NavigationPage.groups.label.tag(NavigationPage.groups) - NavigationPage.connections.label.tag(NavigationPage.connections) - } - ForEach(NavigationPage.macosDefaultPages, id: \.self) { it in - it.label - } - } - .listStyle(.sidebar) - .scrollDisabled(true) - } - @ViewBuilder private var disconnectedContent: some View { List(selection: $localSelection) { diff --git a/MacLibrary/StatusBarController.swift b/MacLibrary/StatusBarController.swift index e65efb0..6c38d33 100644 --- a/MacLibrary/StatusBarController.swift +++ b/MacLibrary/StatusBarController.swift @@ -203,7 +203,7 @@ public class StatusBarController: NSObject, NSMenuDelegate { guard let groups else { return } - let selectableGroups = groups.filter { $0.selectable } + let selectableGroups = groups.filter(\.selectable) if selectableGroups.isEmpty { groupsItem?.isHidden = true return diff --git a/MacLibrary/WindowAccessor.swift b/MacLibrary/WindowAccessor.swift new file mode 100644 index 0000000..04276cb --- /dev/null +++ b/MacLibrary/WindowAccessor.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct WindowAccessor: NSViewRepresentable { + final class WindowReportingView: NSView { + var onWindowChange: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DispatchQueue.main.async { [weak self] in + self?.onWindowChange?(self?.window) + } + } + } + + let callback: (NSWindow?) -> Void + + func makeNSView(context _: Context) -> WindowReportingView { + let view = WindowReportingView() + view.onWindowChange = callback + return view + } + + func updateNSView(_ nsView: WindowReportingView, context _: Context) { + nsView.onWindowChange = callback + DispatchQueue.main.async { + callback(nsView.window) + } + } +} diff --git a/SFI/Info.plist b/SFI/Info.plist index da29cac..ba92b5f 100644 --- a/SFI/Info.plist +++ b/SFI/Info.plist @@ -30,7 +30,7 @@ CFBundleLocalizations en - zh_CN + zh-Hans CFBundleURLTypes diff --git a/SFI/MainView.swift b/SFI/MainView.swift index 49912b5..a49de5f 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -63,7 +63,7 @@ struct MainView: View { } var body: some View { - if ApplicationLibrary.inPreview { + if Variant.screenshotMode { mainBody.preferredColorScheme(.dark) } else { mainBody diff --git a/SFIUITests/SnapshotHelper.swift b/SFIUITests/SnapshotHelper.swift new file mode 100644 index 0000000..0849ba4 --- /dev/null +++ b/SFIUITests/SnapshotHelper.swift @@ -0,0 +1,319 @@ +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + if let override = ProcessInfo.processInfo.environment["SCREENSHOTS_DIR"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + if let screenshotsDirectory { + try FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true) + } + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty, !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { _ in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { evaluatedObject, _ in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { evaluatedObject, _ in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + numberA ... numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.31] diff --git a/SFIUITests/SnapshotTests.swift b/SFIUITests/SnapshotTests.swift new file mode 100644 index 0000000..3074c82 --- /dev/null +++ b/SFIUITests/SnapshotTests.swift @@ -0,0 +1,43 @@ +import XCTest + +@MainActor +final class SnapshotTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + setupSnapshot(app) + app.launch() + } + + func test01Dashboard() throws { + snapshot("01_Dashboard") + } + + func test02Logs() throws { + if app.tabBars.buttons["Logs"].exists { + app.tabBars.buttons["Logs"].firstMatch.tap() + } else if app.buttons["Logs"].exists { + app.buttons["Logs"].firstMatch.tap() + } + sleep(1) + snapshot("02_Logs") + } + + func test03Settings() throws { + // iPad on iOS 18+ uses floating tab bar which creates nested elements + // Use firstMatch to handle multiple matching elements + if app.tabBars.buttons["Settings"].exists { + app.tabBars.buttons["Settings"].firstMatch.tap() + } else if app.buttons["Settings"].exists { + app.buttons["Settings"].firstMatch.tap() + } else { + let tabBar = app.tabBars.firstMatch + if tabBar.exists { + tabBar.buttons.element(boundBy: tabBar.buttons.count - 1).tap() + } + } + sleep(1) + snapshot("03_Settings") + } +} diff --git a/SFM/Application.swift b/SFM/Application.swift index e1eba06..b49aa97 100644 --- a/SFM/Application.swift +++ b/SFM/Application.swift @@ -1,3 +1,4 @@ +import Library import MacLibrary import SwiftUI @@ -5,6 +6,10 @@ import SwiftUI struct Application: App { @NSApplicationDelegateAdaptor private var appDelegate: ApplicationDelegate + init() { + ScreenshotLocalization.applyIfNeeded() + } + var body: some Scene { MacApplication() } diff --git a/SFM/Info.plist b/SFM/Info.plist index 58ef8ab..2557bdf 100644 --- a/SFM/Info.plist +++ b/SFM/Info.plist @@ -105,7 +105,7 @@ CFBundleLocalizations en - zh_CN + zh-Hans CFBundleDevelopmentRegion en diff --git a/SFMUITests/SnapshotHelper.swift b/SFMUITests/SnapshotHelper.swift new file mode 100644 index 0000000..9b11b52 --- /dev/null +++ b/SFMUITests/SnapshotHelper.swift @@ -0,0 +1,142 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation +import XCTest + +private var screenshotsDir: URL! +@MainActor +private var currentApp: XCUIApplication? + +private func resolveScreenshotsDir() -> URL { + let env = ProcessInfo.processInfo.environment + if let override = env["SCREENSHOTS_DIR"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + if let override = env["SNAPSHOT_SCREENSHOTS_PATH"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + if let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { + return cachesDir.appendingPathComponent("tools.fastlane/screenshots", isDirectory: true) + } + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("tools.fastlane/screenshots", isDirectory: true) +} + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations _: Bool = true) { + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + currentApp = app + screenshotsDir = resolveScreenshotsDir() + try! FileManager.default.createDirectory(at: screenshotsDir, withIntermediateDirectories: true) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool = true) { + snapshot(name, timeWaitingForIdle: waitForLoadingIndicator ? 1 : 0) +} + +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval) { + if timeout > 0 { + sleep(UInt32(timeout)) + } + guard let app = currentApp else { return } + let path = screenshotsDir.appendingPathComponent("Mac-\(name).png") + var resolvedWindowID: CGWindowID? + let deadline = Date().addingTimeInterval(10) + while resolvedWindowID == nil, Date() < deadline { + resolvedWindowID = windowID(for: app) + if resolvedWindowID == nil { + usleep(200_000) + } + } + if let resolvedWindowID { + if let image = cgWindowListCreateImage(windowID: resolvedWindowID), let data = pngData(from: image) { + try! data.write(to: path) + return + } + if captureWindowScreenshot(windowID: resolvedWindowID, to: path) { + return + } + } + let screenshot = XCUIScreen.main.screenshot() + try! screenshot.pngRepresentation.write(to: path) +} + +@MainActor +private func captureWindowScreenshot(windowID: CGWindowID, to path: URL) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + task.arguments = ["-x", "-t", "png", "-o", "-l", "\(windowID)", path.path] + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + return false + } +} + +@MainActor +private func windowID(for _: XCUIApplication) -> CGWindowID? { + guard let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: "io.nekohasekai.sfavt").first else { + return nil + } + let pid = Int(runningApp.processIdentifier) + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let infoList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return nil + } + var candidate: (id: CGWindowID, area: CGFloat)? + for info in infoList { + guard let ownerPid = info[kCGWindowOwnerPID as String] as? Int, ownerPid == pid else { + continue + } + let layer = info[kCGWindowLayer as String] as? Int ?? 0 + guard layer == 0 else { continue } + guard let boundsDict = info[kCGWindowBounds as String] as? [String: Any], + let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary) + else { + continue + } + let area = bounds.width * bounds.height + guard area > 0 else { continue } + guard let number = info[kCGWindowNumber as String] as? Int else { continue } + if candidate == nil || area > (candidate?.area ?? 0) { + candidate = (CGWindowID(number), area) + } + } + return candidate?.id +} + +private func cgWindowListCreateImage(windowID: CGWindowID) -> CGImage? { + typealias CGWindowListCreateImageFunc = @convention(c) (CGRect, CGWindowListOption, CGWindowID, CGWindowImageOption) -> CGImage? + let rtldDefault = UnsafeMutableRawPointer(bitPattern: -2) + guard let symbol = dlsym(rtldDefault, "CGWindowListCreateImage") else { return nil } + let function = unsafeBitCast(symbol, to: CGWindowListCreateImageFunc.self) + let bounds = CGRect.null + let listOption = CGWindowListOption.optionIncludingWindow + let imageOption: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] + return function(bounds, listOption, windowID, imageOption) +} + +private func pngData(from image: CGImage) -> Data? { + let rep = NSBitmapImageRep(cgImage: image) + return rep.representation(using: .png, properties: [:]) +} + +func loadScreenshotLanguage() -> (language: String?, locale: String?) { + let dir = resolveScreenshotsDir() + let language = readScreenshotText(dir.appendingPathComponent("language.txt")) + let locale = readScreenshotText(dir.appendingPathComponent("locale.txt")) + return (language, locale) +} + +private func readScreenshotText(_ url: URL) -> String? { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return nil + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} diff --git a/SFMUITests/SnapshotTests.swift b/SFMUITests/SnapshotTests.swift new file mode 100644 index 0000000..bc83708 --- /dev/null +++ b/SFMUITests/SnapshotTests.swift @@ -0,0 +1,78 @@ +import XCTest + +@MainActor +final class SnapshotTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + app.terminate() + configureLanguage() + if let value = ProcessInfo.processInfo.environment["SCREENSHOT_WINDOW_PIXEL_HEIGHT"] { + app.launchEnvironment["SCREENSHOT_WINDOW_PIXEL_HEIGHT"] = value + } + if let value = ProcessInfo.processInfo.environment["SCREENSHOT_WINDOW_HEIGHT"] { + app.launchEnvironment["SCREENSHOT_WINDOW_HEIGHT"] = value + } + if let page = screenshotPage() { + app.launchEnvironment["SCREENSHOT_PAGE"] = page + } else { + app.launchEnvironment["SCREENSHOT_PAGE"] = "" + } + setupSnapshot(app) + app.launch() + } + + private func configureLanguage() { + let environment = ProcessInfo.processInfo.environment + var language = environment["SCREENSHOT_LANGUAGE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + var locale = environment["SCREENSHOT_LOCALE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + if language == nil || language?.isEmpty == true { + let cached = loadScreenshotLanguage() + language = cached.language + if locale == nil || locale?.isEmpty == true { + locale = cached.locale + } + } + guard let language, !language.isEmpty else { + return + } + app.launchArguments += ["-AppleLanguages", "(\(language))"] + app.launchEnvironment["SCREENSHOT_LANGUAGE"] = language + if let locale, !locale.isEmpty { + app.launchArguments += ["-AppleLocale", locale] + app.launchEnvironment["SCREENSHOT_LOCALE"] = locale + } + } + + private func screenshotPage() -> String? { + let testName = name + if testName.contains("test01Dashboard") { + return "dashboard" + } + if testName.contains("test02Logs") { + return "logs" + } + if testName.contains("test03Settings") { + return "settings" + } + return nil + } + + func test01Dashboard() throws { + sleep(1) + snapshot("01_Dashboard") + } + + func test02Logs() throws { + sleep(1) + snapshot("02_Logs") + } + + func test03Settings() throws { + sleep(1) + snapshot("03_Settings") + } +} diff --git a/SFT/Application.swift b/SFT/Application.swift index ec6c6ad..ef1e184 100644 --- a/SFT/Application.swift +++ b/SFT/Application.swift @@ -7,6 +7,10 @@ struct Application: App { @UIApplicationDelegateAdaptor private var appDelegate: ApplicationDelegate @StateObject private var environments = ExtensionEnvironments() + init() { + ScreenshotLocalization.applyIfNeeded() + } + var body: some Scene { WindowGroup { MainView() diff --git a/SFT/Info.plist b/SFT/Info.plist index 458b680..8d97161 100644 --- a/SFT/Info.plist +++ b/SFT/Info.plist @@ -51,7 +51,7 @@ CFBundleLocalizations en - zh_CN + zh-Hans CFBundleDevelopmentRegion en diff --git a/SFT/MainView.swift b/SFT/MainView.swift index 98f5516..9304d01 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -1,4 +1,5 @@ import ApplicationLibrary +import Foundation import Libbox import Library import SwiftUI @@ -6,7 +7,16 @@ import SwiftUI struct MainView: View { @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var environments: ExtensionEnvironments - @State private var selection = NavigationPage.dashboard + @State private var selection: NavigationPage = { + if Variant.screenshotMode, + let pageValue = ProcessInfo.processInfo.environment["SCREENSHOT_PAGE"], + let page = NavigationPage(snapshotValue: pageValue) + { + return page + } + return .dashboard + }() + @State private var importProfile: LibboxProfileContent? @State private var importRemoteProfile: LibboxImportRemoteProfile? @@ -22,6 +32,12 @@ struct MainView: View { } } .onAppear { + if Variant.screenshotMode, + let pageValue = ProcessInfo.processInfo.environment["SCREENSHOT_PAGE"], + let page = NavigationPage(snapshotValue: pageValue) + { + selection = page + } environments.postReload() } .onChangeCompat(of: scenePhase) { newValue in diff --git a/SFTUITests/SnapshotHelper.swift b/SFTUITests/SnapshotHelper.swift new file mode 100644 index 0000000..02f500e --- /dev/null +++ b/SFTUITests/SnapshotHelper.swift @@ -0,0 +1,58 @@ +import Foundation +import XCTest + +private var screenshotsDir: URL! + +private func resolveScreenshotsDir() -> URL { + let env = ProcessInfo.processInfo.environment + if let override = env["SCREENSHOTS_DIR"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + if let override = env["SNAPSHOT_SCREENSHOTS_PATH"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + if let simulatorHostHome = env["SIMULATOR_HOST_HOME"] { + return URL(fileURLWithPath: simulatorHostHome) + .appendingPathComponent("Library/Caches/tools.fastlane/screenshots") + } + return URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Caches/tools.fastlane/screenshots") +} + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations _: Bool = true) { + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + screenshotsDir = resolveScreenshotsDir() + try! FileManager.default.createDirectory(at: screenshotsDir, withIntermediateDirectories: true) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool = true) { + snapshot(name, timeWaitingForIdle: waitForLoadingIndicator ? 1 : 0) +} + +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval) { + if timeout > 0 { + sleep(UInt32(timeout)) + } + let simulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "AppleTV" + let screenshot = XCUIScreen.main.screenshot() + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + try! screenshot.pngRepresentation.write(to: path) +} + +func loadScreenshotLanguage() -> (language: String?, locale: String?) { + let dir = resolveScreenshotsDir() + let language = readScreenshotText(dir.appendingPathComponent("language.txt")) + let locale = readScreenshotText(dir.appendingPathComponent("locale.txt")) + return (language, locale) +} + +private func readScreenshotText(_ url: URL) -> String? { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return nil + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} diff --git a/SFTUITests/SnapshotTests.swift b/SFTUITests/SnapshotTests.swift new file mode 100644 index 0000000..2292ad3 --- /dev/null +++ b/SFTUITests/SnapshotTests.swift @@ -0,0 +1,72 @@ +import XCTest + +@MainActor +final class SnapshotTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + app.terminate() + configureLanguage() + if let page = screenshotPage() { + app.launchEnvironment["SCREENSHOT_PAGE"] = page + } else { + app.launchEnvironment["SCREENSHOT_PAGE"] = "" + } + setupSnapshot(app) + app.launch() + } + + private func configureLanguage() { + let environment = ProcessInfo.processInfo.environment + var language = environment["SCREENSHOT_LANGUAGE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + var locale = environment["SCREENSHOT_LOCALE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + if language == nil || language?.isEmpty == true { + let cached = loadScreenshotLanguage() + language = cached.language + if locale == nil || locale?.isEmpty == true { + locale = cached.locale + } + } + guard let language, !language.isEmpty else { + return + } + app.launchArguments += ["-AppleLanguages", "(\(language))"] + app.launchEnvironment["SCREENSHOT_LANGUAGE"] = language + if let locale, !locale.isEmpty { + app.launchArguments += ["-AppleLocale", locale] + app.launchEnvironment["SCREENSHOT_LOCALE"] = locale + } + } + + private func screenshotPage() -> String? { + let testName = name + if testName.contains("test01Dashboard") { + return "dashboard" + } + if testName.contains("test02Logs") { + return "logs" + } + if testName.contains("test03Settings") { + return "settings" + } + return nil + } + + func test01Dashboard() throws { + sleep(1) + snapshot("01_Dashboard") + } + + func test02Logs() throws { + sleep(1) + snapshot("02_Logs") + } + + func test03Settings() throws { + sleep(1) + snapshot("03_Settings") + } +} diff --git a/UITests/SnapshotTests.swift b/UITests/SnapshotTests.swift new file mode 100644 index 0000000..799b2fd --- /dev/null +++ b/UITests/SnapshotTests.swift @@ -0,0 +1,74 @@ +import XCTest + +@MainActor +final class SnapshotTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + setupSnapshot(app) + app.launch() + } + + func test01Dashboard() throws { + #if os(macOS) + if app.outlines.staticTexts["Dashboard"].exists { + app.outlines.staticTexts["Dashboard"].click() + } + #endif + sleep(1) + snapshot("01_Dashboard") + } + + func test02Logs() throws { + #if os(iOS) + if app.tabBars.buttons["Logs"].exists { + app.tabBars.buttons["Logs"].firstMatch.tap() + } else if app.buttons["Logs"].exists { + app.buttons["Logs"].firstMatch.tap() + } + #elseif os(macOS) + if app.outlines.staticTexts["Logs"].exists { + app.outlines.staticTexts["Logs"].click() + } + #elseif os(tvOS) + let remote = XCUIRemote.shared + for _ in 0 ..< 3 { + remote.press(.down) + usleep(300_000) + } + if app.cells["Logs"].exists { + app.cells["Logs"].tap() + } else { + remote.press(.select) + } + #endif + sleep(1) + snapshot("02_Logs") + } + + func test03Settings() throws { + #if os(iOS) + if app.tabBars.buttons["Settings"].exists { + app.tabBars.buttons["Settings"].firstMatch.tap() + } else if app.buttons["Settings"].exists { + app.buttons["Settings"].firstMatch.tap() + } + #elseif os(macOS) + if app.outlines.staticTexts["Settings"].exists { + app.outlines.staticTexts["Settings"].click() + } + #elseif os(tvOS) + let remote = XCUIRemote.shared + remote.press(.down) + usleep(300_000) + if app.cells["Settings"].exists { + app.cells["Settings"].tap() + } else { + remote.press(.select) + } + #endif + sleep(1) + snapshot("03_Settings") + } +} diff --git a/UITests/UITests.swift b/UITests/UITests.swift new file mode 100644 index 0000000..4bdef35 --- /dev/null +++ b/UITests/UITests.swift @@ -0,0 +1,40 @@ +// +// UITests.swift +// UITests +// +// Created by sekai on 2026-01-06 15:19. +// + +import XCTest + +final class UITests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/UITests/UITestsLaunchTests.swift b/UITests/UITestsLaunchTests.swift new file mode 100644 index 0000000..0e245cf --- /dev/null +++ b/UITests/UITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// UITestsLaunchTests.swift +// UITests +// +// Created by sekai on 2026-01-06 15:19. +// + +import XCTest + +final class UITestsLaunchTests: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..e30df53 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,261 @@ +default_platform(:ios) + +ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "0" +ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "30" + +SCREENSHOT_LANGUAGES = ["en-US", "zh-Hans"].freeze +SCREENSHOT_LOCALES = { + "zh-Hans" => "zh_CN" +}.freeze + +def reset_dir(path) + FileUtils.rm_rf(path) + FileUtils.mkdir_p(path) +end + +def screenshot_locale(language) + SCREENSHOT_LOCALES[language] +end + +def write_screenshot_language_files(dir, language, locale) + FileUtils.mkdir_p(dir) + File.write(File.join(dir, "language.txt"), language) + if locale + File.write(File.join(dir, "locale.txt"), locale) + else + FileUtils.rm_f(File.join(dir, "locale.txt")) + end +end + +def shutdown_simulator(name) + sh("xcrun simctl shutdown \"#{name}\" || true") +end + +platform :ios do + desc "Generate iOS screenshots" + lane :screenshots do + ENV["SNAPSHOT_SIMULATOR_WAIT_FOR_BOOT_TIMEOUT"] = "10" + screenshots_cache = File.expand_path("~/Library/Caches/tools.fastlane/screenshots") + reset_dir(screenshots_cache) + capture_screenshots + shutdown_simulator("iPhone 11 Pro Max") + shutdown_simulator("iPad Pro 13-inch (M4)") + end +end + +platform :mac do + desc "Generate macOS screenshots" + lane :screenshots do + output_base_dir = File.expand_path("../screenshots/macos", __dir__) + screenshots_cache_candidates = [ + File.expand_path("~/Library/Caches/tools.fastlane/screenshots"), + File.expand_path("~/Library/Containers/io.nekohasekai.sfavt.SFMUITests.xctrunner/Data/Library/Caches/tools.fastlane/screenshots") + ].uniq + ENV["DISABLE_SWIFTLINT"] = "1" + ENV["SCREENSHOT_WINDOW_PIXEL_HEIGHT"] = "1000" + + reset_dir(output_base_dir) + + SCREENSHOT_LANGUAGES.each do |language| + output_dir = File.join(output_base_dir, language) + ENV["SCREENSHOT_LANGUAGE"] = language + locale = screenshot_locale(language) + if locale + ENV["SCREENSHOT_LOCALE"] = locale + else + ENV.delete("SCREENSHOT_LOCALE") + end + reset_dir(output_dir) + screenshots_cache_candidates.each do |dir| + reset_dir(dir) + write_screenshot_language_files(dir, language, locale) + end + + run_tests( + project: "sing-box.xcodeproj", + scheme: "SFM", + destination: "platform=macOS", + result_bundle: false, + reinstall_app: false, + app_identifier: "io.nekohasekai.sfavt", + only_testing: ["SFMUITests/SnapshotTests"], + number_of_retries: 0 + ) + + copied = {} + screenshots_cache_candidates.each do |dir| + Dir.glob("#{dir}/Mac-*.png").each do |file| + basename = File.basename(file) + next if copied[basename] + FileUtils.cp(file, output_dir) + copied[basename] = true + end + end + + padded_files = Dir.glob("#{output_dir}/*.png") + magick_bin = `which magick 2>/dev/null`.strip + magick_bin = `which convert 2>/dev/null`.strip if magick_bin.empty? + if magick_bin.empty? + UI.user_error!("ImageMagick not found. Install it (magick/convert) to add shadow.") + end + + canvas_width = 2880 + canvas_height = 1800 + target_width = 2620 + target_height = 1620 + shadow_opacity = 26 + + padded_files.each do |file| + size_output = sh( + magick_bin, + file, + "-format", + "%w %h", + "info:", + log: false + ).strip + opaque_output = sh( + magick_bin, + file, + "-format", + "%[opaque]", + "info:", + log: false + ).strip + width, height = size_output.split.map(&:to_i) + has_transparency = opaque_output != "True" + tmp_file = "#{file}.tmp.png" + + if has_transparency + shadow_sigma = 24 + shadow_offset = 8 + shadow = "#{shadow_opacity}x#{shadow_sigma}+0+#{shadow_offset}" + sh( + magick_bin, + file, + "-alpha", "set", + "-background", "none", + "-trim", + "+repage", + "-resize", + "#{target_width}x#{target_height}", + "(", + "+clone", + "-background", + "black", + "-shadow", + shadow, + ")", + "+swap", + "-background", + "white", + "-layers", + "merge", + "+repage", + "-gravity", + "center", + "-extent", + "#{canvas_width}x#{canvas_height}", + tmp_file + ) + else + scale = [ + target_width.to_f / width, + target_height.to_f / height + ].min + scaled_width = (width * scale).round + scaled_height = (height * scale).round + mask_width = [scaled_width - 1, 1].max + mask_height = [scaled_height - 1, 1].max + corner_radius = [(28 * scale).round, 28].max + shadow_sigma = [(16 * scale).round, 1].max + shadow_offset = [(5 * scale).round, 1].max + shadow = "#{shadow_opacity}x#{shadow_sigma}+0+#{shadow_offset}" + sh( + magick_bin, + file, + "-resize", + "#{scaled_width}x#{scaled_height}", + "-alpha", + "set", + "(", + "-size", + "#{scaled_width}x#{scaled_height}", + "xc:none", + "-fill", + "white", + "-draw", + "roundrectangle 0,0 #{mask_width},#{mask_height},#{corner_radius},#{corner_radius}", + ")", + "-compose", + "DstIn", + "-composite", + "-compose", + "over", + "(", + "+clone", + "-background", + "black", + "-shadow", + shadow, + ")", + "+swap", + "-background", + "white", + "-layers", + "merge", + "+repage", + "-gravity", + "center", + "-extent", + "#{canvas_width}x#{canvas_height}", + tmp_file + ) + end + FileUtils.mv(tmp_file, file, force: true) + end + end + end +end + +platform :tvos do + desc "Generate tvOS screenshots" + lane :screenshots do + output_base_dir = File.expand_path("../screenshots/tvos", __dir__) + screenshots_cache = File.expand_path("~/Library/Caches/tools.fastlane/screenshots") + + reset_dir(output_base_dir) + + SCREENSHOT_LANGUAGES.each do |language| + output_dir = File.join(output_base_dir, language) + ENV["SCREENSHOT_LANGUAGE"] = language + locale = screenshot_locale(language) + if locale + ENV["SCREENSHOT_LOCALE"] = locale + else + ENV.delete("SCREENSHOT_LOCALE") + end + reset_dir(output_dir) + reset_dir(screenshots_cache) + write_screenshot_language_files(screenshots_cache, language, locale) + + run_tests( + project: "sing-box.xcodeproj", + scheme: "SFT", + devices: ["Apple TV 4K (3rd generation)"], + result_bundle: false, + reinstall_app: true, + app_identifier: "io.nekohasekai.sfavt", + only_testing: ["SFTUITests/SnapshotTests"], + number_of_retries: 0, + cloned_source_packages_path: "/tmp/fastlane_source_packages", + derived_data_path: "/tmp/fastlane_derived_data" + ) + + Dir.glob("#{screenshots_cache}/*.png").each do |file| + FileUtils.cp(file, output_dir) + end + end + shutdown_simulator("Apple TV 4K (3rd generation)") + end +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..97533a4 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,58 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Generate iOS screenshots + +---- + + +## Mac + +### mac screenshots + +```sh +[bundle exec] fastlane mac screenshots +``` + +Generate macOS screenshots + +---- + + +## tvos + +### tvos screenshots + +```sh +[bundle exec] fastlane tvos screenshots +``` + +Generate tvOS screenshots + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/Snapfile b/fastlane/Snapfile new file mode 100644 index 0000000..e22814d --- /dev/null +++ b/fastlane/Snapfile @@ -0,0 +1,30 @@ +# iOS Screenshots +# App Store requirements: +# - iPhone 6.5" Display +# - iPad 13" Display +devices([ + "iPhone 11 Pro Max", + "iPad Pro 13-inch (M4)" +]) + +languages([ + "en-US", + "zh-Hans" +]) + +project("./sing-box.xcodeproj") +scheme("SFI") +app_identifier("io.nekohasekai.sfavt") +output_directory("./screenshots/ios") +clear_previous_screenshots(true) +reinstall_app(true) +skip_helper_version_check(true) +override_status_bar(true) +dark_mode(true) +number_of_retries(0) +stop_after_first_error(true) +skip_package_dependencies_resolution(true) +disable_package_automatic_updates(true) +cloned_source_packages_path("/tmp/fastlane_source_packages") +derived_data_path("/tmp/fastlane_derived_data") +localize_simulator(true) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index e662e92..cbc8209 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -115,6 +115,27 @@ remoteGlobalIDString = 3A4EAD0F2A4FEAE6005435B3; remoteInfo = ApplicationLibrary; }; + 3A6313782F0CEF3D0060A550 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AEC20F22A459AB400A63465; + remoteInfo = SFI; + }; + 3A63139A2F0CEF740060A550 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AC03B952A72BF3300B7946F; + remoteInfo = SFT; + }; + 3A6313AD2F0CEFDD0060A550 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AEC21082A459B1900A63465; + remoteInfo = SFM; + }; 3A76504A2A4F07F6003945C5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; @@ -419,6 +440,9 @@ 3A3DEBE12A4FFA1A00373BF4 /* ExtensionFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExtensionFoundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.0.sdk/System/Library/Frameworks/ExtensionFoundation.framework; sourceTree = DEVELOPER_DIR; }; 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.0.sdk/System/Library/Frameworks/AppIntents.framework; sourceTree = DEVELOPER_DIR; }; 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ApplicationLibrary.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A6313722F0CEF3D0060A550 /* SFIUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SFIUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A6313942F0CEF740060A550 /* SFTUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SFTUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A6313A72F0CEFDD0060A550 /* SFMUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SFMUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3A77016D2A4E6B34008F031F /* IntentsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = IntentsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3AAAFB202EF5218F004C69AD /* FileProviderExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FileProviderExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3AAAFB212EF5218F004C69AD /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; @@ -584,6 +608,10 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 3A63135E2F0CEF190060A550 /* UITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = UITests; sourceTree = ""; }; + 3A6313732F0CEF3D0060A550 /* SFIUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SFIUITests; sourceTree = ""; }; + 3A6313952F0CEF740060A550 /* SFTUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SFTUITests; sourceTree = ""; }; + 3A6313A82F0CEFDD0060A550 /* SFMUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SFMUITests; sourceTree = ""; }; 3AAAFB232EF5218F004C69AD /* FileProviderExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3AAAFB322EF5218F004C69AD /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FileProviderExtension; sourceTree = ""; }; 3ADDCEB42E8B723B009ACE1D /* SFI */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3ADDCEBC2E8B723B009ACE1D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = SFI; sourceTree = ""; }; 3ADDCEC02E8B7240009ACE1D /* SFM */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3ADDCEC22E8B7240009ACE1D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = SFM; sourceTree = ""; }; @@ -619,6 +647,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3A63136F2F0CEF3D0060A550 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313912F0CEF740060A550 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313A42F0CEFDD0060A550 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3A77016A2A4E6B34008F031F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -764,6 +813,10 @@ 3ADDCFD52E8B72BC009ACE1D /* WidgetExtension */, 3AAAFB232EF5218F004C69AD /* FileProviderExtension */, 3AE595722F08C34000C13426 /* HelperService */, + 3A63135E2F0CEF190060A550 /* UITests */, + 3A6313732F0CEF3D0060A550 /* SFIUITests */, + 3A6313952F0CEF740060A550 /* SFTUITests */, + 3A6313A82F0CEFDD0060A550 /* SFMUITests */, 3AEC20C72A45991900A63465 /* Products */, 3AEC21012A459AE300A63465 /* Frameworks */, 3AE5C9042F08E53400C13426 /* Recovered References */, @@ -787,6 +840,9 @@ 3AE395F22C21A5CA00647718 /* WidgetExtension.appex */, 3AAAFB202EF5218F004C69AD /* FileProviderExtension.appex */, 3AE595712F08C34000C13426 /* RootHelper */, + 3A6313722F0CEF3D0060A550 /* SFIUITests.xctest */, + 3A6313942F0CEF740060A550 /* SFTUITests.xctest */, + 3A6313A72F0CEFDD0060A550 /* SFMUITests.xctest */, ); name = Products; sourceTree = ""; @@ -880,6 +936,75 @@ productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; productType = "com.apple.product-type.framework"; }; + 3A6313712F0CEF3D0060A550 /* SFIUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A63137A2F0CEF3D0060A550 /* Build configuration list for PBXNativeTarget "SFIUITests" */; + buildPhases = ( + 3A63136E2F0CEF3D0060A550 /* Sources */, + 3A63136F2F0CEF3D0060A550 /* Frameworks */, + 3A6313702F0CEF3D0060A550 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3A6313792F0CEF3D0060A550 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3A6313732F0CEF3D0060A550 /* SFIUITests */, + ); + name = SFIUITests; + packageProductDependencies = ( + ); + productName = SFIUITests; + productReference = 3A6313722F0CEF3D0060A550 /* SFIUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 3A6313932F0CEF740060A550 /* SFTUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A63139C2F0CEF740060A550 /* Build configuration list for PBXNativeTarget "SFTUITests" */; + buildPhases = ( + 3A6313902F0CEF740060A550 /* Sources */, + 3A6313912F0CEF740060A550 /* Frameworks */, + 3A6313922F0CEF740060A550 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3A63139B2F0CEF740060A550 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3A6313952F0CEF740060A550 /* SFTUITests */, + ); + name = SFTUITests; + packageProductDependencies = ( + ); + productName = SFTUITests; + productReference = 3A6313942F0CEF740060A550 /* SFTUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 3A6313A62F0CEFDD0060A550 /* SFMUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A6313AF2F0CEFDD0060A550 /* Build configuration list for PBXNativeTarget "SFMUITests" */; + buildPhases = ( + 3A6313A32F0CEFDD0060A550 /* Sources */, + 3A6313A42F0CEFDD0060A550 /* Frameworks */, + 3A6313A52F0CEFDD0060A550 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3A6313AE2F0CEFDD0060A550 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3A6313A82F0CEFDD0060A550 /* SFMUITests */, + ); + name = SFMUITests; + packageProductDependencies = ( + ); + productName = SFMUITests; + productReference = 3A6313A72F0CEFDD0060A550 /* SFMUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 3A77016C2A4E6B34008F031F /* IntentsExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 3A7701772A4E6B34008F031F /* Build configuration list for PBXNativeTarget "IntentsExtension" */; @@ -1181,6 +1306,18 @@ CreatedOnToolsVersion = 15.0; LastSwiftMigration = 1500; }; + 3A6313712F0CEF3D0060A550 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 3AEC20F22A459AB400A63465; + }; + 3A6313932F0CEF740060A550 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 3AC03B952A72BF3300B7946F; + }; + 3A6313A62F0CEFDD0060A550 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 3AEC21082A459B1900A63465; + }; 3A77016C2A4E6B34008F031F = { CreatedOnToolsVersion = 15.0; }; @@ -1257,6 +1394,9 @@ 3AE395F12C21A5CA00647718 /* WidgetExtension */, 3AAAFB1F2EF5218F004C69AD /* FileProviderExtension */, 3AE595702F08C34000C13426 /* RootHelper */, + 3A6313712F0CEF3D0060A550 /* SFIUITests */, + 3A6313932F0CEF740060A550 /* SFTUITests */, + 3A6313A62F0CEFDD0060A550 /* SFMUITests */, ); }; /* End PBXProject section */ @@ -1270,6 +1410,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3A6313702F0CEF3D0060A550 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313922F0CEF740060A550 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313A52F0CEFDD0060A550 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3AAAFB1E2EF5218F004C69AD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1363,6 +1524,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3A63136E2F0CEF3D0060A550 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313902F0CEF740060A550 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A6313A32F0CEFDD0060A550 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3A7701692A4E6B34008F031F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1485,6 +1667,21 @@ target = 3A4EAD0F2A4FEAE6005435B3 /* ApplicationLibrary */; targetProxy = 3A4FB15E2A73468C007012B9 /* PBXContainerItemProxy */; }; + 3A6313792F0CEF3D0060A550 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3AEC20F22A459AB400A63465 /* SFI */; + targetProxy = 3A6313782F0CEF3D0060A550 /* PBXContainerItemProxy */; + }; + 3A63139B2F0CEF740060A550 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3AC03B952A72BF3300B7946F /* SFT */; + targetProxy = 3A63139A2F0CEF740060A550 /* PBXContainerItemProxy */; + }; + 3A6313AE2F0CEFDD0060A550 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3AEC21082A459B1900A63465 /* SFM */; + targetProxy = 3A6313AD2F0CEFDD0060A550 /* PBXContainerItemProxy */; + }; 3A76504B2A4F07F6003945C5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3AEC211C2A459B4700A63465 /* Library */; @@ -1750,6 +1947,138 @@ }; name = Release; }; + 3A63137B2F0CEF3D0060A550 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFIUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SFI; + }; + name = Debug; + }; + 3A63137C2F0CEF3D0060A550 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFIUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SFI; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3A63139D2F0CEF740060A550 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFTUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_TARGET_NAME = SFT; + TVOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 3A63139E2F0CEF740060A550 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFTUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_TARGET_NAME = SFT; + TVOS_DEPLOYMENT_TARGET = 26.2; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3A6313B02F0CEFDD0060A550 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFMUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = SFM; + }; + name = Debug; + }; + 3A6313B12F0CEFDD0060A550 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 287TTNZF8L; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.SFMUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = SFM; + }; + name = Release; + }; 3A7701782A4E6B34008F031F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2851,6 +3180,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3A63137A2F0CEF3D0060A550 /* Build configuration list for PBXNativeTarget "SFIUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A63137B2F0CEF3D0060A550 /* Debug */, + 3A63137C2F0CEF3D0060A550 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3A63139C2F0CEF740060A550 /* Build configuration list for PBXNativeTarget "SFTUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A63139D2F0CEF740060A550 /* Debug */, + 3A63139E2F0CEF740060A550 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3A6313AF2F0CEFDD0060A550 /* Build configuration list for PBXNativeTarget "SFMUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A6313B02F0CEFDD0060A550 /* Debug */, + 3A6313B12F0CEFDD0060A550 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 3A7701772A4E6B34008F031F /* Build configuration list for PBXNativeTarget "IntentsExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..08de0be --- /dev/null +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/sing-box.xcodeproj/xcshareddata/xcschemes/SFI.xcscheme b/sing-box.xcodeproj/xcshareddata/xcschemes/SFI.xcscheme index fb1b583..afd854a 100644 --- a/sing-box.xcodeproj/xcshareddata/xcschemes/SFI.xcscheme +++ b/sing-box.xcodeproj/xcshareddata/xcschemes/SFI.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + + + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + + + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + + + + + +