├── .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 | 
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 |
--------------------------------------------------------------------------------