├── .github ├── example.gif └── workflows │ └── format.yml ├── .gitignore ├── .swift-format ├── Example.swiftpm ├── .swiftpm │ └── xcode │ │ ├── package.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ └── xcschemes │ │ ├── Example-Package.xcscheme │ │ ├── SwiftUIExample.xcscheme │ │ └── UIKitExample.xcscheme ├── Package.swift └── Source │ ├── Shared │ ├── ColorViewController.swift │ ├── CustomDashboardItem.swift │ └── CustomDebugItem.swift │ ├── SwiftUIExampleModule │ ├── App.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ └── Resource │ │ ├── en.lproj │ │ └── Localizable.strings │ │ └── ja.lproj │ │ └── Localizable.strings │ └── UIKItExampleModule │ ├── App.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ └── Resource │ ├── en.lproj │ └── Localizable.strings │ └── ja.lproj │ └── Localizable.strings ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources └── DebugMenu │ ├── DebugMenu.swift │ ├── Entity │ ├── AnyDebugItem.swift │ ├── Application.swift │ ├── DashboardItem.swift │ ├── DebugItem.swift │ ├── Device.swift │ ├── Device │ │ ├── CPU.swift │ │ ├── GPU.swift │ │ ├── Memory.swift │ │ ├── Network.swift │ │ ├── NetworkUsage.swift │ │ └── System.swift │ ├── Envelope.swift │ ├── MetricsFetcher.swift │ ├── Options.swift │ └── RecentItemStore.swift │ ├── Extensions │ ├── Cell+.swift │ ├── CustomActivityController.swift │ ├── FloatingItemGestureRecognizer.swift │ └── L10n.swift │ ├── Plugin │ ├── Dashboard │ │ ├── CPUGraphDashboardItem.swift │ │ ├── CPUUsageDashboardItem.swift │ │ ├── FPSDashboardItem.swift │ │ ├── GPUMemoryUsageDashboardItem.swift │ │ ├── IntervalDashboardItem.swift │ │ ├── MemoryUsageDashboardItem.swift │ │ ├── NetworkUsageDashboardItem.swift │ │ ├── ThermalStateDashboardItem.swift │ │ └── UI │ │ │ ├── GraphTableViewCell.swift │ │ │ ├── GraphView.swift │ │ │ ├── IntervalTableViewCell.swift │ │ │ └── IntervalView.swift │ └── DebugMenu │ │ ├── AppInfoDebugItem.swift │ │ ├── CaseSelectableDebugItem.swift │ │ ├── ClearCacheDebugItem.swift │ │ ├── DebugMenuResult.swift │ │ ├── DeviceInfoDebugItem.swift │ │ ├── ExitDebugItem.swift │ │ ├── GroupDebugItem.swift │ │ ├── KeyValueDebugItem.swift │ │ ├── SliderDebugItem.swift │ │ ├── ToggleDebugItem.swift │ │ ├── UI │ │ ├── CaseSelectableTableController.swift │ │ ├── EnvelopePreviewTableViewController.swift │ │ ├── SliderCell.swift │ │ ├── ToogleCell.swift │ │ └── Value1TableViewCell.swift │ │ ├── UserDefaultsResetDebugItem.swift │ │ └── ViewControllerDebugItem.swift │ ├── Resource │ ├── en.lproj │ │ └── Localizable.strings │ └── ja.lproj │ │ └── Localizable.strings │ ├── SwiftUI │ └── DebugMenuModifier.swift │ └── View │ ├── FloatingViewController.swift │ ├── InAppDebuggerViewController.swift │ ├── InAppDebuggerWindow.swift │ ├── LaunchView.swift │ └── WidgetView.swift └── Tests ├── DebugMenuTests ├── DebugMenuTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.github/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DebugMenu/65188f390f93364e6e7fc9fbe029af733215d054/.github/example.gif -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | swift_format: 11 | name: swift-format 12 | runs-on: macos-11 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Tap 16 | run: brew tap pointfreeco/formulae 17 | - name: Install 18 | run: brew install Formulae/swift-format@5.5 19 | - name: Format 20 | run: make format 21 | - uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Run swift-format 24 | branch: "main" 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | *.xcframework -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "lineLength": 100, 4 | "indentation": { 5 | "spaces": 4 6 | }, 7 | "tabWidth": 4, 8 | "maximumBlankLines": 1, 9 | "respectsExistingLineBreaks": true, 10 | "lineBreakBeforeControlFlowKeywords": false, 11 | "lineBreakBeforeEachArgument": true, 12 | "lineBreakBeforeEachGenericRequirement": true, 13 | "prioritizeKeepingFunctionOutputTogether": false, 14 | "indentConditionalCompilationBlocks": false, 15 | "lineBreakAroundMultilineExpressionChainComponents": true, 16 | "indentSwitchCaseLabels": false, 17 | "fileScopedDeclarationPrivacy": { 18 | "accessLevel": "private" 19 | }, 20 | "rules": { 21 | "AllPublicDeclarationsHaveDocumentation": false, 22 | "AlwaysUseLowerCamelCase": false, 23 | "AmbiguousTrailingClosureOverload": false, 24 | "BeginDocumentationCommentWithOneLineSummary": false, 25 | "DoNotUseSemicolons": true, 26 | "DontRepeatTypeInStaticProperties": false, 27 | "FileScopedDeclarationPrivacy": false, 28 | "FullyIndirectEnum": false, 29 | "GroupNumericLiterals": false, 30 | "IdentifiersMustBeASCII": false, 31 | "NeverForceUnwrap": false, 32 | "NeverUseForceTry": false, 33 | "NeverUseImplicitlyUnwrappedOptionals": false, 34 | "NoAccessLevelOnExtensionDeclaration": false, 35 | "NoBlockComments": false, 36 | "NoCasesWithOnlyFallthrough": false, 37 | "NoEmptyTrailingClosureParentheses": true, 38 | "NoLabelsInCasePatterns": true, 39 | "NoLeadingUnderscores": false, 40 | "NoParensAroundConditions": true, 41 | "NoVoidReturnOnFunctionSignature": true, 42 | "OneCasePerLine": true, 43 | "OneVariableDeclarationPerLine": true, 44 | "OnlyOneTrailingClosureArgument": false, 45 | "OrderedImports": true, 46 | "ReturnVoidInsteadOfEmptyTuple": true, 47 | "UseLetInEveryBoundCaseVariable": false, 48 | "UseShorthandTypeNames": true, 49 | "UseSingleLinePropertyGetter": true, 50 | "UseSynthesizedInitializer": false, 51 | "UseTripleSlashForDocumentationComments": false, 52 | "ValidateDocumentationComments": false 53 | } 54 | } -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/Example-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 71 | 77 | 78 | 79 | 80 | 81 | 86 | 87 | 88 | 89 | 99 | 100 | 106 | 107 | 108 | 109 | 115 | 116 | 122 | 123 | 124 | 125 | 127 | 128 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/SwiftUIExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 66 | 68 | 74 | 75 | 76 | 77 | 79 | 80 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/UIKitExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 67 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /Example.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import PackageDescription 8 | import AppleProductTypes 9 | 10 | let package = Package( 11 | name: "Example", 12 | defaultLocalization: "en", 13 | platforms: [ 14 | .iOS("15.2") 15 | ], 16 | products: [ 17 | .iOSApplication( 18 | name: "SwiftUIExample", 19 | targets: ["SwiftUIExampleModule"], 20 | bundleIdentifier: "dev.noppe.DebugMenu.SwiftUIExample", 21 | teamIdentifier: "FBQ6Z8AF3U", 22 | displayVersion: "1.0", 23 | bundleVersion: "1", 24 | iconAssetName: "AppIcon", 25 | accentColorAssetName: "AccentColor", 26 | supportedDeviceFamilies: [ 27 | .pad, 28 | .phone 29 | ], 30 | supportedInterfaceOrientations: [ 31 | .portrait, 32 | .landscapeRight, 33 | .landscapeLeft, 34 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 35 | ] 36 | ), 37 | .iOSApplication( 38 | name: "UIKitExample", 39 | targets: ["UIKItExampleModule"], 40 | bundleIdentifier: "dev.noppe.DebugMenu.UIKitExample", 41 | teamIdentifier: "FBQ6Z8AF3U", 42 | displayVersion: "1.0", 43 | bundleVersion: "1", 44 | iconAssetName: "AppIcon", 45 | accentColorAssetName: "AccentColor", 46 | supportedDeviceFamilies: [ 47 | .pad, 48 | .phone 49 | ], 50 | supportedInterfaceOrientations: [ 51 | .portrait, 52 | .landscapeRight, 53 | .landscapeLeft, 54 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 55 | ] 56 | ), 57 | ], 58 | dependencies: [ 59 | .package(name: "DebugMenu", path: "../") 60 | ], 61 | targets: [ 62 | .executableTarget( 63 | name: "SwiftUIExampleModule", 64 | dependencies: [ 65 | .productItem(name: "DebugMenu", package: "DebugMenu", condition: nil), 66 | "Shared" 67 | ] 68 | ), 69 | .executableTarget( 70 | name: "UIKItExampleModule", 71 | dependencies: [ 72 | .productItem(name: "DebugMenu", package: "DebugMenu", condition: nil), 73 | "Shared" 74 | ] 75 | ), 76 | .target( 77 | name: "Shared", 78 | dependencies: [ 79 | .productItem(name: "DebugMenu", package: "DebugMenu", condition: nil) 80 | ] 81 | ) 82 | ] 83 | ) 84 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/Shared/ColorViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class ColorViewController: UIViewController { 4 | 5 | private let color: UIColor 6 | 7 | public init(color: UIColor) { 8 | self.color = color 9 | super.init(nibName: nil, bundle: nil) 10 | } 11 | 12 | public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 13 | color = .red 14 | super.init(nibName: nil, bundle: nil) 15 | } 16 | 17 | public required init?(coder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | 21 | public override func viewDidLoad() { 22 | super.viewDidLoad() 23 | view.backgroundColor = color 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/Shared/CustomDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import DebugMenu 2 | import Foundation 3 | 4 | public class CustomDashboardItem: DashboardItem { 5 | public init() {} 6 | public var title: String = "Date" 7 | private var text: String = "" 8 | public func startMonitoring() {} 9 | public func stopMonitoring() {} 10 | public func update() { 11 | let formatter = DateFormatter() 12 | formatter.dateFormat = "HH:mm:ss" 13 | text = formatter.string(from: Date()) 14 | } 15 | public var fetcher: MetricsFetcher { 16 | .text { [weak self] in 17 | self?.text ?? "" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/Shared/CustomDebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import DebugMenu 3 | 4 | public struct CustomDebugItem: DebugItem { 5 | public init() {} 6 | public let debugItemTitle: String = "Custom item" 7 | public let action: DebugItemAction = .toggle { UserDefaults.standard.bool(forKey: "key") } operation: { isOn in 8 | let userDefaults = UserDefaults.standard 9 | userDefaults.set(isOn, forKey: "key") 10 | if userDefaults.synchronize() { 11 | return .success(message: "Switch to \(isOn)") 12 | } else { 13 | return .failure(message: "Failed to save") 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import DebugMenu 3 | import Shared 4 | 5 | @main 6 | struct App: SwiftUI.App { 7 | var body: some Scene { 8 | WindowGroup { 9 | Button(action: { 10 | let tracker = IntervalTracker(name: "dev.noppe.calc") 11 | tracker.track(.begin) 12 | let _ = (0..<10000000).reduce(0, +) 13 | tracker.track(.end) 14 | }, label: { 15 | Text("calculate") 16 | }).debugMenu(debuggerItems: [ 17 | ViewControllerDebugItem(builder: { $0.init(color: .blue) }), 18 | ClearCacheDebugItem(), 19 | UserDefaultsResetDebugItem(), 20 | CustomDebugItem(), 21 | SliderDebugItem(title: "Attack Rate", current: { 0.1 }, range: 0.0...100.0, onChange: { value in print(value) }), 22 | KeyValueDebugItem(title: "UserDefaults", fetcher: { 23 | let envelops = UserDefaults.standard.dictionaryRepresentation().map({ Envelope(key: $0.key, value: "\($0.value)") }) 24 | return envelops 25 | }), 26 | GroupDebugItem(title: "Info", items: [ 27 | AppInfoDebugItem(), 28 | DeviceInfoDebugItem(), 29 | ]), 30 | ], dashboardItems: [ 31 | CPUUsageDashboardItem(), 32 | CPUGraphDashboardItem(), 33 | GPUMemoryUsageDashboardItem(), 34 | MemoryUsageDashboardItem(), 35 | NetworkUsageDashboardItem(), 36 | FPSDashboardItem(), 37 | ThermalStateDashboardItem(), 38 | CustomDashboardItem(), 39 | IntervalDashboardItem(title: "Reduce time", name: "dev.noppe.calc") 40 | ], options: [.showsWidgetOnLaunch]) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/Resource/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DebugMenu/65188f390f93364e6e7fc9fbe029af733215d054/Example.swiftpm/Source/SwiftUIExampleModule/Resource/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /Example.swiftpm/Source/SwiftUIExampleModule/Resource/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DebugMenu/65188f390f93364e6e7fc9fbe029af733215d054/Example.swiftpm/Source/SwiftUIExampleModule/Resource/ja.lproj/Localizable.strings -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/App.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import DebugMenu 3 | import Shared 4 | 5 | @main 6 | class AppDelegate: NSObject, UIApplicationDelegate { 7 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 8 | return true 9 | } 10 | 11 | func application( 12 | _ application: UIApplication, 13 | configurationForConnecting connectingSceneSession: UISceneSession, 14 | options: UIScene.ConnectionOptions 15 | ) -> UISceneConfiguration { 16 | let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) 17 | configuration.sceneClass = UIWindowScene.self 18 | configuration.delegateClass = SceneDelegate.self 19 | return configuration 20 | } 21 | } 22 | 23 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 24 | var window: UIWindow? 25 | 26 | func scene( 27 | _ scene: UIScene, willConnectTo session: UISceneSession, 28 | options connectionOptions: UIScene.ConnectionOptions 29 | ) { 30 | guard let windowScene = (scene as? UIWindowScene) else { return } 31 | window = UIWindow(windowScene: windowScene) 32 | window?.rootViewController = RootViewController() 33 | window?.makeKeyAndVisible() 34 | 35 | DebugMenu.install( 36 | windowScene: windowScene, 37 | items: [ 38 | AlertDebugMenu(), 39 | ViewControllerDebugItem(builder: { $0.init(color: .blue) }), 40 | ClearCacheDebugItem(), 41 | UserDefaultsResetDebugItem(), 42 | CustomDebugItem(), 43 | SliderDebugItem(title: "Attack Rate", current: { 0.1 }, range: 0.0...100.0, onChange: { value in print(value) }), 44 | KeyValueDebugItem(title: "UserDefaults", fetcher: { 45 | return UserDefaults.standard.dictionaryRepresentation().map({ Envelope(key: $0.key, value: "\($0.value)") }) 46 | }), 47 | GroupDebugItem(title: "Info", items: [ 48 | AppInfoDebugItem(), 49 | DeviceInfoDebugItem(), 50 | ]), 51 | ], dashboardItems: [ 52 | CPUUsageDashboardItem(), 53 | CPUGraphDashboardItem(), 54 | GPUMemoryUsageDashboardItem(), 55 | MemoryUsageDashboardItem(), 56 | NetworkUsageDashboardItem(), 57 | FPSDashboardItem(), 58 | ThermalStateDashboardItem(), 59 | CustomDashboardItem(), 60 | IntervalDashboardItem(title: "Reduce time", name: "dev.noppe.calc") 61 | ], options: [.showsWidgetOnLaunch] 62 | ) 63 | } 64 | } 65 | 66 | class RootViewController: UIViewController { 67 | override func viewDidLoad() { 68 | super.viewDidLoad() 69 | view.backgroundColor = .systemBackground 70 | } 71 | 72 | override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { .all } 73 | } 74 | 75 | struct AlertDebugMenu: DebugItem { 76 | var debugItemTitle: String = "Alert" 77 | var action: DebugItemAction = .didSelect { @MainActor controller in 78 | let alert = UIAlertController(title: "Input Text", message: nil, preferredStyle: .alert) 79 | alert.addTextField() 80 | alert.addAction(.init(title: "OK", style: .default)) 81 | controller.present(alert, animated: true) 82 | return .success() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/Resource/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DebugMenu/65188f390f93364e6e7fc9fbe029af733215d054/Example.swiftpm/Source/UIKItExampleModule/Resource/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /Example.swiftpm/Source/UIKItExampleModule/Resource/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DebugMenu/65188f390f93364e6e7fc9fbe029af733215d054/Example.swiftpm/Source/UIKItExampleModule/Resource/ja.lproj/Localizable.strings -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 noppe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | @swift format \ 3 | --ignore-unparsable-files \ 4 | --configuration .swift-format \ 5 | --in-place \ 6 | --recursive \ 7 | ./Sources/ -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DebugMenu", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v14)], 10 | products: [ 11 | .library( 12 | name: "DebugMenu", 13 | targets: ["DebugMenu"] 14 | ), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "DebugMenu", 19 | resources: [ 20 | .process("Resource"), 21 | ] 22 | ), 23 | .testTarget( 24 | name: "DebugMenuTests", 25 | dependencies: ["DebugMenu"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > [!WARNING] 3 | > DebugMenu will be archive soon. 4 | > Please try new [noppefoxwolf/ContentLauncher](https://github.com/noppefoxwolf/ContentLauncher). 5 | 6 | # DebugMenu 7 | 8 | ![](https://github.com/noppefoxwolf/DebugMenu/blob/main/.github/example.gif) 9 | 10 | ## Installation 11 | 12 | ### Swift Package Manager 13 | 14 | Select File > Swift Packages > Add Package Dependency. 15 | 16 | Enter https://github.com/noppefoxwolf/DebugMenu in the "Choose Package Repository" dialog. 17 | 18 | ```swift 19 | .package(url: "https://github.com/noppefoxwolf/DebugMenu", from: "2.0.4") 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### UIKit based 25 | 26 | ```swift 27 | #if DEBUG 28 | DebugMenu.install(windowScene: windowScene, items: [ 29 | ViewControllerDebugItem(), 30 | ClearCacheDebugItem(), 31 | UserDefaultsResetDebugItem(), 32 | CustomDebugItem() 33 | ], dashboardItems: [ 34 | CPUUsageDashboardItem() 35 | ]) 36 | #endif 37 | ``` 38 | 39 | ### SwiftUI based 40 | 41 | ```swift 42 | @main 43 | struct App: SwiftUI.App { 44 | var body: some Scene { 45 | WindowGroup { 46 | Root.View( 47 | store: .init( 48 | initialState: .init(), 49 | reducer: Root.reducer, 50 | environment: .debug 51 | ) 52 | ).debugMenu(debuggerItems: [ 53 | ViewControllerDebugItem(), 54 | ClearCacheDebugItem(), 55 | UserDefaultsResetDebugItem(), 56 | CustomDebugItem() 57 | ], dashboardItems: [ 58 | CPUUsageDashboardItem() 59 | ]) 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | ## Custom debug item 66 | 67 | ```swift 68 | struct CustomDebugItem: DebugItem { 69 | let debugItemTitle: String = "Custom item" 70 | let action: DebugItemAction = .toggle { UserDefaults.standard.bool(forKey: "key") } action: { (isOn, completions) in 71 | let updater = Updater() 72 | do { 73 | await updater.update() 74 | return .success(message: "Updated") 75 | } catch { 76 | return .failure(message: "Faild to update") 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | ## Custom dashboard item 83 | 84 | ```swift 85 | public class CustomDashboardItem: DashboardItem { 86 | public init() {} 87 | public func startMonitoring() {} 88 | public func stopMonitoring() {} 89 | public let fetcher: MetricsFetcher = .text { 90 | let formatter = DateFormatter() 91 | formatter.dateFormat = "HH:mm:ss" 92 | return formatter.string(from: Date()) 93 | } 94 | public var title: String = "Date" 95 | } 96 | ``` 97 | 98 | # Exclude DebugMenu in production 99 | 100 | Read following article. 101 | [Linking a Swift package only in debug builds](https://augmentedcode.io/2022/05/02/linking-a-swift-package-only-in-debug-builds/) 102 | 103 | # How to use 104 | 105 | ## Open DebugMenu 106 | 107 | Tap floating bug button. 108 | 109 | ## Show Dashboard 110 | 111 | Longpress floating bug button, and tap `Show widget`. 112 | 113 | # License 114 | 115 | License 116 | DebugMenu is released under the MIT license. See LICENSE for details. 117 | -------------------------------------------------------------------------------- /Sources/DebugMenu/DebugMenu.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @available(iOSApplicationExtension, unavailable) 4 | public struct DebugMenu { 5 | public static func install( 6 | windowScene: UIWindowScene? = nil, 7 | items: [DebugItem] = [], 8 | dashboardItems: [DashboardItem] = [], 9 | options: [Options] = Options.default 10 | ) { 11 | InAppDebuggerWindow.install( 12 | windowScene: windowScene, 13 | debuggerItems: items, 14 | dashboardItems: dashboardItems, 15 | options: options 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/AnyDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AnyDebugItem: Hashable, Identifiable, DebugItem { 4 | let id: String 5 | let debugItemTitle: String 6 | let action: DebugItemAction 7 | 8 | init(_ item: DebugItem) { 9 | id = UUID().uuidString 10 | debugItemTitle = item.debugItemTitle 11 | action = item.action 12 | } 13 | 14 | func hash(into hasher: inout Hasher) { 15 | hasher.combine(id) 16 | } 17 | 18 | static func == (lhs: AnyDebugItem, rhs: AnyDebugItem) -> Bool { 19 | lhs.id == rhs.id 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Application.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class Application { 4 | public static var current: Application = .init() 5 | 6 | public var appName: String { 7 | Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String 8 | } 9 | 10 | public var version: String { 11 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String 12 | } 13 | 14 | public var build: String { 15 | Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as! String 16 | } 17 | 18 | public var buildNumber: Int { 19 | Int(build) ?? 0 20 | } 21 | 22 | public var bundleIdentifier: String { 23 | Bundle.main.bundleIdentifier ?? "" 24 | } 25 | 26 | public var locale: String { 27 | Locale.current.identifier 28 | } 29 | 30 | public var preferredLocalizations: String { 31 | Bundle.main.preferredLocalizations.joined(separator: ",") 32 | } 33 | 34 | public var isTestFlight: Bool { 35 | #if DEBUG 36 | return false 37 | #else 38 | return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" 39 | #endif 40 | } 41 | 42 | public var size: String { 43 | let byteCount = try? getByteCount() 44 | let formatter = ByteCountFormatter() 45 | formatter.countStyle = .file 46 | formatter.allowsNonnumericFormatting = false 47 | return formatter.string(fromByteCount: Int64(byteCount ?? 0)) 48 | } 49 | } 50 | 51 | extension Application { 52 | public func getByteCount() throws -> UInt64 { 53 | let bundlePath = Bundle.main.bundlePath 54 | let documentPath = NSSearchPathForDirectoriesInDomains( 55 | .documentDirectory, 56 | .userDomainMask, 57 | true 58 | )[0] 59 | let libraryPath = NSSearchPathForDirectoriesInDomains( 60 | .libraryDirectory, 61 | .userDomainMask, 62 | true 63 | )[0] 64 | let tmpPath = NSTemporaryDirectory() 65 | return try [bundlePath, documentPath, libraryPath, tmpPath].map(getFileSize(atDirectory:)) 66 | .reduce(0, +) 67 | } 68 | 69 | internal func getFileSize(atDirectory path: String) throws -> UInt64 { 70 | let files = try FileManager.default.subpathsOfDirectory(atPath: path) 71 | var fileSize: UInt64 = 0 72 | for file in files { 73 | let attributes = try FileManager.default.attributesOfItem(atPath: "\(path)/\(file)") 74 | fileSize += attributes[.size] as! UInt64 75 | } 76 | return fileSize 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/DashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DashboardItem { 4 | var title: String { get } 5 | func startMonitoring() 6 | func stopMonitoring() 7 | func update() 8 | var fetcher: MetricsFetcher { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/DebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol DebugItem { 4 | var debugItemTitle: String { get } 5 | var action: DebugItemAction { get } 6 | } 7 | 8 | public enum DebugItemAction { 9 | case didSelect( 10 | operation: (_ controller: UIViewController) async -> DebugMenuResult 11 | ) 12 | case execute(_ operation: () async -> DebugMenuResult) 13 | case toggle( 14 | current: () -> Bool, 15 | operation: (_ isOn: Bool) async -> DebugMenuResult 16 | ) 17 | case slider( 18 | current: () -> Double, 19 | valueLabelText: (Double) -> String, 20 | range: ClosedRange, 21 | operation: (_ value: Double) async -> DebugMenuResult 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class Device { 4 | public static let current: Device = .init() 5 | 6 | public var localizedModel: String { 7 | UIDevice.current.localizedModel 8 | } 9 | 10 | public var model: String { 11 | UIDevice.current.model 12 | } 13 | 14 | public var name: String { 15 | UIDevice.current.name 16 | } 17 | 18 | public var systemName: String { 19 | UIDevice.current.systemName 20 | } 21 | 22 | public var systemVersion: String { 23 | UIDevice.current.systemVersion 24 | } 25 | 26 | public var localizedBatteryLevel: String { 27 | "\(batteryLevel * 100.00) %" 28 | } 29 | 30 | public var batteryLevel: Float { 31 | UIDevice.current.batteryLevel 32 | } 33 | 34 | public var batteryState: UIDevice.BatteryState { 35 | UIDevice.current.batteryState 36 | } 37 | 38 | public var localizedBatteryState: String { 39 | switch batteryState { 40 | case .unknown: return "unknown" 41 | case .unplugged: return "unplugged" 42 | case .charging: return "charging" 43 | case .full: return "full" 44 | @unknown default: return "default" 45 | } 46 | } 47 | 48 | public var isJailbreaked: Bool { 49 | FileManager.default.fileExists(atPath: "/private/var/lib/apt") 50 | } 51 | 52 | public var thermalState: ProcessInfo.ThermalState { 53 | ProcessInfo.processInfo.thermalState 54 | } 55 | 56 | public var localizedThermalState: String { 57 | switch thermalState { 58 | case .nominal: return "nominal" 59 | case .fair: return "fair" 60 | case .serious: return "serious" 61 | case .critical: return "critical" 62 | @unknown default: return "default" 63 | } 64 | } 65 | 66 | public var processorCount: Int { 67 | ProcessInfo.processInfo.processorCount 68 | } 69 | 70 | public var activeProcessorCount: Int { 71 | ProcessInfo.processInfo.activeProcessorCount 72 | } 73 | 74 | public var processor: String { 75 | "\(activeProcessorCount) / \(processorCount)" 76 | } 77 | 78 | public var isLowPowerModeEnabled: Bool { 79 | ProcessInfo.processInfo.isLowPowerModeEnabled 80 | } 81 | 82 | public var physicalMemory: UInt64 { 83 | ProcessInfo.processInfo.physicalMemory 84 | } 85 | 86 | public var localizedPhysicalMemory: String { 87 | let formatter = ByteCountFormatter() 88 | formatter.countStyle = .memory 89 | formatter.allowsNonnumericFormatting = false 90 | return formatter.string(fromByteCount: Int64(physicalMemory)) 91 | } 92 | 93 | // without sleep time 94 | public var systemUptime: TimeInterval { 95 | ProcessInfo.processInfo.systemUptime 96 | } 97 | 98 | // include sleep time 99 | public func uptime() -> time_t { 100 | System.uptime() 101 | } 102 | 103 | public var localizedSystemUptime: String { 104 | let formatter = DateComponentsFormatter() 105 | formatter.unitsStyle = .brief 106 | formatter.allowedUnits = [.day, .hour, .minute, .second] 107 | return formatter.string(from: systemUptime) ?? "-" 108 | } 109 | 110 | public var localizedUptime: String { 111 | let formatter = DateComponentsFormatter() 112 | formatter.unitsStyle = .brief 113 | formatter.allowedUnits = [.day, .hour, .minute, .second] 114 | return formatter.string(from: TimeInterval(uptime())) ?? "-" 115 | } 116 | 117 | public var diskTotalSpace: Int64 { 118 | if let attributes = try? FileManager.default.attributesOfFileSystem( 119 | forPath: NSHomeDirectory() 120 | ) { 121 | return attributes[.systemSize] as! Int64 122 | } else { 123 | return 0 124 | } 125 | } 126 | 127 | public var diskFreeSpace: Int64 { 128 | if let attributes = try? FileManager.default.attributesOfFileSystem( 129 | forPath: NSHomeDirectory() 130 | ) { 131 | return attributes[.systemFreeSize] as! Int64 132 | } else { 133 | return 0 134 | } 135 | } 136 | 137 | public var diskUsage: Int64 { 138 | diskTotalSpace - diskFreeSpace 139 | } 140 | 141 | public var localizedDiskUsage: String { 142 | let formatter = ByteCountFormatter() 143 | formatter.countStyle = .file 144 | formatter.allowsNonnumericFormatting = false 145 | return 146 | "\(formatter.string(fromByteCount: diskUsage)) / \(formatter.string(fromByteCount: diskTotalSpace))" 147 | } 148 | 149 | public var localizedMemoryUsage: String { 150 | let formatter = ByteCountFormatter() 151 | formatter.countStyle = .memory 152 | formatter.allowsNonnumericFormatting = false 153 | return formatter.string(fromByteCount: Int64(memoryUsage())) 154 | } 155 | 156 | public func memoryUsage() -> UInt64 { 157 | Memory.usage() 158 | } 159 | 160 | public var localizedCPUUsage: String { 161 | String(format: "%.1f%%", cpuUsage() * 100.0) 162 | } 163 | 164 | public func cpuUsage() -> Double { 165 | CPU.usage() 166 | } 167 | 168 | public func networkUsage() -> NetworkUsage? { 169 | Network.usage() 170 | } 171 | 172 | public var localizedGPUMemory: String { 173 | let formatter = ByteCountFormatter() 174 | formatter.countStyle = .memory 175 | formatter.allowsNonnumericFormatting = false 176 | return formatter.string(fromByteCount: Int64(GPU.current.currentAllocatedSize)) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/CPU.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class CPU { 4 | static func usage() -> Double { 5 | let ids = threadIDs() 6 | var totalUsage: Double = 0 7 | for id in ids { 8 | let usage = threadUsage(id: id) 9 | totalUsage += usage 10 | } 11 | return totalUsage 12 | } 13 | 14 | static func threadIDs() -> [thread_inspect_t] { 15 | var threadList: thread_act_array_t? 16 | var threadCount = UInt32( 17 | MemoryLayout.size / MemoryLayout.size 18 | ) 19 | let result = task_threads(mach_task_self_, &threadList, &threadCount) 20 | if result != KERN_SUCCESS { return [] } 21 | var ids: [thread_inspect_t] = [] 22 | for index in (0.. Double { 29 | var threadInfo = thread_basic_info() 30 | var threadInfoCount = UInt32(THREAD_INFO_MAX) 31 | let result = withUnsafeMutablePointer(to: &threadInfo) { 32 | $0.withMemoryRebound(to: integer_t.self, capacity: 1) { 33 | thread_info(id, UInt32(THREAD_BASIC_INFO), $0, &threadInfoCount) 34 | } 35 | } 36 | // スレッド情報が取れない = 該当スレッドのCPU使用率を0とみなす(基本nilが返ることはない) 37 | if result != KERN_SUCCESS { return 0 } 38 | let isIdle = threadInfo.flags == TH_FLAGS_IDLE 39 | // CPU使用率がスケール調整済みのため`TH_USAGE_SCALE`で除算し戻す 40 | return !isIdle ? Double(threadInfo.cpu_usage) / Double(TH_USAGE_SCALE) : 0 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/GPU.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Metal 3 | 4 | class GPU { 5 | static var current: GPU = .init() 6 | let device: MTLDevice 7 | 8 | init() { 9 | device = MTLCreateSystemDefaultDevice()! 10 | } 11 | 12 | var currentAllocatedSize: Int { 13 | device.currentAllocatedSize 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/Memory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class Memory { 4 | static func usage() -> UInt64 { 5 | var info = mach_task_basic_info() 6 | var count = UInt32(MemoryLayout.size(ofValue: info) / MemoryLayout.size) 7 | let result = withUnsafeMutablePointer(to: &info) { 8 | task_info( 9 | mach_task_self_, 10 | task_flavor_t(MACH_TASK_BASIC_INFO), 11 | $0.withMemoryRebound(to: Int32.self, capacity: 1) { 12 | UnsafeMutablePointer($0) 13 | }, 14 | &count 15 | ) 16 | } 17 | return result == KERN_SUCCESS ? info.resident_size : 0 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/Network.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class Network { 4 | private static func ifaddrs() -> [String] { 5 | var addresses = [String]() 6 | 7 | var ifaddr: UnsafeMutablePointer? 8 | guard getifaddrs(&ifaddr) == 0 else { return [] } 9 | guard let firstAddr = ifaddr else { return [] } 10 | 11 | for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) { 12 | let flags = Int32(ptr.pointee.ifa_flags) 13 | var addr = ptr.pointee.ifa_addr.pointee 14 | if (flags & (IFF_UP | IFF_RUNNING | IFF_LOOPBACK)) == (IFF_UP | IFF_RUNNING) { 15 | if addr.sa_family == UInt8(AF_INET) || addr.sa_family == UInt8(AF_INET6) { 16 | var hostname: [CChar] = Array.init(repeating: 0, count: Int(NI_MAXHOST)) 17 | if getnameinfo( 18 | &addr, 19 | socklen_t(addr.sa_len), 20 | &hostname, 21 | socklen_t(hostname.count), 22 | nil, 23 | socklen_t(0), 24 | NI_NUMERICHOST 25 | ) == 0 { 26 | let address = String(cString: hostname) 27 | addresses.append(address) 28 | } 29 | } 30 | } 31 | } 32 | freeifaddrs(ifaddr) 33 | return addresses 34 | } 35 | 36 | static func usage() -> NetworkUsage? { 37 | var ifaddr: UnsafeMutablePointer? 38 | guard getifaddrs(&ifaddr) == 0 else { return nil } 39 | guard let firstAddr = ifaddr else { return nil } 40 | 41 | var networkData: UnsafeMutablePointer! 42 | 43 | var wifiDataSent: UInt64 = 0 44 | var wifiDataReceived: UInt64 = 0 45 | var wwanDataSent: UInt64 = 0 46 | var wwanDataReceived: UInt64 = 0 47 | 48 | for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) { 49 | let name = String(cString: ptr.pointee.ifa_name) 50 | let addr = ptr.pointee.ifa_addr.pointee 51 | 52 | guard addr.sa_family == UInt8(AF_LINK) else { 53 | continue 54 | } 55 | 56 | if name.hasPrefix("en") { 57 | networkData = unsafeBitCast( 58 | ptr.pointee.ifa_data, 59 | to: UnsafeMutablePointer.self 60 | ) 61 | wifiDataSent += UInt64(networkData.pointee.ifi_obytes) 62 | wifiDataReceived += UInt64(networkData.pointee.ifi_ibytes) 63 | } 64 | 65 | if name.hasPrefix("pdp_ip") { 66 | networkData = unsafeBitCast( 67 | ptr.pointee.ifa_data, 68 | to: UnsafeMutablePointer.self 69 | ) 70 | wwanDataSent += UInt64(networkData.pointee.ifi_obytes) 71 | wwanDataReceived += UInt64(networkData.pointee.ifi_ibytes) 72 | } 73 | } 74 | freeifaddrs(ifaddr) 75 | 76 | return .init( 77 | wifiDataSent: wifiDataSent, 78 | wifiDataReceived: wifiDataReceived, 79 | wwanDataSent: wwanDataSent, 80 | wwanDataReceived: wwanDataReceived 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/NetworkUsage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct NetworkUsage { 4 | public let wifiDataSent: UInt64 5 | public let wifiDataReceived: UInt64 6 | public let wwanDataSent: UInt64 7 | public let wwanDataReceived: UInt64 8 | 9 | public var sent: UInt64 { wifiDataSent + wwanDataSent } 10 | public var received: UInt64 { wifiDataReceived + wwanDataReceived } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Device/System.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class System { 4 | static func uptime() -> time_t { 5 | var boottime = timeval() 6 | var mib: [Int32] = [CTL_KERN, KERN_BOOTTIME] 7 | var size = MemoryLayout.stride 8 | 9 | var now = time_t() 10 | var uptime: time_t = -1 11 | 12 | time(&now) 13 | if sysctl(&mib, 2, &boottime, &size, nil, 0) != -1 && boottime.tv_sec != 0 { 14 | uptime = now - boottime.tv_sec 15 | } 16 | return uptime 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Envelope.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Envelope { 4 | public init(key: String, value: String) { 5 | self.key = key 6 | self.value = value 7 | } 8 | 9 | public let key: String 10 | public let value: String 11 | } 12 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/MetricsFetcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MetricsFetcher { 4 | case text(_ fetcher: () -> String) 5 | case graph(_ fetcher: () -> [Double]) 6 | case interval(_ fetcher: () -> [TimeInterval]) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/Options.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public enum Options { 5 | case showsWidgetOnLaunch 6 | case showsRecentItems 7 | case launchIcon(LaunchIcon) 8 | 9 | public struct LaunchIcon { 10 | public typealias Position = FloatingItemGestureRecognizer.Edge 11 | 12 | let image: UIImage? 13 | let initialPosition: Position 14 | 15 | public init( 16 | image: UIImage? = nil, 17 | initialPosition: Position = .bottomTrailing 18 | ) { 19 | self.image = image 20 | self.initialPosition = initialPosition 21 | } 22 | } 23 | 24 | public static var `default`: [Options] = [.showsRecentItems] 25 | 26 | var isShowsWidgetOnLaunch: Bool { 27 | if case .showsWidgetOnLaunch = self { return true } 28 | return false 29 | } 30 | 31 | var isShowsRecentItems: Bool { 32 | if case .showsRecentItems = self { return true } 33 | return false 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Entity/RecentItemStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct RecentItemStore { 4 | let key: String = "dev.noppe.debugmenu.recent-item-names" 5 | let items: [AnyDebugItem] 6 | let maxCount: Int = 3 7 | 8 | func get() -> [AnyDebugItem] { 9 | let titles = UserDefaults.standard.stringArray(forKey: key)?.prefix(maxCount) ?? [] 10 | return titles.compactMap({ title in items.first(where: { $0.debugItemTitle == title }) }) 11 | .map(AnyDebugItem.init) 12 | } 13 | 14 | func insert(_ item: AnyDebugItem) { 15 | var titles = UserDefaults.standard.stringArray(forKey: key) ?? [] 16 | titles.removeAll(where: { $0 == item.debugItemTitle }) 17 | titles.insert(item.debugItemTitle, at: 0) 18 | UserDefaults.standard.set(titles.prefix(maxCount).map({ $0 }), forKey: key) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Extensions/Cell+.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public extension UITableView { 4 | func register(_ cellClass: T.Type) { 5 | register(cellClass, forCellReuseIdentifier: NSStringFromClass(cellClass)) 6 | } 7 | 8 | func dequeue(_ cellClass: T.Type, for indexPath: IndexPath) -> T { 9 | dequeueReusableCell(withIdentifier: NSStringFromClass(cellClass), for: indexPath) as! T 10 | } 11 | } 12 | 13 | public extension UICollectionView { 14 | func register(_ cellClass: T.Type) { 15 | register(cellClass, forCellWithReuseIdentifier: String(describing: cellClass)) 16 | } 17 | 18 | func register(_ cellClass: T.Type) { 19 | register( 20 | cellClass, 21 | forSupplementaryViewOfKind: cellClass.elementKind, 22 | withReuseIdentifier: String(describing: cellClass) 23 | ) 24 | } 25 | 26 | func dequeue(_ cellClass: T.Type, for indexPath: IndexPath) -> T { 27 | dequeueReusableCell(withReuseIdentifier: String(describing: cellClass), for: indexPath) 28 | as! T 29 | } 30 | 31 | func dequeue(_ cellClass: T.Type, for indexPath: IndexPath) -> T { 32 | dequeueReusableSupplementaryView( 33 | ofKind: cellClass.elementKind, 34 | withReuseIdentifier: String(describing: cellClass), 35 | for: indexPath 36 | ) as! T 37 | } 38 | } 39 | 40 | public extension UICollectionReusableView { 41 | static var elementKind: String { 42 | String(describing: Self.self) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Extensions/CustomActivityController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class CustomActivityViewController: UIActivityViewController { 4 | 5 | private let controller: UIViewController 6 | 7 | required init(controller: UIViewController) { 8 | self.controller = controller 9 | super.init(activityItems: [], applicationActivities: nil) 10 | } 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | 15 | let subViews = self.view.subviews 16 | for view in subViews { 17 | view.removeFromSuperview() 18 | } 19 | 20 | self.addChild(controller) 21 | self.view.addSubview(controller.view) 22 | } 23 | 24 | override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { 25 | super.dismiss(animated: flag, completion: completion) 26 | guard let presentationController = presentationController else { 27 | return 28 | } 29 | presentationController.delegate?.presentationControllerDidDismiss?(presentationController) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Extensions/FloatingItemGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class FloatingItemGestureRecognizer: UIPanGestureRecognizer { 4 | private weak var groundView: UIView? 5 | private var gestureGap: CGPoint? 6 | private let margin: CGFloat = 16 7 | public enum Edge { 8 | case top 9 | // case center 10 | case bottom 11 | 12 | case topLeading 13 | case topTrailing 14 | 15 | case leading 16 | case trailing 17 | 18 | case bottomLeading 19 | case bottomTrailing 20 | } 21 | 22 | public init(groundView: UIView) { 23 | super.init(target: nil, action: nil) 24 | self.addTarget(self, action: #selector(pan(_:))) 25 | self.groundView = groundView 26 | 27 | } 28 | 29 | deinit { 30 | self.removeTarget(self, action: #selector(pan(_:))) 31 | } 32 | 33 | public func moveInitialPosition(_ edge: Edge) { 34 | DispatchQueue.main.async { [weak self] in 35 | self?.view?.center = self?.cornerPositions()[edge] ?? .zero 36 | UIView.animate( 37 | withDuration: 0.2, 38 | animations: { [weak self] in 39 | self?.view?.alpha = 1.0 40 | } 41 | ) 42 | } 43 | } 44 | 45 | @objc private func pan(_ gesture: UIPanGestureRecognizer) { 46 | guard let targetView = self.view else { return } 47 | guard let groundView = groundView else { return } 48 | switch gesture.state { 49 | case .began: 50 | // ジェスチャ座標とオブジェクトの中心座標までの“ギャップ”を計算 51 | 52 | let location = gesture.location(in: groundView) 53 | let gap = CGPoint( 54 | x: targetView.center.x - location.x, 55 | y: targetView.center.y - location.y 56 | ) 57 | self.gestureGap = gap 58 | 59 | case .ended: 60 | let lastObjectLocation = targetView.center 61 | let velocity = gesture.velocity(in: groundView) // points per second 62 | 63 | // 仮想の移動先を計算 64 | let projectedPosition = CGPoint( 65 | x: lastObjectLocation.x 66 | + project(initialVelocity: velocity.x, decelerationRate: .fast), 67 | y: lastObjectLocation.y 68 | + project(initialVelocity: velocity.y, decelerationRate: .fast) 69 | ) 70 | // 最適な移動先を計算 71 | let destination = nearestCornerPosition(projectedPosition) 72 | 73 | let initialVelocity = initialAnimationVelocity( 74 | for: velocity, 75 | from: self.view!.center, 76 | to: destination 77 | ) 78 | 79 | // iOSの一般的な動きに近い動きを再現 80 | let parameters = UISpringTimingParameters( 81 | dampingRatio: 0.5, 82 | initialVelocity: initialVelocity 83 | ) 84 | let animator = UIViewPropertyAnimator(duration: 1.0, timingParameters: parameters) 85 | 86 | animator.addAnimations { 87 | self.view!.center = destination 88 | } 89 | animator.startAnimation() 90 | 91 | self.gestureGap = nil 92 | 93 | default: 94 | // ジェスチャに合わせてオブジェクトをドラッグ 95 | 96 | let gestureGap = self.gestureGap ?? CGPoint.zero 97 | let location = gesture.location(in: groundView) 98 | let destination = CGPoint(x: location.x + gestureGap.x, y: location.y + gestureGap.y) 99 | self.view!.center = destination 100 | 101 | } 102 | } 103 | 104 | // アニメーション開始時の変化率を計算 105 | private func initialAnimationVelocity( 106 | for gestureVelocity: CGPoint, 107 | from currentPosition: CGPoint, 108 | to finalPosition: CGPoint 109 | ) -> CGVector { 110 | // https://developer.apple.com/documentation/uikit/uispringtimingparameters/1649909-initialvelocity 111 | 112 | var animationVelocity = CGVector.zero 113 | let xDistance = finalPosition.x - currentPosition.x 114 | let yDistance = finalPosition.y - currentPosition.y 115 | 116 | if xDistance != 0 { 117 | animationVelocity.dx = gestureVelocity.x / xDistance 118 | } 119 | if yDistance != 0 { 120 | animationVelocity.dy = gestureVelocity.y / yDistance 121 | } 122 | 123 | return animationVelocity 124 | } 125 | 126 | // 仮想の移動先を計算 127 | private func project(initialVelocity: CGFloat, decelerationRate: UIScrollView.DecelerationRate) 128 | -> CGFloat 129 | { 130 | // https://developer.apple.com/videos/play/wwdc2018/803/ 131 | 132 | return (initialVelocity / 1000.0) * decelerationRate.rawValue 133 | / (1.0 - decelerationRate.rawValue) 134 | } 135 | 136 | // 引数にもっとも近い位置を返す 137 | private func nearestCornerPosition(_ projectedPosition: CGPoint) -> CGPoint { 138 | let destinations = cornerPositions() 139 | let nearestPosition = 140 | destinations.sorted(by: { 141 | return distance(from: $0.value, to: projectedPosition) 142 | < distance(from: $1.value, to: projectedPosition) 143 | }) 144 | .first! 145 | 146 | return nearestPosition.value 147 | } 148 | 149 | private func distance(from: CGPoint, to: CGPoint) -> CGFloat { 150 | return sqrt(pow(from.x - to.x, 2) + pow(from.y - to.y, 2)) 151 | } 152 | 153 | private func cornerPositions() -> [Edge: CGPoint] { 154 | guard let targetView = self.view else { return [:] } 155 | guard let groundView = groundView else { return [:] } 156 | let viewSize = groundView.bounds.size 157 | let objectSize = targetView.bounds.size 158 | let xCenter = targetView.bounds.width / 2 159 | let yCenter = targetView.bounds.height / 2 160 | let safeAreaInsets = groundView.safeAreaInsets 161 | 162 | let top = CGPoint( 163 | x: viewSize.width / 2, 164 | y: self.margin + yCenter + safeAreaInsets.top 165 | ) 166 | let bottom = CGPoint( 167 | x: viewSize.width / 2, 168 | y: viewSize.height - objectSize.height - self.margin + yCenter - safeAreaInsets.bottom 169 | ) 170 | 171 | let topLeading = CGPoint( 172 | x: self.margin + xCenter + safeAreaInsets.left, 173 | y: self.margin + yCenter + safeAreaInsets.top 174 | ) 175 | let topTrailing = CGPoint( 176 | x: viewSize.width - objectSize.width - self.margin + xCenter - safeAreaInsets.right, 177 | y: self.margin + yCenter + safeAreaInsets.top 178 | ) 179 | 180 | let leading = CGPoint( 181 | x: self.margin + xCenter + safeAreaInsets.left, 182 | y: viewSize.height / 2 183 | ) 184 | 185 | let trailing = CGPoint( 186 | x: viewSize.width - objectSize.width - self.margin + xCenter - safeAreaInsets.right, 187 | y: viewSize.height / 2 188 | ) 189 | 190 | let bottomLeading = CGPoint( 191 | x: self.margin + xCenter + safeAreaInsets.left, 192 | y: viewSize.height - objectSize.height - self.margin + yCenter - safeAreaInsets.bottom 193 | ) 194 | 195 | let bottomTrailing = CGPoint( 196 | x: viewSize.width - objectSize.width - self.margin + xCenter - safeAreaInsets.right, 197 | y: viewSize.height - objectSize.height - self.margin + yCenter - safeAreaInsets.bottom 198 | ) 199 | return [ 200 | .top: top, 201 | .bottom: bottom, 202 | 203 | .topLeading: topLeading, 204 | .topTrailing: topTrailing, 205 | 206 | .leading: leading, 207 | .trailing: trailing, 208 | 209 | .bottomLeading: bottomLeading, 210 | .bottomTrailing: bottomTrailing, 211 | ] 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Extensions/L10n.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | static var showWidget: String { makeLocalizaedString("Show widget") } 5 | static var hideWidget: String { makeLocalizaedString("Hide widget") } 6 | static var hideUntilNextLaunch: String { makeLocalizaedString("Hide until next launch") } 7 | static var cancel: String { makeLocalizaedString("Cancel") } 8 | 9 | private static func makeLocalizaedString(_ key: String) -> String { 10 | NSLocalizedString(key, bundle: .module, comment: "") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/CPUGraphDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class CPUGraphDashboardItem: DashboardItem { 4 | public init() {} 5 | public let title: String = "CPU" 6 | private var data: [Double] = [] 7 | public func startMonitoring() {} 8 | public func stopMonitoring() {} 9 | public func update() { 10 | let metrics = Device.current.cpuUsage() 11 | data.append(Double(metrics * 100.0)) 12 | } 13 | public var fetcher: MetricsFetcher { 14 | .graph { [weak self] in 15 | self?.data ?? [] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/CPUUsageDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class CPUUsageDashboardItem: DashboardItem { 4 | public init() {} 5 | public let title: String = "CPU" 6 | private var text: String = "" 7 | public func startMonitoring() {} 8 | public func stopMonitoring() {} 9 | public func update() { 10 | text = Device.current.localizedCPUUsage 11 | } 12 | public var fetcher: MetricsFetcher { 13 | .text { [weak self] in 14 | self?.text ?? "" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/FPSDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import QuartzCore 3 | 4 | public class FPSDashboardItem: DashboardItem { 5 | public let title: String = "FPS" 6 | var displayLink: CADisplayLink? 7 | var lastupdated: CFTimeInterval = 0 8 | var updateCount: Int = 1 9 | var currentFPS: Int = 0 10 | 11 | public init() {} 12 | 13 | public func startMonitoring() { 14 | displayLink = .init(target: self, selector: #selector(updateDisplayLink)) 15 | displayLink?.preferredFramesPerSecond = 0 16 | displayLink?.add(to: .main, forMode: .common) 17 | } 18 | 19 | public func stopMonitoring() { 20 | displayLink?.remove(from: .main, forMode: .common) 21 | displayLink?.invalidate() 22 | displayLink = nil 23 | } 24 | 25 | @objc func updateDisplayLink(_ displayLink: CADisplayLink) { 26 | if displayLink.timestamp - lastupdated > 1.0 { 27 | currentFPS = updateCount 28 | updateCount = 1 29 | lastupdated = displayLink.timestamp 30 | } else { 31 | updateCount += 1 32 | } 33 | } 34 | 35 | public func update() { 36 | 37 | } 38 | 39 | public var fetcher: MetricsFetcher { 40 | .text { [weak self] in 41 | guard let self = self else { return "-" } 42 | return "\(self.currentFPS)" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/GPUMemoryUsageDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class GPUMemoryUsageDashboardItem: DashboardItem { 4 | public init() {} 5 | public let title: String = "GPU MEM" 6 | private var text: String = "" 7 | public func startMonitoring() {} 8 | public func stopMonitoring() {} 9 | public func update() { 10 | text = Device.current.localizedGPUMemory 11 | } 12 | public var fetcher: MetricsFetcher { 13 | .text { [weak self] in 14 | self?.text ?? "" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/IntervalDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public class IntervalDashboardItem: DashboardItem { 5 | public init(title: String, name: String) { 6 | self.title = title 7 | self.name = Notification.Name(name) 8 | } 9 | 10 | public let title: String 11 | private let name: Notification.Name 12 | var intervals: [TimeInterval] = [] 13 | private var cancellables: Set = [] 14 | 15 | public func startMonitoring() { 16 | NotificationCenter.default.publisher(for: name) 17 | .sink { notification in 18 | if let interval = notification.userInfo?[IntervalTracker.intervalKey] 19 | as? TimeInterval 20 | { 21 | self.intervals.append(interval) 22 | } 23 | } 24 | .store(in: &cancellables) 25 | } 26 | 27 | public func stopMonitoring() { 28 | cancellables = [] 29 | } 30 | 31 | public func update() { 32 | 33 | } 34 | 35 | public var fetcher: MetricsFetcher { 36 | .interval { [weak self] in 37 | guard let self = self else { return [] } 38 | return self.intervals 39 | } 40 | } 41 | } 42 | 43 | public class IntervalTracker { 44 | public enum SignpostType { 45 | case begin 46 | case end 47 | } 48 | static let intervalKey: String = "dev.noppe.intervalTracker.interval" 49 | 50 | public init(name: String) { 51 | notificationName = .init(name) 52 | } 53 | 54 | let notificationName: Notification.Name 55 | var beginDate: Date? 56 | 57 | public func track(_ type: SignpostType) { 58 | switch type { 59 | case .begin: 60 | beginDate = Date() 61 | case .end: 62 | guard let beginDate = beginDate else { return } 63 | let interval = Date().timeIntervalSince(beginDate) 64 | NotificationCenter.default.post( 65 | Notification( 66 | name: notificationName, 67 | object: nil, 68 | userInfo: [IntervalTracker.intervalKey: interval] 69 | ) 70 | ) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/MemoryUsageDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class MemoryUsageDashboardItem: DashboardItem { 4 | public init() {} 5 | public let title: String = "MEM" 6 | private var text: String = "" 7 | public func startMonitoring() {} 8 | public func stopMonitoring() {} 9 | public func update() { 10 | text = Device.current.localizedMemoryUsage 11 | } 12 | public var fetcher: MetricsFetcher { 13 | .text { [weak self] in 14 | self?.text ?? "" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/NetworkUsageDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public class NetworkUsageDashboardItem: DashboardItem { 5 | public init() {} 6 | public let title: String = "Network" 7 | var lastNetworkUsage: NetworkUsage? = nil 8 | var sendPerSec: UInt64 = 0 9 | var receivedPerSec: UInt64 = 0 10 | 11 | private var cancellables: Set = [] 12 | 13 | public func startMonitoring() { 14 | Timer.publish(every: 1, on: .main, in: .default).autoconnect() 15 | .sink { [weak self] _ in 16 | self?.updateNetworkUsage() 17 | } 18 | .store(in: &cancellables) 19 | } 20 | 21 | public func stopMonitoring() { 22 | cancellables = [] 23 | } 24 | 25 | private func updateNetworkUsage() { 26 | let networkUsage = Device.current.networkUsage() 27 | if let lastUsage = lastNetworkUsage, let newUsage = networkUsage { 28 | let sendPerSec = newUsage.sent.subtractingReportingOverflow(lastUsage.sent) 29 | self.sendPerSec = sendPerSec.partialValue 30 | let receivedPerSec = newUsage.received.subtractingReportingOverflow(lastUsage.received) 31 | self.receivedPerSec = receivedPerSec.partialValue 32 | } 33 | lastNetworkUsage = networkUsage 34 | } 35 | 36 | public func update() { 37 | 38 | } 39 | 40 | public var fetcher: MetricsFetcher { 41 | .text { [weak self] in 42 | guard let self = self else { return "-" } 43 | let formatter = ByteCountFormatter() 44 | formatter.countStyle = .file 45 | formatter.allowsNonnumericFormatting = false 46 | return 47 | "↑\(formatter.string(fromByteCount: Int64(self.sendPerSec)))/s\n↓\(formatter.string(fromByteCount: Int64(self.receivedPerSec)))/s" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/ThermalStateDashboardItem.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public class ThermalStateDashboardItem: DashboardItem { 5 | public init() {} 6 | public let title: String = "Thermal" 7 | var currentThermalState: ProcessInfo.ThermalState = .nominal 8 | 9 | private var cancellables: Set = [] 10 | 11 | public func startMonitoring() { 12 | } 13 | 14 | public func stopMonitoring() { 15 | cancellables = [] 16 | } 17 | 18 | public func update() { 19 | currentThermalState = Device.current.thermalState 20 | } 21 | 22 | public var fetcher: MetricsFetcher { 23 | .text { [weak self] in 24 | switch self?.currentThermalState { 25 | case .critical: 26 | return "critical" 27 | case .serious: 28 | return "serious" 29 | case .fair: 30 | return "fair" 31 | case .nominal: 32 | return "nominal" 33 | default: 34 | return "-" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/UI/GraphTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class GraphTableViewCell: UITableViewCell { 4 | let graph = GraphView() 5 | 6 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 7 | super.init(style: style, reuseIdentifier: reuseIdentifier) 8 | graph.frame = .init(x: 0, y: 0, width: 64, height: 36) 9 | accessoryView = graph 10 | selectionStyle = .none 11 | textLabel?.textColor = .white 12 | textLabel?.adjustsFontSizeToFitWidth = true 13 | } 14 | 15 | required init?(coder: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | func setData(_ data: [Double]) { 20 | graph.reload(data: data, capacity: 60) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/UI/GraphView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class GraphView: UIView { 4 | private var maxValue: Double = 0 5 | private var capacity: Int = 60 6 | private var data: [Double] = [] 7 | 8 | func setCapacity(_ capacity: Int) { 9 | self.capacity = capacity 10 | setNeedsDisplay() 11 | } 12 | 13 | func append(data: Double) { 14 | self.data.append(data) 15 | if self.data.count >= 10 { 16 | self.data.removeFirst() 17 | } 18 | self.maxValue = max(self.maxValue, data) 19 | setNeedsDisplay() 20 | } 21 | 22 | func reload(data: [Double], capacity: Int, maxValue: Double? = nil) { 23 | self.data = data.suffix(capacity).map({ $0 }) 24 | self.capacity = capacity 25 | self.maxValue = maxValue ?? data.max() ?? 0 26 | setNeedsDisplay() 27 | } 28 | 29 | override func draw(_ rect: CGRect) { 30 | guard data.count >= 1, data.count <= capacity else { return } 31 | let context = UIGraphicsGetCurrentContext() 32 | context?.clear(rect) 33 | 34 | let graph = UIBezierPath() 35 | 36 | graph.move(to: .init(x: 0, y: rect.height)) 37 | 38 | for (index, data) in data.enumerated() { 39 | let x = rect.width * CGFloat(index) / CGFloat(capacity) 40 | let y = rect.height - CGFloat(data / maxValue) * rect.height 41 | graph.addLine(to: .init(x: x, y: y)) 42 | } 43 | last: do { 44 | let x = rect.width * CGFloat(data.count - 1) / CGFloat(capacity) 45 | graph.addLine(to: .init(x: x, y: rect.size.height)) 46 | } 47 | graph.addLine(to: .init(x: rect.size.width, y: rect.size.height)) 48 | graph.addLine(to: .init(x: 0, y: rect.height)) 49 | graph.close() 50 | UIColor.lightGray.setFill() 51 | graph.fill() 52 | 53 | let string = String(format: "%.2f", maxValue) 54 | let text = NSAttributedString( 55 | string: string, 56 | attributes: [ 57 | .foregroundColor: UIColor.white, 58 | .font: UIFont.preferredFont(forTextStyle: .caption1), 59 | ] 60 | ) 61 | text.draw(at: .zero) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/UI/IntervalTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class IntervalTableViewCell: UITableViewCell { 4 | let graph = IntervalView(frame: .null) 5 | 6 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 7 | super.init(style: style, reuseIdentifier: reuseIdentifier) 8 | graph.frame = .init(x: 0, y: 0, width: 64, height: 36) 9 | accessoryView = graph 10 | selectionStyle = .none 11 | textLabel?.textColor = .white 12 | textLabel?.adjustsFontSizeToFitWidth = true 13 | } 14 | 15 | required init?(coder: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | func setDurations(_ durations: [TimeInterval]) { 20 | graph.reload(durations: durations) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/Dashboard/UI/IntervalView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class IntervalView: UIStackView { 4 | private var latestInterval: TimeInterval? 5 | private var minInterval: TimeInterval? 6 | private var maxInterval: TimeInterval? 7 | 8 | private var latestIntervalLabel: UILabel = .init() 9 | private var minIntervalLabel: UILabel = .init() 10 | private var maxIntervalLabel: UILabel = .init() 11 | 12 | override init(frame: CGRect) { 13 | super.init(frame: frame) 14 | distribution = .fillEqually 15 | axis = .vertical 16 | spacing = 0 17 | backgroundColor = .black 18 | addArrangedSubview(latestIntervalLabel) 19 | latestIntervalLabel.font = UIFont.systemFont(ofSize: 10) 20 | latestIntervalLabel.textColor = .white 21 | addArrangedSubview(minIntervalLabel) 22 | minIntervalLabel.font = UIFont.systemFont(ofSize: 10) 23 | minIntervalLabel.textColor = .systemGreen 24 | addArrangedSubview(maxIntervalLabel) 25 | maxIntervalLabel.font = UIFont.systemFont(ofSize: 10) 26 | maxIntervalLabel.textColor = .systemRed 27 | } 28 | 29 | required init(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | func set(duration: TimeInterval) { 34 | latestInterval = duration 35 | minInterval = minInterval.map({ min(duration, $0) }) ?? duration 36 | maxInterval = maxInterval.map({ max(duration, $0) }) ?? duration 37 | updateLabels() 38 | } 39 | 40 | func reload(durations: [TimeInterval]) { 41 | latestInterval = durations.last 42 | minInterval = durations.min() 43 | maxInterval = durations.max() 44 | updateLabels() 45 | } 46 | 47 | private func updateLabels() { 48 | latestIntervalLabel.text = latestInterval.map({ String(format: "%.3f", $0) }) 49 | minIntervalLabel.text = minInterval.map({ String(format: "%.3f", $0) }) 50 | maxIntervalLabel.text = maxInterval.map({ String(format: "%.3f", $0) }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/AppInfoDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AppInfoDebugItem: DebugItem { 4 | public init() {} 5 | public var debugItemTitle: String = "App Info" 6 | public var action: DebugItemAction = .didSelect { parent in 7 | let vc = await EnvelopePreviewTableViewController { 8 | [ 9 | "App Name": Application.current.appName, 10 | "Version": Application.current.version, 11 | "Build": Application.current.build, 12 | "Bundle ID": Application.current.bundleIdentifier, 13 | "App Size": Application.current.size, 14 | "Locale": Application.current.locale, 15 | "Localization": Application.current.preferredLocalizations, 16 | "TestFlight?": Application.current.isTestFlight ? "YES" : "NO", 17 | ] 18 | .map({ Envelope.init(key: $0.key, value: $0.value) }) 19 | .sorted(by: { $0.key < $1.key }) 20 | } 21 | await parent.navigationController?.pushViewController(vc, animated: true) 22 | return .success() 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/CaseSelectableDebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct CaseSelectableDebugItem: DebugItem 4 | where T.RawValue: Equatable { 5 | public init(currentValue: T, didSelected: @escaping (T) -> Void) { 6 | self.action = .didSelect { controller in 7 | let vc = await CaseSelectableTableController( 8 | currentValue: currentValue, 9 | didSelected: didSelected 10 | ) 11 | await controller.navigationController?.pushViewController(vc, animated: true) 12 | return .success() 13 | } 14 | } 15 | public var debugItemTitle: String { String(describing: T.self) } 16 | public let action: DebugItemAction 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/ClearCacheDebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct ClearCacheDebugItem: DebugItem { 4 | public init() {} 5 | 6 | public let debugItemTitle: String = "Clear Cache" 7 | public let action: DebugItemAction = .execute { 8 | do { 9 | try ClearCacheDebugItem.clearCache() 10 | return .success(message: "The cache completely cleared.") 11 | } catch { 12 | return .failure(message: "\(error)") 13 | } 14 | } 15 | 16 | static func clearCache() throws { 17 | let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! 18 | let fileManager = FileManager.default 19 | // Get the directory contents urls (including subfolders urls) 20 | let directoryContents = try FileManager.default.contentsOfDirectory( 21 | at: cacheURL, 22 | includingPropertiesForKeys: nil, 23 | options: [] 24 | ) 25 | for file in directoryContents { 26 | try fileManager.removeItem(at: file) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/DebugMenuResult.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum DebugMenuResult { 4 | case success(message: String? = nil) 5 | case failure(message: String? = nil) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/DeviceInfoDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct DeviceInfoDebugItem: DebugItem { 4 | public init() {} 5 | public var debugItemTitle: String = "Device Info" 6 | public var action: DebugItemAction = .didSelect { parent in 7 | let vc = await EnvelopePreviewTableViewController { 8 | [ 9 | "Name": Device.current.name, 10 | "Battery level": Device.current.localizedBatteryLevel, 11 | "Battery state": Device.current.localizedBatteryState, 12 | "Model": Device.current.localizedModel, 13 | "System name": Device.current.systemName, 14 | "System version": Device.current.systemVersion, 15 | "Jailbreak?": Device.current.isJailbreaked ? "YES" : "NO", 16 | "System uptime": Device.current.localizedSystemUptime, 17 | "Uptime": Device.current.localizedUptime, 18 | "LowPower mode?": Device.current.isLowPowerModeEnabled ? "YES" : "NO", 19 | "Processor": Device.current.processor, 20 | "Physical Memory": Device.current.localizedPhysicalMemory, 21 | "Disk usage": Device.current.localizedDiskUsage, 22 | ] 23 | .map({ Envelope(key: $0.key, value: $0.value) }).sorted(by: { $0.key < $1.key }) 24 | } 25 | await parent.navigationController?.pushViewController(vc, animated: true) 26 | return .success() 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/ExitDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ExitDebugItem: DebugItem { 4 | public init() { 5 | self.action = .didSelect(operation: { _ in 6 | exit(0) 7 | }) 8 | } 9 | 10 | public var debugItemTitle: String { "exit" } 11 | public let action: DebugItemAction 12 | } 13 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/GroupDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol HasDebugItems { 4 | var debugItems: [AnyGroupDebugItem] { get } 5 | } 6 | 7 | public struct GroupDebugItem: DebugItem, HasDebugItems { 8 | public init(title: String, items: [DebugItem]) { 9 | self.debugItemTitle = title 10 | self.debugItems = items.map(AnyGroupDebugItem.init) 11 | } 12 | 13 | public var debugItemTitle: String 14 | public var action: DebugItemAction { 15 | .didSelect { controller in 16 | let vc = await InAppDebuggerViewController( 17 | title: self.debugItemTitle, 18 | debuggerItems: self.debugItems, 19 | options: [] 20 | ) 21 | await controller.navigationController?.pushViewController(vc, animated: true) 22 | return .success() 23 | } 24 | } 25 | let debugItems: [AnyGroupDebugItem] 26 | } 27 | 28 | struct AnyGroupDebugItem: Hashable, Identifiable, DebugItem, HasDebugItems { 29 | let id: String 30 | let debugItemTitle: String 31 | let action: DebugItemAction 32 | let debugItems: [AnyGroupDebugItem] 33 | 34 | init(_ item: DebugItem) { 35 | id = UUID().uuidString 36 | debugItemTitle = item.debugItemTitle 37 | action = item.action 38 | if let grouped = item as? HasDebugItems { 39 | debugItems = grouped.debugItems.map(AnyGroupDebugItem.init) 40 | } else { 41 | debugItems = [] 42 | } 43 | } 44 | 45 | func hash(into hasher: inout Hasher) { 46 | hasher.combine(id) 47 | } 48 | 49 | static func == (lhs: AnyGroupDebugItem, rhs: AnyGroupDebugItem) -> Bool { 50 | lhs.id == rhs.id 51 | } 52 | } 53 | 54 | extension Array where Element == AnyGroupDebugItem { 55 | func flatten() -> [AnyGroupDebugItem] { 56 | var result: [AnyGroupDebugItem] = [] 57 | for element in self { 58 | if element.debugItems.isEmpty { 59 | result.append(element) 60 | } else { 61 | result.append(element) 62 | result.append(contentsOf: element.debugItems.flatten()) 63 | } 64 | } 65 | return result 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/KeyValueDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct KeyValueDebugItem: DebugItem { 4 | public init( 5 | title: String, 6 | fetcher: @escaping () async -> [Envelope] 7 | ) { 8 | self.title = title 9 | self.action = .didSelect(operation: { parent in 10 | let vc = await EnvelopePreviewTableViewController(fetcher: fetcher) 11 | await parent.navigationController?.pushViewController(vc, animated: true) 12 | return .success() 13 | }) 14 | } 15 | 16 | let title: String 17 | public var debugItemTitle: String { title } 18 | public let action: DebugItemAction 19 | } 20 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/SliderDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct SliderDebugItem: DebugItem { 4 | public init( 5 | title: String, 6 | current: @escaping () -> Double, 7 | valueLabelText: @escaping (Double) -> String = { String(format: "%.2f", $0) }, 8 | range: ClosedRange = 0.0...1.0, 9 | onChange: @escaping (Double) -> Void 10 | ) { 11 | self.title = title 12 | self.action = .slider( 13 | current: current, 14 | valueLabelText: valueLabelText, 15 | range: range, 16 | operation: { (value) in 17 | onChange(value) 18 | return .success() 19 | } 20 | ) 21 | } 22 | 23 | let title: String 24 | public var debugItemTitle: String { title } 25 | public let action: DebugItemAction 26 | } 27 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/ToggleDebugItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ToggleDebugItem: DebugItem { 4 | public init(title: String, current: @escaping () -> Bool, onChange: @escaping (Bool) -> Void) { 5 | self.title = title 6 | self.action = .toggle( 7 | current: current, 8 | operation: { (isOn) in 9 | onChange(isOn) 10 | return .success() 11 | } 12 | ) 13 | } 14 | 15 | let title: String 16 | public var debugItemTitle: String { title } 17 | public let action: DebugItemAction 18 | } 19 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UI/CaseSelectableTableController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public class CaseSelectableTableController: 5 | UITableViewController 6 | where T.RawValue: Equatable { 7 | public let currentValue: T 8 | public let didSelected: (T) -> Void 9 | private var selectedIndex: IndexPath? = nil 10 | 11 | public init(currentValue: T, didSelected: @escaping (T) -> Void) { 12 | self.currentValue = currentValue 13 | self.didSelected = didSelected 14 | super.init(style: .grouped) 15 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 16 | } 17 | 18 | required init?(coder: NSCoder) { fatalError() } 19 | 20 | public override func viewDidLoad() { 21 | super.viewDidLoad() 22 | selectedIndex = IndexPath( 23 | row: T.allCases.enumerated().first(where: { $1 == currentValue })?.offset ?? 0, 24 | section: 0 25 | ) 26 | tableView.tableFooterView = UIView() 27 | } 28 | 29 | public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) 30 | -> Int 31 | { 32 | T.allCases.count 33 | } 34 | 35 | public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 36 | -> UITableViewCell 37 | { 38 | let cell = tableView.dequeueReusableCell(withIdentifier: "cell")! 39 | let value = T.allCases.map({ $0 })[indexPath.row] 40 | cell.textLabel?.text = "\(value)" 41 | cell.accessoryType = (indexPath == selectedIndex) ? .checkmark : .none 42 | return cell 43 | } 44 | 45 | public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 46 | let indexPathes: [IndexPath] = [indexPath, selectedIndex].compactMap({ $0 }) 47 | selectedIndex = indexPath 48 | tableView.reloadRows(at: indexPathes, with: .automatic) 49 | let value = T.allCases.map({ $0 })[indexPath.row] 50 | didSelected(value) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UI/EnvelopePreviewTableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class EnvelopePreviewTableViewController: UITableViewController { 4 | 5 | @MainActor 6 | var envelops: [Envelope] = [] 7 | var fetcher: () async -> [Envelope] 8 | 9 | init(fetcher: @escaping () async -> [Envelope]) { 10 | self.fetcher = fetcher 11 | super.init(style: .plain) 12 | } 13 | 14 | required init?(coder: NSCoder) { 15 | fatalError("init(coder:) has not been implemented") 16 | } 17 | 18 | public override func viewDidLoad() { 19 | tableView.register(Value1TableViewCell.self) 20 | 21 | let refreshControlAction: UIAction = .init { [weak self] _ in 22 | self?.fetch() 23 | } 24 | let refreshControl = UIRefreshControl(frame: .zero, primaryAction: refreshControlAction) 25 | tableView.refreshControl = refreshControl 26 | 27 | let rightBarButtonAction: UIAction = .init { [weak self] _ in 28 | self?.presentActivity() 29 | } 30 | let rightBarButtonItem = UIBarButtonItem( 31 | systemItem: .action, 32 | primaryAction: rightBarButtonAction, 33 | menu: nil 34 | ) 35 | navigationItem.rightBarButtonItem = rightBarButtonItem 36 | 37 | fetch() 38 | } 39 | 40 | private func fetch() { 41 | Task { [weak self] in 42 | let envelops = await self?.fetcher() 43 | guard let envelops else { return } 44 | self?.envelops = envelops 45 | self?.tableView.refreshControl?.endRefreshing() 46 | self?.tableView.reloadData() 47 | } 48 | } 49 | 50 | private func presentAlert(title: String, message: String) { 51 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 52 | alert.addAction( 53 | .init( 54 | title: "Copy", 55 | style: .default, 56 | handler: { _ in 57 | UIPasteboard.general.string = message 58 | } 59 | ) 60 | ) 61 | alert.addAction(.init(title: "OK", style: .default, handler: nil)) 62 | present(alert, animated: true, completion: nil) 63 | } 64 | 65 | private func presentActivity() { 66 | let texts = envelops.map({ "\($0.key) : \($0.value)" }).joined(separator: "\n") 67 | let vc = UIActivityViewController(activityItems: [texts], applicationActivities: nil) 68 | present(vc, animated: true, completion: nil) 69 | } 70 | 71 | public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) 72 | -> Int 73 | { 74 | envelops.count 75 | } 76 | 77 | public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 78 | -> UITableViewCell 79 | { 80 | let cell = tableView.dequeue(Value1TableViewCell.self, for: indexPath) 81 | let envelop = envelops[indexPath.row] 82 | cell.textLabel?.text = envelop.key 83 | cell.detailTextLabel?.text = envelop.value 84 | return cell 85 | } 86 | 87 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 88 | let envelop = envelops[indexPath.row] 89 | presentAlert(title: envelop.key, message: envelop.value) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UI/SliderCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SliderCell: UICollectionViewListCell { 4 | var title: String! 5 | var current: (() -> Double)! 6 | var valueLabelText: ((Double) -> String)! 7 | var range: ClosedRange! 8 | var onChange: ((Double) -> Void)! 9 | 10 | override func updateConfiguration(using state: UICellConfigurationState) { 11 | let configuration = SliderCellConfiguration( 12 | title: title, 13 | current: current, 14 | valueLabelText: valueLabelText, 15 | range: range, 16 | onChange: onChange 17 | ) 18 | contentConfiguration = configuration 19 | } 20 | } 21 | 22 | struct SliderCellConfiguration: UIContentConfiguration { 23 | let title: String 24 | let current: () -> Double 25 | let valueLabelText: (Double) -> String 26 | let range: ClosedRange 27 | let onChange: (Double) -> Void 28 | 29 | func makeContentView() -> UIView & UIContentView { 30 | SliderCellView(configuration: self) 31 | } 32 | 33 | func updated(for state: UIConfigurationState) -> Self { self } 34 | } 35 | 36 | class SliderCellView: UIView, UIContentView { 37 | var configuration: UIContentConfiguration 38 | 39 | init(configuration: SliderCellConfiguration) { 40 | self.configuration = configuration 41 | super.init(frame: .null) 42 | 43 | let titleLabel = UILabel(frame: .null) 44 | titleLabel.text = configuration.title 45 | let valueLabel = UILabel(frame: .null) 46 | 47 | let slider = UISlider( 48 | frame: .null, 49 | primaryAction: UIAction(handler: { (action) in 50 | if let slider = action.sender as? UISlider { 51 | valueLabel.text = configuration.valueLabelText(Double(slider.value)) 52 | configuration.onChange(Double(slider.value)) 53 | } 54 | }) 55 | ) 56 | slider.maximumValue = Float(configuration.range.upperBound) 57 | slider.minimumValue = Float(configuration.range.lowerBound) 58 | slider.setValue(Float(configuration.current()), animated: false) 59 | valueLabel.text = configuration.valueLabelText(Double(slider.value)) 60 | 61 | let hStack = UIStackView(arrangedSubviews: [titleLabel, valueLabel]) 62 | hStack.axis = .horizontal 63 | 64 | let vStack = UIStackView(arrangedSubviews: [hStack, slider]) 65 | vStack.axis = .vertical 66 | vStack.spacing = 6 67 | vStack.translatesAutoresizingMaskIntoConstraints = false 68 | addSubview(vStack) 69 | NSLayoutConstraint.activate([ 70 | vStack.topAnchor.constraint(equalTo: topAnchor, constant: 8), 71 | vStack.rightAnchor.constraint(equalTo: rightAnchor, constant: -16), 72 | vStack.leftAnchor.constraint(equalTo: leftAnchor, constant: 16), 73 | vStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), 74 | ]) 75 | } 76 | 77 | required init?(coder: NSCoder) { 78 | fatalError("init(coder:) has not been implemented") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UI/ToogleCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ToggleCell: UICollectionViewListCell { 4 | var current: (() -> Bool)! 5 | var onChange: ((Bool) -> Void)! 6 | 7 | override func updateConfiguration(using state: UICellConfigurationState) { 8 | let toggle = UISwitch() 9 | toggle.isOn = current() 10 | toggle.addAction( 11 | .init(handler: { [weak self] action in 12 | self?.onChange(toggle.isOn) 13 | }), 14 | for: .valueChanged 15 | ) 16 | let configuration = UICellAccessory.CustomViewConfiguration( 17 | customView: toggle, 18 | placement: .trailing() 19 | ) 20 | accessories = [.customView(configuration: configuration)] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UI/Value1TableViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class Value1TableViewCell: UITableViewCell { 4 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 5 | super.init(style: .value1, reuseIdentifier: reuseIdentifier) 6 | textLabel?.adjustsFontSizeToFitWidth = true 7 | } 8 | 9 | required init?(coder: NSCoder) { 10 | fatalError("init(coder:) has not been implemented") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/UserDefaultsResetDebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct UserDefaultsResetDebugItem: DebugItem { 4 | public init() {} 5 | 6 | public let debugItemTitle: String = "Reset UserDefaults" 7 | 8 | public let action: DebugItemAction = .execute { 9 | let appDomain = Bundle.main.bundleIdentifier! 10 | UserDefaults.standard.removePersistentDomain(forName: appDomain) 11 | exit(0) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Plugin/DebugMenu/ViewControllerDebugItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct ViewControllerDebugItem: DebugItem { 4 | public enum PresentationMode { 5 | case present 6 | case push 7 | } 8 | 9 | public init( 10 | title: String? = nil, 11 | presentationMode: PresentationMode = .push, 12 | builder: @escaping @MainActor (T.Type) -> T = { $0.init() } 13 | ) { 14 | debugItemTitle = title ?? String(describing: T.self) 15 | action = .didSelect { controller in 16 | let viewController = await builder(T.self) 17 | switch presentationMode { 18 | case .present: 19 | await controller.present(viewController, animated: true) 20 | return .success() 21 | case .push: 22 | await controller.navigationController? 23 | .pushViewController(viewController, animated: true) 24 | return .success() 25 | } 26 | } 27 | } 28 | 29 | public let debugItemTitle: String 30 | public let action: DebugItemAction 31 | } 32 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Resource/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "Cancel" = "Cancel"; 2 | "Hide widget" = "Hide widget"; 3 | "Show widget" = "Show widget"; 4 | "Hide until next launch" = "Hide until next launch"; 5 | -------------------------------------------------------------------------------- /Sources/DebugMenu/Resource/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "Cancel" = "キャンセル"; 2 | "Hide widget" = "ウィジェットを非表示"; 3 | "Show widget" = "ウィジェットを表示"; 4 | "Hide until next launch" = "次回起動までランチャーを非表示"; 5 | -------------------------------------------------------------------------------- /Sources/DebugMenu/SwiftUI/DebugMenuModifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | @available(iOSApplicationExtension, unavailable) 5 | struct DebugMenuModifier: ViewModifier { 6 | internal init( 7 | debuggerItems: [DebugItem], 8 | dashboardItems: [DashboardItem], 9 | options: [Options] 10 | ) { 11 | self.debuggerItems = debuggerItems 12 | self.dashboardItems = dashboardItems 13 | self.options = options 14 | } 15 | 16 | let debuggerItems: [DebugItem] 17 | let dashboardItems: [DashboardItem] 18 | let options: [Options] 19 | 20 | func body(content: Content) -> some View { 21 | content.onAppear(perform: { 22 | if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { 23 | DebugMenu.install( 24 | windowScene: windowScene, 25 | items: debuggerItems, 26 | dashboardItems: dashboardItems, 27 | options: options 28 | ) 29 | } 30 | }) 31 | } 32 | } 33 | 34 | @available(iOSApplicationExtension, unavailable) 35 | public extension View { 36 | @ViewBuilder 37 | func debugMenu( 38 | debuggerItems: [DebugItem] = [], 39 | dashboardItems: [DashboardItem] = [], 40 | options: [Options] = Options.default, 41 | enabled: Bool = true 42 | ) -> some View { 43 | if enabled { 44 | modifier( 45 | DebugMenuModifier( 46 | debuggerItems: debuggerItems, 47 | dashboardItems: dashboardItems, 48 | options: options 49 | ) 50 | ) 51 | } else { 52 | self 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/DebugMenu/View/FloatingViewController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | internal class FloatingViewController: UIViewController { 5 | class View: UIView, TouchThrowing {} 6 | private let launchView: LaunchView 7 | private let widgetView: WidgetView 8 | private let debuggerItems: [DebugItem] 9 | private var cancellables: Set = [] 10 | private let options: [Options] 11 | 12 | init( 13 | debuggerItems: [DebugItem], 14 | dashboardItems: [DashboardItem], 15 | options: [Options] 16 | ) { 17 | self.debuggerItems = debuggerItems 18 | self.widgetView = .init(dashboardItems: dashboardItems) 19 | self.options = options 20 | 21 | launchView = .init( 22 | image: 23 | options.compactMap { option -> UIImage? in 24 | if case .launchIcon(let launchIcon) = option { 25 | return launchIcon.image 26 | } 27 | return nil 28 | } 29 | .first 30 | ) 31 | 32 | super.init(nibName: nil, bundle: nil) 33 | } 34 | 35 | required init?(coder: NSCoder) { fatalError() } 36 | 37 | override func loadView() { 38 | view = View(frame: .null) 39 | 40 | view.addSubview(launchView) 41 | view.addSubview(widgetView) 42 | 43 | launchView.isHidden = true 44 | widgetView.isHidden = true 45 | } 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | 50 | bug: do { 51 | let gesture = FloatingItemGestureRecognizer(groundView: self.view) 52 | launchView.addGestureRecognizer(gesture) 53 | 54 | let initialPosition = 55 | options 56 | .compactMap { option -> Options.LaunchIcon.Position? in 57 | if case .launchIcon(let launchIcon) = option { 58 | return launchIcon.initialPosition 59 | } 60 | return nil 61 | } 62 | .first ?? .bottomTrailing 63 | 64 | gesture.moveInitialPosition(initialPosition) 65 | 66 | let longPress = UILongPressGestureRecognizer() 67 | longPress.publisher(for: \.state).filter({ $0 == .began }) 68 | .sink { [weak self] _ in 69 | self?.presentMenu() 70 | } 71 | .store(in: &cancellables) 72 | launchView.addGestureRecognizer(longPress) 73 | 74 | launchView.addAction( 75 | .init(handler: { [weak self] _ in 76 | guard let self = self else { return } 77 | let vc = InAppDebuggerViewController( 78 | debuggerItems: self.debuggerItems, 79 | options: self.options 80 | ) 81 | let nc = UINavigationController(rootViewController: vc) 82 | nc.modalPresentationStyle = .fullScreen 83 | if #available(iOS 15, *) { 84 | nc.modalPresentationStyle = .pageSheet 85 | nc.sheetPresentationController?.selectedDetentIdentifier = .medium 86 | nc.sheetPresentationController?.detents = [.medium(), .large()] 87 | nc.popoverPresentationController?.sourceView = self.launchView 88 | self.present(nc, animated: true, completion: nil) 89 | } else { 90 | let ac = CustomActivityViewController(controller: nc) 91 | ac.popoverPresentationController?.sourceView = self.launchView 92 | self.present(ac, animated: true, completion: nil) 93 | } 94 | }) 95 | ) 96 | } 97 | 98 | widget: do { 99 | let gesture = FloatingItemGestureRecognizer(groundView: self.view) 100 | widgetView.addGestureRecognizer(gesture) 101 | gesture.moveInitialPosition(.topLeading) 102 | } 103 | 104 | if options.contains(where: { $0.isShowsWidgetOnLaunch }) { 105 | widgetView.show() 106 | } else { 107 | widgetView.hide() 108 | } 109 | launchView.isHidden = false 110 | } 111 | 112 | private func presentMenu() { 113 | let sheet = UIAlertController(title: "DebugMenu", message: nil, preferredStyle: .alert) 114 | sheet.addAction( 115 | .init( 116 | title: .hideUntilNextLaunch, 117 | style: .destructive, 118 | handler: { [weak self] _ in 119 | self?.launchView.isHidden = true 120 | self?.widgetView.hide() 121 | } 122 | ) 123 | ) 124 | if widgetView.isHidden { 125 | sheet.addAction( 126 | .init( 127 | title: .showWidget, 128 | style: .default, 129 | handler: { [weak self] _ in 130 | self?.widgetView.show() 131 | } 132 | ) 133 | ) 134 | } else { 135 | sheet.addAction( 136 | .init( 137 | title: .hideWidget, 138 | style: .destructive, 139 | handler: { [weak self] _ in 140 | self?.widgetView.hide() 141 | } 142 | ) 143 | ) 144 | } 145 | sheet.addAction(.init(title: .cancel, style: .cancel, handler: nil)) 146 | present(sheet, animated: true, completion: nil) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/DebugMenu/View/InAppDebuggerViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class InAppDebuggerViewController: UIViewController { 4 | let collectionView: UICollectionView 5 | let flattenDebugItems: [AnyDebugItem] 6 | let debuggerItems: [AnyDebugItem] 7 | let options: [Options] 8 | lazy var dataSource: UICollectionViewDiffableDataSource = { 9 | preconditionFailure() 10 | }() 11 | 12 | enum Section: Int, CaseIterable { 13 | case recent 14 | case items 15 | 16 | var title: String { 17 | switch self { 18 | case .recent: 19 | return "Recent" 20 | case .items: 21 | return "Items" 22 | } 23 | } 24 | } 25 | 26 | init(title: String = "DebugMenu", debuggerItems: [DebugItem], options: [Options]) { 27 | self.options = options 28 | self.flattenDebugItems = debuggerItems.map(AnyGroupDebugItem.init).flatten() 29 | .map(AnyDebugItem.init) 30 | self.debuggerItems = debuggerItems.map(AnyDebugItem.init) 31 | var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) 32 | configuration.headerMode = .supplementary 33 | let collectionViewLayout = UICollectionViewCompositionalLayout.list(using: configuration) 34 | collectionView = UICollectionView( 35 | frame: .null, 36 | collectionViewLayout: collectionViewLayout 37 | ) 38 | super.init(nibName: nil, bundle: nil) 39 | self.title = title 40 | } 41 | 42 | required init?(coder: NSCoder) { fatalError() } 43 | 44 | override func loadView() { 45 | view = collectionView 46 | } 47 | 48 | override func viewDidLoad() { 49 | super.viewDidLoad() 50 | 51 | navigationItem.largeTitleDisplayMode = .always 52 | 53 | search: do { 54 | let searchController = UISearchController(searchResultsController: nil) 55 | searchController.searchResultsUpdater = self 56 | navigationItem.searchController = searchController 57 | } 58 | 59 | navigation: do { 60 | let rightItem = UIBarButtonItem( 61 | systemItem: .done, 62 | primaryAction: UIAction(handler: { [weak self] (_) in 63 | if #available(iOS 15, *) { 64 | // sheetPresentationController 65 | self?.dismiss(animated: true) 66 | } else { 67 | // CustomActivityViewController 68 | self?.parent?.parent?.dismiss(animated: true) 69 | } 70 | }), 71 | menu: nil 72 | ) 73 | navigationItem.rightBarButtonItem = rightItem 74 | } 75 | 76 | toolbar: do { 77 | let label = UILabel(frame: .null) 78 | label.font = UIFont.preferredFont(forTextStyle: .caption1) 79 | label.textColor = UIColor.label 80 | label.text = 81 | "\(Application.current.appName) \(Application.current.version) (\(Application.current.build))" 82 | let bundleIDLabel = UILabel(frame: .null) 83 | bundleIDLabel.font = UIFont.preferredFont(forTextStyle: .caption2) 84 | bundleIDLabel.textColor = UIColor.secondaryLabel 85 | bundleIDLabel.text = "\(Application.current.bundleIdentifier)" 86 | let vStack = UIStackView(arrangedSubviews: [label, bundleIDLabel]) 87 | vStack.axis = .vertical 88 | vStack.alignment = .center 89 | let space = UIBarButtonItem.flexibleSpace() 90 | let item = UIBarButtonItem(customView: vStack) 91 | navigationController?.isToolbarHidden = false 92 | toolbarItems = [space, item, space] 93 | } 94 | configureDataSource() 95 | collectionView.delegate = self 96 | 97 | performUpdate() 98 | } 99 | 100 | override func viewDidAppear(_ animated: Bool) { 101 | super.viewDidAppear(animated) 102 | deselectSelectedItems() 103 | } 104 | 105 | private func onCompleteAction(_ result: DebugMenuResult) { 106 | switch result { 107 | case .success(let message) where message != nil: 108 | presentAlert(title: "Success", message: message) 109 | case .failure(let message) where message != nil: 110 | presentAlert(title: "Error", message: message) 111 | default: 112 | break 113 | } 114 | } 115 | 116 | private func deselectSelectedItems(animated: Bool = true) { 117 | collectionView.indexPathsForSelectedItems? 118 | .forEach { (indexPath) in 119 | collectionView.deselectItem(at: indexPath, animated: animated) 120 | } 121 | } 122 | } 123 | 124 | extension InAppDebuggerViewController { 125 | 126 | func configureDataSource() { 127 | let selectCellRegstration = UICollectionView.CellRegistration { 128 | (cell: UICollectionViewListCell, indexPath, title: String) in 129 | var content = cell.defaultContentConfiguration() 130 | content.text = title 131 | cell.contentConfiguration = content 132 | cell.accessories = [.disclosureIndicator()] 133 | } 134 | 135 | let executableCellRegstration = UICollectionView.CellRegistration { 136 | (cell: UICollectionViewListCell, indexPath, title: String) in 137 | var content = cell.defaultContentConfiguration() 138 | content.text = title 139 | cell.contentConfiguration = content 140 | } 141 | 142 | let toggleCellRegstration = UICollectionView.CellRegistration { 143 | ( 144 | cell: ToggleCell, 145 | indexPath, 146 | item: (title: String, current: () -> Bool, onChange: (Bool) -> Void) 147 | ) in 148 | var content = cell.defaultContentConfiguration() 149 | content.text = item.title 150 | cell.contentConfiguration = content 151 | cell.current = item.current 152 | cell.onChange = item.onChange 153 | } 154 | 155 | let sliderCellRegstration = UICollectionView.CellRegistration { 156 | ( 157 | cell: SliderCell, 158 | indexPath, 159 | item: ( 160 | title: String, current: () -> Double, valueLabelText: (Double) -> String, 161 | range: ClosedRange, onChange: (Double) -> Void 162 | ) 163 | ) in 164 | cell.title = item.title 165 | cell.current = item.current 166 | cell.valueLabelText = item.valueLabelText 167 | cell.range = item.range 168 | cell.onChange = item.onChange 169 | } 170 | 171 | dataSource = .init( 172 | collectionView: collectionView, 173 | cellProvider: { [unowned self] (collectionView, indexPath, item) in 174 | switch item.action { 175 | case .didSelect: 176 | return collectionView.dequeueConfiguredReusableCell( 177 | using: selectCellRegstration, 178 | for: indexPath, 179 | item: item.debugItemTitle 180 | ) 181 | case .execute: 182 | return collectionView.dequeueConfiguredReusableCell( 183 | using: executableCellRegstration, 184 | for: indexPath, 185 | item: item.debugItemTitle 186 | ) 187 | case let .toggle(current, onChange): 188 | return collectionView.dequeueConfiguredReusableCell( 189 | using: toggleCellRegstration, 190 | for: indexPath, 191 | item: ( 192 | item.debugItemTitle, current, 193 | { [unowned self] (value) in 194 | Task { @MainActor[weak self] in 195 | let result = await onChange(value) 196 | self?.onCompleteAction(result) 197 | } 198 | } 199 | ) 200 | ) 201 | case let .slider(current, valueLabelText, range, onChange): 202 | return collectionView.dequeueConfiguredReusableCell( 203 | using: sliderCellRegstration, 204 | for: indexPath, 205 | item: ( 206 | item.debugItemTitle, current, valueLabelText, range, 207 | { [unowned self] (value) in 208 | Task { @MainActor[weak self] in 209 | let result = await onChange(value) 210 | self?.onCompleteAction(result) 211 | } 212 | } 213 | ) 214 | ) 215 | } 216 | } 217 | ) 218 | 219 | let headerRegistration = UICollectionView.SupplementaryRegistration< 220 | UICollectionViewListCell 221 | >(elementKind: UICollectionView.elementKindSectionHeader) { 222 | [unowned self] (headerView, elementKind, indexPath) in 223 | var configuration = headerView.defaultContentConfiguration() 224 | if #available(iOS 15.0, *) { 225 | #if compiler(>=5.5) 226 | configuration.text = 227 | self.dataSource.sectionIdentifier(for: indexPath.section)?.title 228 | #else 229 | // FIXME: Index is wrong when unused showsRecentItems 230 | configuration.text = Section(rawValue: indexPath.section)?.title 231 | #endif 232 | } else { 233 | // FIXME: Index is wrong when unused showsRecentItems 234 | configuration.text = Section(rawValue: indexPath.section)?.title 235 | } 236 | headerView.contentConfiguration = configuration 237 | } 238 | dataSource.supplementaryViewProvider = { 239 | (collectionView, kind, indexPath) -> UICollectionReusableView? in 240 | collectionView.dequeueConfiguredReusableSupplementary( 241 | using: headerRegistration, 242 | for: indexPath 243 | ) 244 | } 245 | } 246 | 247 | func performUpdate(_ query: String? = nil) { 248 | var snapshot = NSDiffableDataSourceSnapshot() 249 | 250 | if let query = query, !query.isEmpty { 251 | snapshot.appendSections([Section.items]) 252 | let filteredItems = flattenDebugItems.filter({ 253 | $0.debugItemTitle.lowercased().contains(query.lowercased()) 254 | }) 255 | snapshot.appendItems(filteredItems, toSection: .items) 256 | } else { 257 | let recentItems = RecentItemStore(items: debuggerItems).get() 258 | if !recentItems.isEmpty && options.contains(where: { $0.isShowsRecentItems }) { 259 | snapshot.appendSections([Section.recent]) 260 | snapshot.appendItems(recentItems, toSection: .recent) 261 | } 262 | snapshot.appendSections([Section.items]) 263 | snapshot.appendItems(debuggerItems, toSection: .items) 264 | } 265 | 266 | dataSource.apply(snapshot) 267 | } 268 | } 269 | 270 | extension InAppDebuggerViewController: UICollectionViewDelegate { 271 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 272 | switch Section(rawValue: indexPath.section) { 273 | case .items, .recent: 274 | let item = dataSource.itemIdentifier(for: indexPath)! 275 | switch item.action { 276 | case let .didSelect(action): 277 | Task { @MainActor[weak self] in 278 | guard let self = self else { return } 279 | let result = await action(self) 280 | self.onCompleteAction(result) 281 | } 282 | case let .execute(action): 283 | Task { @MainActor[weak self] in 284 | let result = await action() 285 | self?.onCompleteAction(result) 286 | } 287 | case .toggle, .slider: 288 | break 289 | } 290 | RecentItemStore(items: debuggerItems).insert(item) 291 | performUpdate() 292 | default: 293 | fatalError() 294 | } 295 | } 296 | 297 | func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) 298 | -> Bool 299 | { 300 | switch Section(rawValue: indexPath.section) { 301 | case .items, .recent: 302 | let item = dataSource.itemIdentifier(for: indexPath)! 303 | switch item.action { 304 | case .didSelect, .execute: 305 | return true 306 | case .toggle, .slider: 307 | return false 308 | } 309 | default: 310 | fatalError() 311 | } 312 | } 313 | 314 | private func presentAlert(title: String, message: String?) { 315 | DispatchQueue.main.async { [weak self] in 316 | let vc = UIAlertController(title: title, message: message, preferredStyle: .alert) 317 | vc.addAction( 318 | .init( 319 | title: "OK", 320 | style: .cancel, 321 | handler: { [weak self] _ in 322 | self?.deselectSelectedItems() 323 | } 324 | ) 325 | ) 326 | self?.present(vc, animated: true, completion: nil) 327 | } 328 | } 329 | } 330 | 331 | extension InAppDebuggerViewController: UISearchResultsUpdating { 332 | func updateSearchResults(for searchController: UISearchController) { 333 | performUpdate(searchController.searchBar.text) 334 | } 335 | } 336 | 337 | open class CollectionViewCell: UICollectionViewCell { 338 | open override var isHighlighted: Bool { 339 | didSet { 340 | if isHighlighted { 341 | contentView.alpha = 0.5 342 | } else { 343 | UIView.animate(withDuration: 0.3) { [weak self] in 344 | self?.contentView.alpha = 1.0 345 | } 346 | } 347 | } 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /Sources/DebugMenu/View/InAppDebuggerWindow.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | protocol TouchThrowing {} 5 | 6 | @available(iOSApplicationExtension, unavailable) 7 | public class InAppDebuggerWindow: UIWindow { 8 | internal static var windows: [InAppDebuggerWindow] = [] 9 | 10 | internal static func install( 11 | windowScene: UIWindowScene? = nil, 12 | debuggerItems: [DebugItem], 13 | dashboardItems: [DashboardItem], 14 | options: [Options] 15 | ) { 16 | install( 17 | { 18 | windowScene.map(InAppDebuggerWindow.init(windowScene:)) 19 | ?? InAppDebuggerWindow(frame: UIScreen.main.bounds) 20 | }, 21 | debuggerItems: debuggerItems, 22 | dashboardItems: dashboardItems, 23 | options: options 24 | ) 25 | } 26 | 27 | internal override init(windowScene: UIWindowScene) { 28 | super.init(windowScene: windowScene) 29 | } 30 | 31 | internal override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | } 34 | 35 | private static func install( 36 | _ factory: (() -> InAppDebuggerWindow), 37 | debuggerItems: [DebugItem], 38 | dashboardItems: [DashboardItem], 39 | options: [Options] 40 | ) { 41 | let window = factory() 42 | window.windowLevel = UIWindow.Level.statusBar + 1 43 | window.rootViewController = FloatingViewController( 44 | debuggerItems: debuggerItems, 45 | dashboardItems: dashboardItems, 46 | options: options 47 | ) 48 | // workaround: Screen edge deferring is choose from full-screen windows. 49 | // -> preferredScreenEdgesDeferringSystemGestures 50 | window.frame.size.width += 1 51 | window.isHidden = false 52 | window.frame.size.width -= 1 53 | windows.append(window) 54 | } 55 | 56 | internal required init?(coder: NSCoder) { fatalError() } 57 | 58 | // deprecated: Keyboard input not working without responder chain. 59 | // public override func makeKey() { 60 | // // workaround: Make a new UIWindow without become key. 61 | // // https://stackoverflow.com/a/64758605/1131587 62 | // } 63 | 64 | public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 65 | let view = super.hitTest(point, with: event) 66 | if view is TouchThrowing { 67 | return nil 68 | } else { 69 | return view 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/DebugMenu/View/LaunchView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class LaunchView: UIVisualEffectView { 4 | private let button: UIButton = .init(frame: .null) 5 | 6 | init(image: UIImage? = nil) { 7 | super.init(effect: UIBlurEffect(style: .systemMaterialDark)) 8 | frame = CGRect(x: 0, y: 0, width: 44, height: 44) 9 | 10 | button.setImage(image ?? UIImage(systemName: "ant.fill"), for: .normal) 11 | button.tintColor = UIColor.white 12 | button.translatesAutoresizingMaskIntoConstraints = false 13 | contentView.addSubview(button) 14 | NSLayoutConstraint.activate([ 15 | button.topAnchor.constraint(equalTo: contentView.topAnchor), 16 | button.leftAnchor.constraint(equalTo: contentView.leftAnchor), 17 | button.rightAnchor.constraint(equalTo: contentView.rightAnchor), 18 | button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 19 | ]) 20 | 21 | layer.cornerCurve = .continuous 22 | layer.cornerRadius = 22 23 | layer.masksToBounds = true 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | var menu: UIMenu? { 31 | get { button.menu } 32 | set { button.menu = newValue } 33 | } 34 | 35 | func addAction(_ action: UIAction) { 36 | button.addAction(action, for: .touchUpInside) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/DebugMenu/View/WidgetView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | class WidgetView: UIVisualEffectView { 5 | private let tableView: UITableView = .init(frame: .null, style: .plain) 6 | private var cancellables: Set = [] 7 | private let dashboardItems: [DashboardItem] 8 | 9 | init(dashboardItems: [DashboardItem]) { 10 | self.dashboardItems = dashboardItems 11 | super.init(effect: UIBlurEffect(style: .systemMaterialDark)) 12 | frame = .init(origin: .zero, size: .init(width: 200, height: 200)) 13 | 14 | let stackView = UIStackView(arrangedSubviews: [tableView]) 15 | stackView.axis = .vertical 16 | stackView.translatesAutoresizingMaskIntoConstraints = false 17 | contentView.addSubview(stackView) 18 | NSLayoutConstraint.activate([ 19 | stackView.topAnchor.constraint(equalTo: contentView.topAnchor), 20 | stackView.rightAnchor.constraint(equalTo: contentView.rightAnchor), 21 | stackView.leftAnchor.constraint(equalTo: contentView.leftAnchor), 22 | stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 23 | ]) 24 | 25 | layer.cornerCurve = .continuous 26 | layer.cornerRadius = 16 27 | layer.masksToBounds = true 28 | 29 | tableView.backgroundColor = .clear 30 | tableView.separatorStyle = .none 31 | tableView.register(Value1TableViewCell.self) 32 | tableView.register(GraphTableViewCell.self) 33 | tableView.register(IntervalTableViewCell.self) 34 | tableView.delegate = self 35 | tableView.dataSource = self 36 | } 37 | 38 | required init(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | func show() { 43 | isHidden = false 44 | dashboardItems.forEach({ $0.startMonitoring() }) 45 | Timer.publish(every: 1, on: .main, in: .default).autoconnect() 46 | .sink { [weak self] _ in 47 | self?.reloadData() 48 | } 49 | .store(in: &cancellables) 50 | } 51 | 52 | func hide() { 53 | isHidden = true 54 | dashboardItems.forEach({ $0.stopMonitoring() }) 55 | cancellables = [] 56 | } 57 | 58 | private func reloadData() { 59 | dashboardItems.forEach({ $0.update() }) 60 | tableView.reloadData() 61 | } 62 | } 63 | 64 | extension WidgetView: UITableViewDelegate, UITableViewDataSource { 65 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 66 | dashboardItems.count 67 | } 68 | 69 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 70 | let item = dashboardItems[indexPath.row] 71 | switch item.fetcher { 72 | case let .text(fetcher): 73 | let cell = tableView.dequeue(Value1TableViewCell.self, for: indexPath) 74 | cell.selectionStyle = .none 75 | cell.textLabel?.text = item.title 76 | cell.textLabel?.textColor = .white 77 | cell.detailTextLabel?.text = fetcher() 78 | cell.detailTextLabel?.textColor = .lightGray 79 | cell.detailTextLabel?.numberOfLines = 0 80 | return cell 81 | case let .graph(fetcher): 82 | let cell = tableView.dequeue(GraphTableViewCell.self, for: indexPath) 83 | cell.textLabel?.text = item.title 84 | cell.setData(fetcher()) 85 | return cell 86 | case let .interval(fetcher): 87 | let cell = tableView.dequeue(IntervalTableViewCell.self, for: indexPath) 88 | cell.textLabel?.text = item.title 89 | cell.setDurations(fetcher()) 90 | return cell 91 | } 92 | } 93 | 94 | func tableView( 95 | _ tableView: UITableView, 96 | willDisplay cell: UITableViewCell, 97 | forRowAt indexPath: IndexPath 98 | ) { 99 | cell.contentView.backgroundColor = .clear 100 | cell.backgroundColor = .clear 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/DebugMenuTests/DebugMenuTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DebugMenu 3 | 4 | final class DebugMenuTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(DebugMenu().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Tests/DebugMenuTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(DebugMenuTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import DebugMenuTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += DebugMenuTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------